Slide 1

Slide 1 text

PyCon JP 2021 柴内 一宏 (株式会社ブレインパッド) 2021年10月16日 Pythonのバリデーション定義から フロントエンドTypeScriptのコード生成

Slide 2

Slide 2 text

©BrainPad Inc. ● 背景 ○ 今回の導入対象のプロダクトの概要 ○ バリデーションライブラリ Marshmallow の紹介と、プロダクトでの利用について ○ 発生した課題 ● Marshmallow から TypeScript への変換 ○ スキーマ定義からの型定義生成 ○ スキーマ定義からのバリデーション定義生成 ● 実装について ● コード生成のデモ ● まとめ 発表のアウトライン 2

Slide 3

Slide 3 text

©BrainPad Inc. 背景 〜 対象プロダクト 3 ● Rtoaster insight+ というデータ管理プラットフォームの Web UI ● スケジューリングされたETL処理やユーザーセグメント出力処理がコア機能 ● 入力/出力の連携先情報やスケジュール・フォーマットの設定を行うので、 APIの数は多く、複 雑なものが多い クラウドストレージ (S3/GCS…) ⋮ JSON CSV テーブル ⋮ データ 抽出 データ 変換/統合 データ 加工/整形 JSON CSV テーブル ⋮ データ ウェアハウス サードパーティ SaaS製品 クラウド ストレージ (S3/GCS…) Push配信 プロダクト レコメンド プロダクト

Slide 4

Slide 4 text

©BrainPad Inc. 背景 〜 対象プロダクト 4 Rtoaster insight+で使われている技術要素 ● バックエンド:Python 3 on Google App Engine(GCP) ○ Webフレームワーク:Flask ○ バリデーションライブラリ:Marshmallow ○ インターフェースは REST API です ○ ( mypy での型チェック ) ● フロントエンド:TypeScript + Vue 2 (NuxtJS) ● CI/CD:CircleCI

Slide 5

Slide 5 text

©BrainPad Inc. サーバー (GCP) ざっくりしたプロダクトのアーキテクチャ 5 DWH DB ユーザー DB Vue Components (TypeScript) バックエンド ロジック JSON axios クライアントからのリクエスト (JSON)をバリデーションし つつシリアライズ/デシリアラ イズを行う dict REST API

Slide 6

Slide 6 text

©BrainPad Inc. Marshmallow の概要 6 ● フレームワーク/ORM に依存しないバリデーション・シリアライゼーションライブラリ ● クラス定義によってスキーマを記述する ● 基本的なバリデーションに加え、ネストした構造・スキーマの合成・ユーザー定義型・部分定義 などの高度なスキーマが表現できる ● dataclass との連携あり ● プロダクトでの利用 ○ 150を越えるスキーマ定義 ○ カスタムフィールドによるシリアライズ ■ 機密情報のマスク(ユーザー情報を *** のように置換) ■ テーブルRawデータからJSONへのシリアライズ

Slide 7

Slide 7 text

©BrainPad Inc. 7 ● クラス定義とバリデーション対象の Dict オブジェクトが対応 ● メンバにフィールドオブジェクトを設定する ● フィールドの型 ○ プリミティブ:Str, Int, Float ○ Nested で囲むと他のスキーマを入れ子で参照できる ○ List, Dict ○ フィールドの自作も可能 ■ 実例:MaskedString,, TableRowData, etc... class UserSchema(Schema): id = fields.Int() name = fields.Str() email = fields.Str() class UserListSchema(Schema): users = fields.List( fields.Nested(UserSchema) ) # UserSchema { "id": 123, "name": "foo", "email": "[email protected]" } # UserListSchema { "users": [{ "id": 123, "name": "foo", "email": "[email protected]" }, { "id": 456, "name": "bar", "email": "[email protected]" }, ...]} Marshmallow の概要

Slide 8

Slide 8 text

©BrainPad Inc. 8 フィールドのパラメータで値の情報を設定する ● default:デフォルト値 ● required:フィールドが必須かどうか ● allow_none:None(null)を入力してもよいか ● validation:バリデーション(後で説明) ● Nested 限定:部分スキーマ ○ only:ネストしたスキーマの一部のみを利用する ■ 例)Nested(UserSchema, only=["id"]) ● UserSchema の id フィールドのみ ○ exclude:ネストしたスキーマの一部を除外する class UserSchema(Schema): id = fields.Int(required=True) name = fields.Str(required=True) email = fields.Str( required=False, allow_none=True, validate=validate.Email()) { "id": 123, "name": "foo", "email": null } # → OK { "id": 123, "email": “foo” } # ValidationError: { 'email': ['Not a valid email address.'], 'name': ['Missing data for required field.'] } Marshmallow の概要

Slide 9

Slide 9 text

©BrainPad Inc. 9 Marshmallow の概要 ● ポリモーフィックな定義( marshmallow) ○ 種類を表わすキーによって、フィールドが決定される ● 例:ファイルフォーマットを扱うスキーマ ○ JSON:圧縮、エスケープするか、 etc... ○ CSV:改行コード、クオート有無、 etc… ● 次ページにコード例を出します。

Slide 10

Slide 10 text

©BrainPad Inc. class FileFormatSchema(OneOfSchema): # スキーマのマッピング type_schemas = { 'CSV': CsvFileFormatSchema, 'JSON': JsonFileFormatSchema, } # マッピングのキーを指定するフィールド type_field = "type" Marshmallow の概要 〜 ポリモーフィックなコード例 10 class CsvFileFormatSchema(Schema): has_header = fields.Bool() # ヘッダがあるかどうか field_delimiter = fields.Str( # フィールドの区切り validate=validate.Length(equal=1)) ... class JsonFileFormatSchema(Schema): encoding = fields.Str( validate=validate.OneOf(["utf-8", "cp932"]), ) # エンコーディング ... ..> schema = FileFormatSchema() ..> schema.load({"type": "CSV", "has_header": True}) {'has_header': True} ..> schema.load({"type": "JSON", "encoding": "utf-8"}) {'encoding': 'utf-8'}

Slide 11

Slide 11 text

©BrainPad Inc. 課題 11 ● (ア)TypeScript からの fetch リクエスト & レスポンスの型が手動で管理されていた ○ 人力で型定義を作っていた ○ 型付けミスやサボりにより、TypeScript を使っているにもかかわらず Undefined property エラーが出るなどの開発に支障が出ていた ● (イ)バリデーションの定義が冗長になっていた ○ フロントエンドのフォームの入力バリデーションと APIリクエストのバリデーション ○ どちらも必要 & できる限り一致させる必要がある ※ TypeScript 側の観点から見ると、(ア)はコンパイル時の処理、(イ)はランタイムの処理の問題 に区別できる

Slide 12

Slide 12 text

©BrainPad Inc. 課題 12 ● (ア)TypeScript からの fetch リクエスト & レスポンスの型が手動で管理されていた ○ 人力で型定義を作っていた ○ 型付けミスやサボりにより、TypeScript を使っているにもかかわらず Undefined property エラーが出るなどの開発に支障が出ていた ● (イ)バリデーションの定義が冗長になっていた ○ フロントエンドのフォームの入力バリデーションと APIリクエストのバリデーション ○ どちらも必要 & できる限り一致させる必要がある ※ TypeScript 側の観点から見ると、(ア)はコンパイル時の処理、(イ)はランタイムの処理の問題 に区別できる

Slide 13

Slide 13 text

©BrainPad Inc. 解決のアイデア 13 ● Marshmallow の定義を動的に解析し、TypeScript のコードを生成する ● 参考: ここでいう動的な解析について ○ 静的解析 → ソースコードの構文木や字句解析を行う ○ 動的解析 → ファイルをPythonとして実行し、オブジェクトやデータから情報を抽出する ● 次ページからは変換のイメージを見ていく。実装の詳細は後ほど。 class CsvFileFormatSchema(Schema): has_header = fields.Bool() field_delimiter = fields.Str( validate=validate.Length(equal= 1)) ... type CSVFormatSchema = { has_header: bool, field_delimiter: string, ... } (1) Pythonランタイムで コードを実行・評価 (2) 解析結果をTSコード に変換して出力 generator.py Pythonのスキーマ定義 import TypeScriptの型定義

Slide 14

Slide 14 text

©BrainPad Inc. Marshmallow → TypeScript 〜 オブジェクト型の定義 14 Review:TypeScriptの型 ● JSのオブジェクト(or JSON)のフィールドごとに型を付ける ○ 型がネストしたフィールドも定義可能(左図) ● 構造的部分型(Structual Subtyping)を採用しているので、定義した型の構造が合っていれば、明示せ ずとも派生型関係が認められる(右図) ○ Python3.8で導入された typing.Protocol で採用されている概念です type Foo = { a: number, b: string } let c: Foo; let a = { a: 123, b: "456" }; let b = { a: 123 }; c = a; ./ OK c = b; ./ ERROR! type AnotherType = { x: string } type SomeType = { x: number, y: AnotherType }

Slide 15

Slide 15 text

©BrainPad Inc. Marshmallow → TypeScript 〜 オブジェクト型の定義 15 ● スキーマ定義と type 定義を 1対1 でマッピングさせる ● プリミティブ型はそのまま変換: ○ Str → string, Int → number ● カスタムフィールドについては特別に対応 ○ Email → string 等 ● ネストした型についても対応 ○ List(Nested(FooSchema)) → Array ○ Dict(key=fields.Str(), value=Nested(BarSchema)) → { [key: string]: BarSchema } ● 実装の詳細については後述 ○ 次ページからは、細かい対応を見ていきます class UserSchema(Schema): id = fields.Int() name = fields.Str() email = fields.Str() class UserListSchema(Schema): users = fields.List( fields.Nested(UserSchema) ) type UserSchema = { id : number, name: str, email = fields.Str() } type UserListSchema = { users: Array }

Slide 16

Slide 16 text

©BrainPad Inc. Marshmallow → TypeScript 〜 リテラル型と Union 16 Review:TypeScriptの高度な型 ● リテラル型:静的に決定される文字列あるいは数値の型 ○ Python3.8 以降の typing.Literal 型と同じ ● ユニオン型:型の直和( Python の typing.Union) ● Discriminated Union:リテラル型をキーとしたオブジェクト型の Union のこと type L1 = 'red' | 'blue' let l1 = 'red' let l2 = 'r' + 'ed' ./ NG: 静的に決定できる必要がある type U1 = string | boolean let u1 = 'sstttrrr' let u2 = true type DU = { type: 'foo', a: number } | { type: 'bar', b: string } let u1: DU = { type: 'foo', a: 123 } let u2: DU = { type: 'bar', b: "hey"} let u3: DU = { type: 'baz', c: "no"} ./ NG!

Slide 17

Slide 17 text

©BrainPad Inc. Marshmallow → TypeScript 〜 スキーマの属性 17 ● 列挙型(EnumField)への対応:リテラルに変換 ○ Enum(“A”, “B”, “C”) → “A” | “B” | “C” ○ バリデーション(後述)において、 validate.OneOf([“a”, “b”, “c”]) のような静的なアイテムの選択にお いては、上のように変換する。 ● ポリモーフィックなスキーマ ○ リテラル型と Union Type を組み合わせて実装 class FileFormatSchema(OneOfSchema): type_schemas = { 'CSV': CsvFileFormatSchema, 'JSON': JsonFileFormatSchema, } class FileFormatSchema = {type: 'CSV'} & CsvFileFormatSchema | {type: 'JSON'} & JsonFileFormatSchema 補足: & はフィールドの結合を表す。 {a: number} & {b: string} === {a: number, b: string}

Slide 18

Slide 18 text

©BrainPad Inc. Marshmallow → TypeScript 〜 スキーマの属性 18 ● required, allow_none の対応 ○ required=False は Optional Fields として変換 ■ 例) { a?: number } ○ allow_none は T | null という union type に変換 ■ 例) { a?: number | null } ● only, exclude といった部分スキーマ ○ TS の Utility Type を利用した出力 ■ only → Pick, exclude → Omit ○ 例)Nested(FooSchema, only=["a","b"]) ■ Pick ● これで FooSchema の a, b フィールドのみが 抽出できる type X = { a: number } type Y = { a: number | null } type Z = { a.: number } ./ a は number | undefined になる let x1:X = { a: 123 } ./ OK let x2:X = { a: null } ./ NG let y1:Y = { a: null } ./ OK let y2:Y = {} ./ NG let z1:Z = { a: 123 } ./ OK let z2:Z = {} ./ OK let y2:Z = { a: null } ./ NG x1 = y1 ./ NG x1 = { a: y1.a .? 0 } ./ OK Pick<{a:number, b:string}, "a"> ./ → {a: number} ./ aフィールドを取り出す Omit<{a:number, b:string}, "a"> ./ → {b: string} ./ aフィールドを排除する

