Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Pythonのバリデーション定義から フロントエンドTypeScriptのコード生成

BrainPad
October 19, 2021

Pythonのバリデーション定義から フロントエンドTypeScriptのコード生成

PyCon JP 2021の登壇資料です。

BrainPad

October 19, 2021
Tweet

More Decks by BrainPad

Other Decks in Programming

Transcript

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

    View Slide

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

    View Slide

  3. ©BrainPad Inc.
    背景 〜 対象プロダクト
    3
    ● Rtoaster insight+ というデータ管理プラットフォームの Web UI
    ● スケジューリングされたETL処理やユーザーセグメント出力処理がコア機能
    ● 入力/出力の連携先情報やスケジュール・フォーマットの設定を行うので、
    APIの数は多く、複
    雑なものが多い
    クラウドストレージ
    (S3/GCS…)

    JSON
    CSV
    テーブル

    データ
    抽出
    データ
    変換/統合
    データ
    加工/整形
    JSON
    CSV
    テーブル

    データ
    ウェアハウス
    サードパーティ
    SaaS製品
    クラウド
    ストレージ
    (S3/GCS…)
    Push配信
    プロダクト
    レコメンド
    プロダクト

    View Slide

  4. ©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

    View Slide

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

    View Slide

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

    View Slide

  7. ©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 の概要

    View Slide

  8. ©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 の概要

    View Slide

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

    View Slide

  10. ©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'}

    View Slide

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

    View Slide

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

    View Slide

  13. ©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の型定義

    View Slide

  14. ©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
    }

    View Slide

  15. ©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
    }

    View Slide

  16. ©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!

    View Slide

  17. ©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}

    View Slide

  18. ©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}
    ./ aフィールドを取り出す
    Omit
    ./ → {b: string}
    ./ aフィールドを排除する

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  22. ©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}$"))

    View Slide

  23. ©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}$"))
    ※ 正規表現の違いには注意が必要です

    View Slide

  24. ©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);
    }
    }

    View Slide

  25. ©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':
    ow.missing>, attribute=None,
    validate=None, required=False,
    load_only=False, dump_only=False,
    load_default=,
    allow_none=False, error_message...

    View Slide

  26. ©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" # 再帰呼び出し
    ...

    View Slide

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

    View Slide

  28. ©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 から導入された、アノテーションに型情報に加えてメタデータを付与できる機能
    ○ ユーザーが定義したオブジェクトが変数のアノテーション情報に埋め込める

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide