$30 off During Our Annual Pro Sale. View Details »

高速で統一的な自動生成ツールをprotocプラグインとして実装した話

 高速で統一的な自動生成ツールをprotocプラグインとして実装した話

Go言語には総称型が実装されていないためコードを自動生成して賄うことが多いです。

ここで自動生成のソースをGo言語自体とした場合、よくある手法としてreflectパッケージによる生成が行われますが、ソースが多いと実効速度がネックとなってしまいます。 また、StructTagを活用したオプション設定は便利ですが、文字列による設定なのでタイプミスも発生します。

そこでProtocolBuffersをソースとする自動生成ツールをprotocプラグインとして実装することで、オプションを型安全にしつつ実行速度を大幅に向上させることに成功しました。 今回はこのprotocプラグインを紹介します。

QualiArts

April 23, 2022
Tweet

More Decks by QualiArts

Other Decks in Programming

Transcript

  1. 高速で統一的な自動生成
    ツールをprotocプラグイン
    として実装した話
    鈴木 光

    View Slide

  2. 自己紹介
    鈴木 光(すずき ひかる)
    ● 2019年CyberAgent新卒入社 -> QualiArts
    ● マッチングアプリ開発 -> 既存ゲーム運用 -> 既存ゲーム運用 -> 社内システム
    開発 -> 新規ゲーム開発
    ● 公式HPやエンジニアブログ、社内ツールの開発や運用も兼務
    ● 趣味は筋トレとゲーム、特技はソシャゲのデイリーをこなすこと
    ● Twitter:https://twitter.com/hikyaru_suzuki @hikyaru_suzuki
    ● GitHub:https://github.com/hikyaru-suzuki
    ○ 今日のサンプルコード:https://github.com/hikyaru-suzuki/go-con-2022-spring-sample
    もし質問などがあれば、DMやメンションなどでお気軽にどうぞ!!!
    2

    View Slide

  3. もくじ
    ● 要約
    ● 経緯と課題
    ● 解決方法
    ● 小話
    ● まとめ
    3

    View Slide

  4. Summary
    4

    View Slide

  5. 簡潔に言うと
    私のプロジェクトではスキーマ定義言語として
    Protocol Buffersを用いており、
    protocコマンドを打つと爆速で色々生成するツールを作りました
    5

    View Slide

  6. Motivation
    6

    View Slide

  7. 経緯
    ● Go(<1.18)にはジェネリクスがないため自動生成で補う必要がある
    ○ 🙅:自動生成すれば出来る
    ○ 🙆:自動生成しないと出来ない
    ● 自動生成するにあたって大きく分けて2つの「ソース」があった
    ○ gRPCのAPI通信で使うproto定義
    ○ entityと呼ばれるDTOなGoの構造体、enumなどを生成するためのGoによる定義など
    7

    View Slide

  8. 開発の流れ(旧)
    1. enum定義 -> $ make enum-gen(enumのGoコードやproto生成)
    2. setting定義 -> $ make setting-gen(settingマスタのデータを生成)
    3. entityを定義 -> $ make db-gen(entityからDB関連コード生成)
    4. packerを定義 -> $ make packer-gen(マスタ配信関連コード生成)
    5. apiを定義 -> $ make protoc(API関連コードを生成)
    6. 生成物を元に実装していく
    8

    View Slide

  9. 課題
    ● 手順や定義が色々あってわかりにくい
    ● 実行が遅い
    ○ API以外はGoによる定義からリフレクションを用いて生成していたため実
    行に数分かかっていた
    ○ 特にdb-genは生成物も多いため遅かった
    ● structタグの限界
    ○ バリデーションやjson化した時の文字列、生成するDDLに含める情報な
    どをstructタグに突っ込んでいたが、structタグはただの文字列なので
    typoや記入漏れが発生していた
    9

    View Slide

  10. entity定義(旧)
    10

    View Slide

  11. Solution
    11

    View Slide

  12. 解決案
    ● 拡張可能でシンプルなスキーマ定義ツールがほしい
    ○ Goをスキーマ定義ツールとして使うには拡張性に乏しかった
    ○ Json Schemaは冗長だな〜
    ○ できれば速いものを・・・
    12

    View Slide

  13. 解決案
    ● 拡張可能でシンプルなスキーマ定義ツールがほしい
    ○ Goをスキーマ定義ツールとして使うには拡張性に乏しかった
    ○ Json Schemaは冗長だな〜
    ○ できれば速いものを・・・
    全部解決できる
    そう Protobuf ならね
    13

    View Slide

  14. protoc plugin
    ● Protocol Buffersは拡張ができるよう設計されている
    ● --go_outや--go-grpc_outもprotoc pluiginの一種
    ○ 実際こいつらを使うためにprotoc-gen-goやprotoc-gen-go-grpcなどのバイナリをインス
    トールしているはず
    ● protocはオプションとして--hoge_outを渡すとprotoc-gen-hogeコマンド
    が呼び出されるようになっている
    ○ このとき入力されたprotoファイルやオプションなどの情報がpluginの実装に渡される
    14

    View Slide

  15. こうなりました
    15

    View Slide

  16. 開発の流れ(旧)
    1. enum定義 -> $ make enum-gen(enumのGoコードやproto生成)
    2. setting定義 -> $ make setting-gen(settingマスタのデータを生成)
    3. entityを定義 -> $ make db-gen(entityからDB関連コード生成)
    4. packerを定義 -> $ make packer-gen(マスタ配信関連コード生成)
    5. apiを定義 -> $ make protoc(API関連コードを生成)
    6. 生成物を元に実装していく
    16

    View Slide

  17. 開発の流れ(新)
    1. protoを定義する -> $ make generate(あらゆるものを生成する)
    2. 生成物を元に実装していく
    17

    View Slide

  18. entity定義(旧)
    18

    View Slide

  19. 19

    View Slide

  20. 20
    別ファイルにproto optionsを定義
    AccessorTypeという概念によって
    自動生成するものを制御する

    View Slide

  21. 21
    管理ツールのみ対象とする
    クライアントのみ対象とする
    (DB定義すらない)
    管理ツール&サーバキャッシュ
    を対象とする
    このテーブル情報は次の対象に渡す
    ● 管理ツール
    ● サーバキャッシュ
    ● クライアントキャッシュ

    View Slide

  22. 22
    特に指定しなければ管理ツール、サー
    バキャッシュ、クライアントキャッ
    シュ全てに渡す
    管理ツールとクライアント
    キャッシュを対象とする
    サーバキャッシュとクライアント
    キャッシュを対象とする

    View Slide

  23. 自動生成用protoc plugin
    ● 単一バイナリでプロジェクトで用いる全ての自動生成を賄う
    ● 生成処理はgoimportsやgofmt込みで10秒前後(数千ファイル生成)
    ● 自動生成を追加しやすい(?)
    23

    View Slide

  24. 実装
    24
    コアライブラリ
    自動生成の実装を
    ここへ追加していく

    View Slide

  25. 実装
    25
    各自動生成コードは
    Generator
    を実装していく
    ※殆どがデフォルト実装
    を使うので、実装するメ
    ソッドは事実上 Build()
    のみ

    View Slide

  26. Q. なんで速いの?
    A. I/O処理の極小化、並列処理、goimportsの最
    適化を行ったから
    ● 本当に最後だけファイルI/Oが発生するような設計に
    ○ Goテンプレートはembedで埋め込み
    ○ ファイルフォーマットさえI/Oレスで
    ● テンプレート処理、整形、ファイル出力などあらゆる処理を並列化
    ● import文を見直してgoimports向けに最適化
    26

    View Slide

  27. 27
    goimportsやgofmtのAPIで
    「ソースコード」を整形
    「ソースコード」を
    ファイルへ出力
    「/tmp/generated_files.txt」に
    出力したファイルのパスを記録
    generator配下の実装を処理して
    生成物の「ソースコード」を取得

    View Slide

  28. goimports(1/4)
    こういうこと、してませんか?
    28
    {{ template "autogen_comment" }}
    package repository
    import (
    "context"
    )
    type {{ .GoName }}Repository interface {
    SelectAll(ctx context.Context) ([]*entity.{{ .GoName }}, error)
    {{ range $col := .KeyColumns }}
    SelectBy{{ $col.PascalName }}(
    ctx context.Context,
    {{ $col.CamelName }} {{ $col.Type }},
    ) ([]*entity.{{ .GoName }}, error)
    {{ end }}
    }
    おすすめ
    Extend的なことが
    できる
    contextパッケージだけでいいの?

    View Slide

  29. goimports(2/4)
    ● 生成自体はエラーにならない
    ● gofmtもエラーにならない
    ● goimportsもエラーにならない
    ● Lintは流石にエラーとなる
    29
    // DO NOT EDITコメント
    package repository
    import (
    "context"
    )
    type HogeRepository interface {
    SelectAll(ctx context.Context) ([]*entity.Hoge, error)
    SelectByHogeType(
    ctx context.Context,
    hogeType enum.HogeType,
    ) ([]*entity.Hoge, error)
    }
    生成されるもの

    View Slide

  30. goimports(3/4)
    30
    // DO NOT EDITコメント
    package repository
    import (
    "context"
    “github.com/hikyaru-suzuki/hoge-server/pkg/enum”
    “github.com/hikyaru-suzuki/hoge-server/pkg/entity”
    )
    type HogeRepository interface {
    SelectAll(ctx context.Context) ([]*entity.Hoge, error)
    SelectByHogeType(
    ctx context.Context,
    hogeType enum.HogeType,
    ) ([]*entity.Hoge, error)
    }
    ● goimportsはパッケージが宣言されて
    いない場合、GOPATH配下などを自動
    で探索してimportへ追加する
    ○ なかったらエラーになる
    ○ これがめっちゃ重い
    ● 探索速度はGOPATH配下にあるパッ
    ケージ量に依存する
    ○ https://cs.opensource.google/go/x/tools/+/m
    aster:internal/imports/fix.go;l=1386
    ○ https://cs.opensource.google/go/x/tools/+/m
    aster:internal/gopathwalk/walk.go;l=53
    ● ツールを作っていた際には最大数分か
    かるものもあった
    パッケージが存在
    すると追記される

    View Slide

  31. // DO NOT EDITコメント
    package repository
    import (
    "context"
    “github.com/hikyaru-suzuki/hoge-server/pkg/enum”
    “github.com/hikyaru-suzuki/hoge-server/pkg/entity”
    )
    type HogeRepository interface {
    SelectAll(ctx context.Context) ([]*entity.Hoge, error)
    SelectByHogeType(
    ctx context.Context,
    hogeType enum.HogeType,
    ) ([]*entity.Hoge, error)
    }
    goimports(4/4)
    31
    ● 解決策は利用される可能性がある
    パッケージを全てimportへ追加して
    おくこと
    ● パッケージ探索はディレクトリス
    キャンが走るので重いが、未使用
    パッケージの削除はAST解析だけでわ
    かるので速い
    使われるであろう
    パッケージは
    予め追加しておく

    View Slide

  32. 並列数
    並列数を多くしすぎると
    逆にパフォーマンスが悪くなるので、
    errgroup.Groupと
    semaphore.Weightedを
    組み合わせた
    独自の並列処理ライブラリを作り、
    上限付きの並列処理をしている
    (現状は100並列)
    32

    View Slide

  33. Tips
    33

    View Slide

  34. 自動生成の冪等性(1/3)
    ● 自動生成にありがちな課題
    ○ ディレクトリ全削除 -> 自動生成としているので、
    そのディレクトリ配下に手動で作成した実装を差し
    込めない
    ○ そもそも全削除 -> 全作成が重い
    ○ ディレクトリ全削除をしないと、ゴミコードが残る
    ● これを解決するため、「gen-file-remover」
    というスクリプトを実装し、protoc plugin
    と連携するようにした
    ○ 全作成はするが差分だけ削除されるようになる
    gen-file-removerは
    100行にも満たないスクリプト
    34

    View Slide

  35. 自動生成の冪等性(2/3)
    1. protoc plugin側で自動生成物のファイルパスを
    「/tmp/generated_files.txt」に対して書き込むよう実装する
    2. protoc pluginによる自動生成が終了したら、
    gen-file-removerコマンド(バイナリ)を呼び出す
    3. gen-file-removerがプロジェクトの特定ディレクトリをスキャンし、
    自動生成物(<名前>-gen.go)を探索する
    4. 発見した自動生成ファイルが「/tmp/generated_files.txt」になければ
    それを削除する
    35

    View Slide

  36. 自動生成の冪等性(3/3)
    ディレクトリスキャンの実装
    ● filepath.Walk
    ○ 実は結構遅い
    ○ ファイルツリーを同期的に探索
    ● filepath.Walkdir
    ○ Go 1.16で追加
    ○ 不要な情報を取得しないため速い
    ○ https://github.com/golang/go/issue
    s/42027
    36

    View Slide

  37. 自動生成物の拡張
    ● ディレクトリを削除していないので自動生成物と自動生成物の拡張を同一
    パッケージに共存させることが可能
    ○ 定数や関数、メソッドなどを手動で実装できるようになる
    ○ メソッドが実装できるということはInterfaceが実装可能となるので可能性は無限大
    entity
    ├── character-ext.go
    └── character-gen.go
    手動で実装したファイル
    自動生成コマンドを再実行しても消えない!
    自動生成されたファイル
    37

    View Slide

  38. 自動生成物の拡張
    /** 自動生成ファイル */
    type HogeRepository interface {
    FindAll() []*Hoge
    }
    /** 手動作成ファイル */
    type HogeRepositoryExt interface {
    HogeRepository
    FindByName(name string) []*Hoge
    }
    手動実装による拡張例(1)
    自動生成物を埋め込み、
    新しいメソッドを追加した
    拡張実装をDIして使う
    Ext = Extension(拡張)
    38

    View Slide

  39. 自動生成物の拡張
    /** 自動生成ファイル */
    type HogeRepository interface {
    FindAll() []*Hoge
    FindByName(name string) []*Hoge
    }
    type hogeRepository struct {}
    func (h *hogeRepository) FindAll() []*Hoge { /** すごい実装 */ }
    func (h *hogeRepository) FindByName(name string) []*Hoge { /** すごい実装 */ }
    func NewHogeRepository() HogeRepository { return &hogeRepository{} }
    /** 手動作成ファイル */
    type hogeRepositoryExt struct {
    hogeRepository
    }
    func (h *hogeRepositoryExt) FindAll() []*Hoge { /** さらにすごい実装 */ }
    func NewHogeRepositoryExt() HogeRepository { return &hogeRepositoryExt{} }
    手動実装による拡張例(2)
    Interfaceとしては
    自動生成物の型だが、
    特定のメソッドだけを
    オーバーライドした
    実装をDIして使う
    39

    View Slide

  40. util
    独自実装している ToSnakeCase() 的な
    文字列変換関数は多用するので
    キャッシュした(効果は微妙)
    40

    View Slide

  41. 変数名を工夫
    ● 「名前」を定義するときはスネー
    クケースにすると良い
    ○ 🤔:Name -> character_id,
    CharacterID ?
    ○ 👍:SnakeName = character_id,
    GoName = CharacterID
    ● 自動生成の際にパスカルケース、
    キャメルケース、スネークケース
    など色々な形に変形する作業を
    行っていると、元の形が何かわか
    らず変換ミスが多発するため
    ● 元の形を統一しておけば前述の
    キャッシュが効きやすい 41

    View Slide

  42. protoc plugin実装時のログ
    ● protoc pluginではinfoなログを出したい場合にも
    標準エラー出力に出さないと自動生成物として認識されてしまい、
    「出力したやつGoのコードちゃうやんけ」と
    protocに怒られてしまうのでloggerの出力先はos.Stderrにする
    ● --sample_out: protoc-gen-sample: Plugin output is unparseable
    実はこれ
    標準エラー出力
    42

    View Slide

  43. PG*
    ● https://github.com/lyft/protoc-gen-star
    ● > !!! THIS PROJECT IS A WORK-IN-PROGRESS | THE API SHOULD BE
    CONSIDERED UNSTABLE !!!
    ○ とはいえ使っている有名なプロジェクトもある
    ■ https://github.com/envoyproxy/protoc-gen-validate
    ● 学習コストが高そう
    43

    View Slide

  44. Conclusion
    44

    View Slide

  45. まとめ
    ● 全ての自動生成定義がprotoに集約し、コマンド1発で生成可能に
    ○ APIスキーマ、DBスキーマ、関連コード etc…
    ● protoc pluginの独自実装によって自動生成が数分から10数秒に
    ○ I/Oを極限まで減らすことがポイント
    ■ この発想はprotoc pluginの実装を参考に
    ○ 並列処理を積極的に使うのも大事
    ○ import文をgoimports向けに最適化
    ● もう自動生成待ちという言い訳でサボることはできません 😎
    自動生成ライフの参考になれば嬉しいです!!!
    45

    View Slide