Slide 19

Slide 19 text

©BrainPad Inc. バリデーションの自動生成 19 ● これまでの実装で、フィールドの型・構造についてのTypeScriptの型エラーについてはチェックでき るようになった! ○ (ア)TypeScript からの fetch リクエスト & レスポンスの型が手動で管理されていた 問題 が解消 ● 一方で入力値に対するバリデーション(長さ・範囲・文字列フォーマット)に対する情報も Marshmallowが持っているので、これも自動生成して活用できないか? ○ (イ)バリデーションの定義が冗長になっていた 問題の解消ができる

Slide 20

Slide 20 text

©BrainPad Inc. 課題 20 ● (ア)TypeScript からの fetch リクエスト & レスポンスの型が手動で管理されていた ○ 人力で型定義を作っていた ○ 型付けミスやサボりにより、TypeScript を使っているにもかかわらず Undefined property が出るなどの開発に支障が出ていた ● (イ)バリデーションの定義が冗長になっていた ○ フロントエンドのフォームの入力バリデーションと APIリクエストのバリデーション ○ どちらも必要 & できる限り一致させる必要がある ※ TypeScript 側の観点から見ると、(ア)はコンパイル時の処理、(イ)はランタイムの処理の問題 に区別できる

Slide 21

Slide 21 text

©BrainPad Inc. バリデーションの自動生成 〜 実装の共通化 21 導入前 validateFoo(value) { … } ... class Foo: a = … バックエンドとフロントエンドでそれぞれ冗長に定 義を独立実装 人手で定義を合わせる type Foo = … class FooValidator 利用 class Foo: a = … 利用 参照 validateFoo = FooValidator.validate スキーマ定義 Component実装 Component実装 生成 スキーマ定義 導入後 スキーマ定義からバリデーションコードを生成、コンポーネントか らはそれを参照する 定義の統一化ができるようになった

Slide 22

Slide 22 text

©BrainPad Inc. Marshmallow でのバリデーション 22 ● フィールドオブジェクトの validation 引数に Validator クラスの派生クラスのオブジェクト渡す ○ marshmallow で定義されているもの(一部) ■ Length(min,max):文字列の長さ ■ Regexp(pattern):正規表現 ■ Range(min,max):数値の範囲 ■ OneOf(choices):choicesのいずれか ○ カスタムバリデータの自作もできる class TableSchema(Schema): name = fields.Str( required=True, validate=validate.Regexp( r"^[\w]{1,256}$"))

Slide 23

Slide 23 text

©BrainPad Inc. バリデーションの自動生成のアイデア 23 ● 各フィールドの定義を抜き出して、スキーマごとのクラスに静的メソッドとして出力 ● 長さや範囲・正規表現※でのチェックといった複雑でないバリデーション validation.Length(min, max) → min <= v.length && v.length <= max validation.RegExp(re) → !!v.match(/re/) validation.OneOf(choices) → choices.some(c => c === v) export class TableValidator { static validateName(v: string): boolean { return (!!v.match(/^[\w]{1,256}$/)) } } class TableSchema(Schema): name = fields.Str( required=True, validate=validate.Regexp( r"^[\w]{1,256}$")) ※ 正規表現の違いには注意が必要です

Slide 24

Slide 24 text

©BrainPad Inc. バリデーションの自動生成のアイデア 24 ● 複雑なバリデーションについては、カスタムバリデーションとして名前を付けておき、人力で TypeScriptに変換したものと対応させる案 # module custom_validate class Json(Validator): def __call__(self, value: str) -> str: try: json.loads(value) except JSONDecodeError: raise ValidationError("Not a valid JSON") return value // custom_validate.ts // 人力で翻訳 export class Json { validate(value: str): bool { try { JSON.parse(value);return true; } catch(e) { return false; } } } class FooSchema(Schema): json = fields.Str( required=True, validate=custom_validate.JSON) export class FooValidator { static validateJson(v: string):boolean { return custom_validate.Json().validate(v); } }

Slide 25

Slide 25 text

