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

QualiArtsにおけるコード自動生成 〜 Protoc Plugin 1次ソースの美学(C...

karamaru
January 07, 2025
25

QualiArtsにおけるコード自動生成 〜 Protoc Plugin 1次ソースの美学(Cue実装もあるよ!)〜

slidev初めて使ってみたけどいい感じ!
cf. https://sli.dev/

karamaru

January 07, 2025
Tweet

Transcript

  1. 目次 1. 自動生成しているもの一覧 2. Protocol Buffers による自動生成の仕組みを 見てみる 3. Cue

    を用いた実装例の紹介 Goal 1. 事業ドメインや通信プロトコルに依らない自 動生成の利用例を知る
  2. QualiArts における自動生成対象一覧 マスタデータ (MySQL/onmemory) 構造体や関連定数 MySQL 連携(CRUD ロジック) オンメモリキャッシュロジック 管理画面における整合性チェックロジック

    ユーザーデータ (Spanner) 構造体や関連定数 Spanner 連携 CRUD ロジック Index やインターリーブ、TTL などの設定 enum: type やconst の定義 RPC (connect) Handler 適用するmiddleware 処理 管理画面からのテスター Batch (cobra) Ranking (Redis) Log 定義 環境変数読み込み Debug コマンド (connect<->next 画面) などなど… -> 具体例を見ていこう!
  3. マスタデータ / 定義proto ファイル message 名「Card 」がMySQL テーブル名を示す 各message/field のコメントはMySQL

    コメントに対応する バリデーションは誤ったデータを入力させないために機能 オンメモリキャッシュのキー追加したり、クライアント用(Unity エンジニア向け) のproto 生成制御をしたりする際のオプション設 定 マスタデータ: 運営が用意する不変データ。カードゲームにおける「カード」など。 // カード message Card { // カードID(PK) string id = 1 [(options.entity.field) = { schema: {pk: true} validations: [{key: "required"}] }]; // キャラクターID( キャラクターマスタへのFK) string character_id = 2 [(options.entity.field) = { schema: { master_ref: {table: "character", column: "id"} } validations: [{key: "required"}] }]; // 名前(10 文字以下) string name = 3 [(options.entity.field) = { validations: [{key: "required"}, {key: "max", value: "10"}] }]; // レアリティ enums.card_rarity.CardRarity rarity = 4 [(options.entity.field) = { validations: [{key: "required"}] }]; option (options.master.message) = { // PK 以外でサーバーマスタのキャッシュキーが必要な場合は以下のように設定する cache_keys: [{columns: ["character_id"]}] // クライアント用のproto ファイルに出力させない場合は以下のように設定する accessor_type: ADMIN_AND_SERVER };
  4. マスタデータ / 自動生成されたgo ファイル(sql, entity, validation 編) 基本的な構造体とgenerics で賄えない便利メソッド群を生成する PK

    情報は次に掲載するrepository の引数に、FK 情報は管理画面に おけるジャンプなどに使われる validate タグはプランナーの入力ミス対策用途で使用される。 github.com/go-playground/validator ベース。 sql はproto のフィールド名やコメントに対応したものが出力され る // Card カード type Card struct { // カードID(PK) ID string `json:"id,omitempty" validate:"required"` // キャラクターID( キャラクターマスタへのFK) CharacterID string `json:"character_id,omitempty" validate:"required,master_ref=character$id" // 名前(10 文字以下) Name string `json:"name,omitempty" validate:"required,max=10"` // レアリティ Rarity enum.CardRarity `json:"rarity,omitempty" validate:"required,enum"` } // CardSlice カード配列 type CardSlice []*Card // 他、便利メソッド群 CREATE TABLE `card` ( `id` VARCHAR(255) NOT NULL COMMENT ' カードID(PK)', `character_id` VARCHAR(255) NOT NULL COMMENT ' キャラクターID( キャラクターマスタへの `name` VARCHAR(255) NOT NULL COMMENT ' 名前(10 文字以下)', `rarity` INT NOT NULL COMMENT ' レアリティ', PRIMARY KEY (`id`) ) ENGINE = InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=' カード';
  5. マスタデータ / 自動生成されたgo ファイル(MySQL, キャッシュ編) MySQL のRepository 生成 ※ MySQL

    用の構造体とキャッシュ用の構造体は別で定義される オンメモリキャッシュ(サーバーマスター)のRepository 生成 MySQL にあるマスタデータをサーバー起動時にオンメモリにキャッシュする実装を自動生成している // pkg/domain/repository/master/card.gen.go // CardRepository MySQL 疎通I/F type CardRepository interface { SelectAll(ctx context.Context, tx database.ROTx) (master.CardSlice, error) BulkInsert(ctx context.Context, tx database.RWTx, rows master.CardSlice) error // その他CRUD メソッド } // pkg/infra/mysql/repository/card.gen.go type cardRepository struct { db *sql.DB } func (r *cardRepository) SelectAll(ctx context.Context, tx database.ROTx) (master.CardSlice, error) { // MySQL 実装... // pkg/domain/repository/server_master/card.gen.go // CardCommandRepository キャッシュ(サーバーマスター)を詰めるI/F type CardCommandRepository interface { Set(rows server_master.CardSlice) } // CardQueryRepository キャッシュ(サーバーマスター)からデータを取得するI/F type CardQueryRepository interface { GetAll() server_master.CardSlice GetByPK(id string) *server_master.Card LoadByPK(id string) (*server_master.Card, error) GetByCharacterID(characterID string) server_master.CardSlice } // pkg/infra/inmemory/server_master/repository/card.gen.go type cardRepository struct { rows server_master.CardSlice mapByPK map[string]*server_master.Card mapByCharacterID map[string]server_master.CardSlice } func (r *cardRepository) Set(rows server_master.CardSlice) { // MySQL データからオンメモリにキャッシュする実装など...
  6. ユーザーデータの自動生成 左proto から構造体やRepository 、sql やクエリキャッシュ* が生成さ れる。 @see: https://gocon.jp/2022spring/sessions/lt4/ ユーザー依存の動的なデータ。カードゲームにおける「カード所持情報」など。

    // 所持カード message UserCard { // ユーザーID string user_id = 1 [ (options.entity.field) = { schema: {pk: true} } ]; // カードID string card_id = 2 [(options.entity.field) = { schema: { pk: true master_ref: {table: "card", column: "id"} } }]; // 現在の経験値 int64 exp = 3; option (options.entity.message) = { schema: { // interleave の設定 interleave: {parent: "user"} // index の設定 indexes: [{keys: [{column: "card_id"}]}] } }; option (options.transaction.message) = { // オプション。クライアントに更新差分を返却するかなど。 accessor_type: ALL_WITH_COMMON_RESPONSE, // UserCard 所持カード type UserCard struct { // ユーザーID UserID string `json:"user_id,omitempty"` // カードID CardID string `json:"card_id,omitempty" validate:"master_ref=card$id"` // 現在の経験値 Exp int64 `json:"exp,omitempty"` // 作成日時 CreatedTime time.Time `json:"created_time,omitempty"` // 更新日時 UpdatedTime time.Time `json:"updated_time,omitempty"` } type UserCardPK struct { UserID string CardID string } type UserCardRepository interface { LoadByPK(ctx context.Context, tx database.ROTx, pk *transaction.UserCardPK) (*transaction.UserCard, Insert(ctx context.Context, tx database.RWTx, row *transaction.UserCard) error
  7. 他にも… Ranking (Redis) 関連コードを生成したり CobraCLI を生成したり RPC ではリクエストのバリデーションや実行するmiddleware を制御したり //

    カード経験値ランキング message CardExp { // 降順 option (options.entity.ranking_message) = {score_order: DESC}; // カードID string card_id = 1 [(options.entity.field) = { schema: { master_ref: { table: "card" column: "id" } } }]; } // CardExpRK カード経験値ランキングランキングキー (impl dto.RankingKey) type CardExpRK struct { CardID string } // Ranking Redis#ZSET へのI/F type Ranking interface { Add(ctx context.Context, key dto.RankingKey, id string) error Rank(ctx context.Context, key dto.RankingKey, id string) (int, error) Range(ctx context.Context, key dto.RankingKey, start, end int) (dto.RankingRangeResults, error) } // Redis 疎通実装コードも別package に生成される... // コマンド例 message Example { // サブコマンド例 message Run { // オプション例 string str = 1 [(options.batch.field) = {validations: [{key: "required"}]}]; } } $ go run ${PATH} example run --str="hoge" // success! str="hoge" // カード service Card { // 強化 rpc Enhance(CardEnhanceRequest) returns (google.protobuf.Empty) { // ここに無効化する/ 適用するmiddleware を記述していく。未認証で強化って何やねんって感じですけどorz option (options.api.method) = {disable_auth: true}; } } message CardCardEnhanceRequest { // カードID string card_id = 1 [(buf.validate.field).string.min_bytes = 1]; }
  8. Protoc Plugin protoc は独自の" プラグイン" を実装することで拡張可能。以下が標準的* な流れ。 1. protoc がproto

    ファイルの情報を標準入力に出力 2. plugin がそれを受け取り出力したい内容を標準出力 3. protoc が受け取り、ファイルを生成 cf. https://github.com/protocolbuffers/protobuf/blob/main/src/google/protobuf/compiler/plugin.proto protoc (aka the Protocol Compiler) can be extended via plugins. A plugin is just a program that reads a CodeGeneratorRequest from stdin and writes a CodeGeneratorResponse to stdout. CodeGeneratorResponse CodeGeneratorRequest proto ファイル protoc 自動生成ファイル 独自plugin QualiArts ではProtocPlugin を利用してコード生成してます // 最小実装 protogen.Options{}.Run(func(plugin *protogen.Plugin) error { for _, file := range plugin.Files { generatedFile := plugin.NewGeneratedFile( file.GeneratedFilenamePrefix+".gen.go", file.GoImportPath, ) generatedFile.P("package " + file.GoPackageName + " // hoge") }
  9. マスタデータのentity 生成簡易版を例にしてみよう! 1. このproto から → 2. "protoc-gen-qua" という独自プラグインを実装して 3.

    このgo ファイルを出したい! QualiArts ではbuf-connect を使用しているよ message Card { // ID string id = 1; // 名前 string name = 2; } buf generate --template buf.gen.master.yaml --path proto/entity/master # buf.gen.master.yaml version: v2 plugins: - local: protoc-gen-qua out: ../.. opt: - gen_entity # 対応するフラグをセット - output_dir=.. # 適当な出力PATH // カード type Card struct { // ID ID string // 名前 Name string }
  10. 1. メッセージを解析する このproto から このgo ファイルを出したい! こんな解析ができる SetMessage 関数があれば良さそう! //

    カード message Card { // ID string id = 1; // 名前 string name = 2; } // カード type Card struct { // ID ID string // 名前 Name string } // めっちゃ簡略ver func ConvertMessageFromProto(file *protogen.File) (*Message, error) { message := &Message{} if err := result.SetMessage(file.Messages[0]); err != nil { return nil, err } // fmt.Printf("message: %v\n", message) // { // PascalName: "Card" // Comment: " カード" // Fields: []*Field{ // { // PascalName: "ID" // Comment: "ID" // GoType: "string" // } // { // PascalName: "Name" // Comment: " 名前" // GoType: "string" // } // } // }
  11. 1. メッセージを解析する(実装) Message の解析 Field の解析 ※ 本当は共通部分をgenerics で切り出し拡張できるようにしているが割愛 type

    Message struct { PascalName string Comment string Fields []*Field } func (m *Message) SetMessage(message *protogen.Message) err m.PascalName = ToPascalCase(string(message.Desc.Name())) m.Comment = message.Comments.Leading.String() m.Fields = make([]*Field, 0, len(message.Fields)) for _, field := range message.Fields { f := new(Field) if err := f.SetField(field); err != nil { return err } m.Fields = append(m.Fields, f) } } type Field struct { PascalName string Comment string GoType string } // SetField フィールドの解析 func (f *Field) SetField(field *protogen.Field) error { f.PascalName = ToPascalCase(field.Desc.TextName()) f.Comment = field.Comments.Leading.String() switch field.Desc.Kind() { case protoreflect.StringKind: f.GoType = "string" // ...
  12. 2. Go Template でファイルデータを作成 ※ 最終的にはこれ↓になる テンプレートにproto ファイルから読み取ったデータを代入する // path/to/entity.gen.go.tpl

    // {{ .PascalName }} {{ .Comment }} type {{ .PascalName }} struct { {{- range .Columns }} // {{ .Comment }} {{ .PascalName }} {{ .GoType }} {{- end }} } //go:embed types.gen.go.tpl var templateFileBytes []byte func hoge(message *Message) { tpl := template.Must( template.New("path/to/entity.gen.go.tpl", ).Parse(string(templateFileBytes))), newFileData := &bytes.Buffer{} if err := c.tpl.Execute(newFileData, message); err != nil return err } } // カード type Card struct { // ID ID string // 名前 Name string }
  13. 3. goimports/gofmt で自動生成コードを整形 ※ tpl はパフォーマンスに配慮した書き方をする必要がある(後述) go 標準のformatter にあやかる //

    goimports importsData, err := imports.Process("", newFileData, &imports.Options{}) if err != nil { return err } // gofmt fmtData, err := format.Source(importsData) if err != nil { return err } return fmtData
  14. 4. 自動生成コードをファイルとして出力 ProtocPlugin 標準にあやかりprotoc に任せる形式 CodeGeneratorResponse CodeGeneratorRequest proto ファイル protoc

    自動生成ファイル 独自plugin 標準出力経由のオーバーヘッドが気にあったり、自身で並列数を管理したい場合などは os#Create や file#Write を使用し自力でファイル書き込みを行う。(qua はこっち) 今まで[]byte だったものを実際にファイルに書き込む // generateByProtoc にデータを標準出力経由でパスしてファイル書き込みを任せる (※ 簡略イメージ) func generateByProtoc(plugin *protogen.Plugin, newFileData []byte, filePath string) error { generatedFile := plugin.NewGeneratedFile(filePath, "") if _, err := generatedFile.Write(newFileData); err != nil { return err } return nil }
  15. 工夫ポイントなど 上限付き並列処理でファイル生成 (errgroup×semaphore) goimports で使用される可能性のあるpackage はtpl 段階であらかじめ全て宣言 import 文に適切なpackage がないとgoimports

    実行時に極端にパフォーマンスが落ちる(削除は早いが追加が大変) 時間がかかるファイルはwarn でlog を出すなどして追加漏れ対応を行なっている 古い自動生成ファイルは自動で削除する様に。 package master import ( "ctx" "{{ template "go_local_module" }}/pkg/util/hoge" ) # 開始時刻を保存 start_timestamp="$(date -d '1 second ago' +'%Y-%m-%d %H:%M:%S')" # 自動生成... # 開始時刻より古い日時に作成された自動生成ファイルは削除する find pkg -name '*.gen.go' -not -newermt "${start_timestamp}" -exec rm {} +
  16. 再掲: 様々なファイルがproto ファイルから自動生成されているんだね! マスタデータ(MySQL/onmemory ) ユーザーデータ(Spanner ) enum RPC (connect

    ) Batch (cobra ) Ranking (Redis ) Log 環境変数 Debug コマンド … CodeGeneratorResponse CodeGeneratorRequest proto ファイル protoc 自動生成ファイル 独自plugin
  17. 必ずしも gRPC の採用が必要というわけじゃない! Protoc Plugin はproto ファイルのパースや自動生成ファイルのI/O を手伝ってくれるだけ 「定義ファイルのパース」などは比較的簡単に自作できる ->

    gRPC の採用や定義ファイルを proto にしない場合でも独自自動生成は行える! たとえば、定義ファイルに Json の型付きスーパーセット Cue を使用した場合を考えてみる。 。 。
  18. Cue を用いた自動生成例 - 定義 ユーザーデータ定義.cue cue vet で定義が正しいかバリデーションできるのが強み。type に不正値↓ ユーザーデータ定義自体のバリデーション

    package transaction // ユーザー user: { columns: { // ユーザーID user_id: {pk: true, type: "string"} // 年齢 age: {type: "int"} // 管理者か is_admin: {type: "bool"} } } package transaction [Name=_]: #table & { // field 名がname に入る name: Name } // テーブル #table: { // テーブル名(snake_case) name: =~"^[a-z_]*$" // カラム一覧 columns: [Name=_]: #column & { name: Name } } // カラム #column: { // カラム名(snake_case) name: =~"^[a-z_]*$" // プライマリキーか pk: bool | *false // 型 type: "string" | "int" | "bool" // 配列か is_list: bool | *false }
  19. Cue を用いた自動生成例 - パース Cue ファイルを`cue.Value` 型で持ってくる `cue.Value` を解釈してGo の型に落とし込む。

    自力でも結構簡単にできるね! import ( // ... "cuelang.org/go/cue" "cuelang.org/go/cue/cuecontext" "cuelang.org/go/cue/load" ) func hoge(cueFilePaths []string) (cue.Value, error) { ctx := cuecontext.New() instances := load.Instances(cueFilePaths, &load.Config{ModuleRoot: moduleRoot}) var value cue.Value for _, instance := range instances { if instance.Err != nil { return cue.Value{}, xerrors.Wrap(instance.Err, "cue ファイルのロードに失敗しました") } v := ctx.BuildInstance(instance) if v.Err() != nil { return cue.Value{}, xerrors.Wrap(v.Err(), "cue ファイルのビルドに失敗しました") } if err := v.Validate(); err != nil { return cue.Value{}, xerrors.Wrap(err, "cue ファイルのバリデーションに失敗しました") } value = value.Unify(v) } return value, nil } import ( // ... "cuelang.org/go/cue" ) type Table struct { Name string Columns []*Column } type Column struct { Name string PK bool Type string IsList bool } // parseTransactionEntity cue ファイルからテーブル情報を解釈する func parseTransactionEntity(value cue.Value) (*Table, error) { return Decode[Table](value) } // Decode cue ファイルをUnmarshal する。 func Decode[T any](cueValue cue.Value) (*T, error) { var decoded T if err := cueValue.Decode(&decoded); err != nil { return nil, err }
  20. 色々な自動生成元ファイル QualiArts: ProtoBuf のみ 弊社はgRPC (buf-connet )を使用している 自動生成定義のシンタックスを一元化でき学習コストが低い DB 操作や固定ロジックもproto

    からの生成に寄せている cf. https://speakerdeck.com/qualiarts/gao-su-detong-de-nazi-dong- sheng-cheng-turuwoprotocpuraguintositeshi-zhuang-sitahua その他1 次ソース候補 Go: ロジックをGo 学習コストは最も低い。 SQL: DB 操作系やモデルなどの生成元になり得る。 Cue: 定義自体のvet ができるのは便利。一方でコードジャンプできないな どエコシステムがネックか。 QraphQL: proto と同様の利用ができそう。 Yaml, Json などなど… -> プロジェクトに合った1 次ソースの選択が大切! 何を生成元に選ぶかはプロジェクト次第。書き味と親和性で選ぶ。