Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

自己紹介 鈴木 光(すずき ひかる) ● 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

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

Summary 4

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

Motivation 6

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

開発の流れ(旧) 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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

entity定義(旧) 10

Slide 11

Slide 11 text

Solution 11

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

こうなりました 15

Slide 16

Slide 16 text

開発の流れ(旧) 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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

entity定義(旧) 18

Slide 19

Slide 19 text

19

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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パッケージだけでいいの?

Slide 29

Slide 29 text

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) } 生成されるもの

Slide 30

Slide 30 text

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 ● ツールを作っていた際には最大数分か かるものもあった パッケージが存在 すると追記される

Slide 31

Slide 31 text

// 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解析だけでわ かるので速い 使われるであろう パッケージは 予め追加しておく

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

Tips 33

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

自動生成の冪等性(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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

自動生成物の拡張 /** 自動生成ファイル */ 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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

Conclusion 44

Slide 45

Slide 45 text

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