©BrainPad Inc. 実装 25 ● Marshmallow の Schema.fields というメンバ に各フィールドオブジェクトが格納されている ● 手順 ○ スキーマのコードをモジュールとしてイン ポート ○ そのモジュール内でSchemaクラスを継承 しているクラスを走査 ■ inspect.getmembers(module) ○ それぞれTypeScriptに変換し、コード出力 を行う ● コード量:最低限必要な部分だけ実装して 300行 程度(スキーマ定義等除く) >>> class UserSchema(Schema): id = fields.Int() name = fields.Str() email = fields.Str() ... >>> UserSchema().fields.keys() dict_keys(['name', 'id', 'email']) >>> UserSchema().fields {'name': , attribute=None, validate=None, required=False, load_only=False, dump_only=False, load_default=, allow_none=False, error_message...

Slide 26

Slide 26 text

©BrainPad Inc. 実装 〜 コードの一部 26 # 簡素化したもの def schema_to_ts_definition(name: str, schema: Schema) -> str: lines = [] lines.append(f"export type {name} = {{") for name, field in sorted(schema.fields.items()): if not field.required and field.default is missing: lines.append(f" {name}?: {field_to_ts_type(field)}") else: lines.append(f" {name}: {field_to_ts_type(field)}") lines.append("}") return "\n".join(lines) def field_to_ts_type(field: fields.Field) -> str: if isinstance(field, fields.Bool): return "boolean" elif isinstance(field, fields.String): return "string" elif isinstance(field, fields.List): return f"Array<{field_to_ts_type(f.inner)}>" # 再帰呼び出し ...

Slide 27

Slide 27 text

©BrainPad Inc. CI との連携 27 ● 本来は「生成物はコード管理せずに、ビルドプロセスに組み込む」のが行儀の良い実装だが、諸般の事 情によりコード生成したものもレポジトリに含めている ● スキーマの変更時にはコード生成も忘れずに行うようにする ● コード生成の結果は一意なので、単純に現在のスキーマからコード生成、レポジトリのそれと diffで比較 することで生成し忘れを検知する ○ (本当は検知後botによる自動コミットがあると尚良い)

Slide 28

Slide 28 text

©BrainPad Inc. 余談 〜 PEP 593 -- Flexible function and variable annotations 28 ● これを利用したバリデーションライブラリが実現できそう ○ そのようなバリデーションライブラリが実現できれば、 Python の型アノテーション情報か ら TypeScript の型やバリデーションコードの変換にも今回の方法で応用できる class UserData: id: Annotated[int, validation.Range(min=0)] name: Annotated[str, validation.Length(max=5)] email: Annotated[str, validation.Email(max=5)] ● Python3.9 から導入された、アノテーションに型情報に加えてメタデータを付与できる機能 ○ ユーザーが定義したオブジェクトが変数のアノテーション情報に埋め込める

Slide 29

Slide 29 text

©BrainPad Inc. まとめ 29 ● バリデーションライブラリ Marshmallow の Python のクラス定義から TypeScript のコードを 動的生成するツールを開発し、型定義とバリデーション共通化を可能とした。 ● (ア)TypeScript からの fetch リクエスト & レスポンスの型が手動で管理されていた ○ → スキーマ変更箇所が統合され、キッチリとした型がつくようになった ○ → APIのパラメータの渡し忘れ、undefined アクセス、null に対する演算エラーのバグは 減った ● (イ)バリデーションの定義が冗長になっていた ○ フロントエンド & バックエンドのバリデーション実装が共通化できた

Slide 30

Slide 30 text

©BrainPad Inc. ● 我々は Python を中心として、デジタルマーケティングツール( CDP/プライベートDMP) 「Rtoaster」など、お客さまのデータ活用を促進する様々なプロダクトを開発しています。 ● 自分たちでプロダクトを育てていく実感を持てる面白みがあります。 ○ 上流からエンジニアが関わりプロダクトに対して自身の意見を盛り込みながら開発すること が出来ます。 ● インターンシップ・23新卒・ポジション別中途採用を実施中です! ○ 詳しくは公式サイトをご覧ください。 ● Twitter: @BrainPadProduct We Are Hiring 30

Slide 31

Slide 31 text

株式会社ブレインパッド 〒108-0071 東京都港区白金台3-2-10 白金台ビル3F TEL:03-6721-7002 FAX:03-6721-7010 www.brainpad.co.jp [email protected] 本資料は、日本及び各国の著作権法に基づき保護されております。