現場で即使える Go開発実践テクニック集 4

「Protocol Buffers」からGoコードを自動生成 ー独自protocプラグイン開発と「1次ソース」の設計思想

第4回の今回は、Goの実装Tipsとして「Protocol Buffers」を「1次ソース」に独自protocプラグインを開発してGoコードを自動生成する手法とその設計思想について解説します。

唐木 稜生

6:30

はじめに

株式会社サイバーエージェントでソフトウェアエンジニアをしている唐木稜生(@karamaru_alpha)です。

前回までは、独自エラーを自作しながら、Goのスタックトレースの仕組みについて学びました。

今回のテーマは「Protocol Buffers(以下ProtoBuf)からのGoコード自動生成」です。ProtoBufはgRPCの通信定義に使われるスキーマ言語として広く知られていますが、その真価は「あらゆるコードの生成元となる1次ソース」として活用できることです。

本記事では、独自のprotocプラグインを開発してprotoファイルからGoコードを自動生成する方法や、その効果的な活用事例について解説します。

また、1次ソースは必ずしもProtoBufである必要はないため、記事の後半ではその選定基準についても触れます。

「1次ソース」としてのProtoBuf

筆者が携わるゲームサーバー開発では、protoファイルを唯一の定義元(1次ソース)として、エンティティや設定ファイルを自動生成しています。

この設計の最大のメリットは、頻繁にコピー&ペーストされるコードをレビューする必要がなくなることです。

例えば、SQLテーブルを新たに作成する際、そのテーブルを主キーでSELECTするコードや、それを抽象化するインターフェース、テーブルを表現する構造体はほぼ確実にセットで書かれることになります。

CREATE TABLE "card" (
  "id" text,
  "name" text,
  PRIMARY KEY ("id")
);
type Card struct {
	id   string
	name string
}
type CardRepository interface {
	SelectByPk(ctx context.Context, id string) (*entity.Card, error)
}
type cardRepository struct {
	db *sql.DB
}
func (r *cardRepository) SelectByPk(ctx context.Context, id string) (*entity.Card, error) {
	// SELECT * FROM card WHERE id = ? を実行する定型処理
}

こうしたコードは、テーブル名やカラム名が違うだけの同じ構造にもかかわらず、テーブルの数だけ書かなければなりません。

手書きではヒューマンエラーが入り込みやすく、レビュアーも定型コードばかりの差分を何度も確認することになります。

そこで、ProtoBufで以下のような定義を書くだけで、これらのコードがすべて自動生成されるとしたらどうでしょうか。

実装者はProtoBufの定義さえ書けば生成された構造体をそのまま利用でき、SQLスキーマやCRUD処理を手書きする必要は一切なくなります。

message Card {

  string id = 1 [(options.entity.field) = {
    schema: {pk: true}
    validations: [{key: "required"}]
  }];

  string name = 2 [(options.entity.field) = {
    validations: [{key: "required"}]
  }];
}

本記事では、このようにProtoBufの定義を1次ソースとして、そこから好きなコードを自動生成する手法について学びます。

なお、1次ソースの定義ファイルは宣言的に記述できるものであれば何でも構いません。

そのうえで筆者がProtoBufを選んでいるのは次のような強みがあり、1次ソースとしての適性が高いと考えているためです。

  • スキーマの表現力: 型・コメント・ネスト構造を簡潔に記述できる
  • カスタムオプション: バリデーションなどの独自ルールをメタデータ指定できる
  • エコシステム: パーサーやコード生成基盤(protoc、buf、protogen)が成熟している

protocプラグインの仕組み

ProtoBufのコード生成は「protocプラグイン」という仕組みで拡張できます。仕組みは非常にシンプルで、プラグインの実体は標準入出力でやり取りする単なる実行バイナリです。

  1. protoc(またはbuf)がprotoファイル群をパースする
  2. パース結果をCodeGeneratorRequest(ProtoBufメッセージ)として、プラグインの標準入力に渡す
  3. プラグインは生成したいファイルのパスと内容をCodeGeneratorResponseとして標準出力に返す
  4. 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.

つまり、protoファイルの構文解析やファイルI/Oはすべて公式ライブラリに任せられるため、我々プラグイン開発者は「パース済みの定義情報をどんなコードに変換するか」というロジックだけに集中できます。

この手軽さこそが、ProtoBufによる独自プラグイン開発をおすすめする理由です。

プラグインはprotoc-gen-xxxという命名規則でPATH上に配置すると、protoc --xxx_out=.のように呼び出せます。

最小構成で動かしてみる

具体的な実装に入る前に「protoを1つ用意して、独自プラグインでGoファイルを1つ生成する」最小構成を動かし、全体の流れを掴みましょう。

用意するファイルは3つだけです。

まず、生成元となるprotoファイルです。中身は空のmessageで構いません。

// proto/entity/hoge.proto
syntax = "proto3";

package entity;

option go_package = "example.com/test/hoge";

message Hoge {}

次に、プラグイン本体です。Goでのプラグイン開発には、公式のgoogle.golang.org/protobuf/compiler/protogenパッケージを使います。

前述の標準入出力のハンドリング(CodeGeneratorRequest/CodeGeneratorResponseのやり取り)はprotogen.Options{}.Run()がすべて面倒を見てくれるため、実装するのは「渡されたprotoファイルごとに.gen.goファイルを1つ生成する」処理だけです。

// cmd/protoc-gen-hoge/main.go
package main

import (
	"fmt"

	"google.golang.org/protobuf/compiler/protogen"
)

func main() {
	protogen.Options{}.Run(func(plugin *protogen.Plugin) error {
		for _, file := range plugin.Files {
			generatedFile := plugin.NewGeneratedFile(
				fmt.Sprintf("%s.gen.go", file.GeneratedFilenamePrefix),
				file.GoImportPath,
			)
			generatedFile.P(fmt.Sprintf("package %s // generated by plugin!", file.GoPackageName))
		}
		return nil
	})
}

最後に、生成設定です。Bufを使用する場合、buf.gen.yamllocal:でプラグインを登録するだけで、独自プラグインも公式プラグインと同じように実行できます。

# buf.gen.yaml
version: v2
plugins:
  - local: protoc-gen-hoge
    out: .

プラグインをビルドして、bufから実行してみます。

$ buf generate --template buf.gen.yaml --path proto/entity

すると、次のGoファイルが生成されます。

// hoge.gen.go(自動生成されたファイル)
package hoge // generated by plugin!

まだ中身は1行ですが、「protoを定義し、プラグインがGoファイルを書き出す」というパイプラインはこれで完成です。

あとはプラグインの生成処理を拡張していくだけで、どんなコードでも生成できるようになります。

独自プラグインを実装する

それでは実践編です。以下のproto定義から、Goの構造体を出力するプラグインを記述してみましょう。

message Card {
  string id = 1;
  string name = 2;
}

1. 構造体を出力する

最小構成のmain.goを少しだけ拡張します。

protogenがパース済みのmessage定義(file.Messages)とフィールド定義(m.Fields)を走査し、gf.P()で構造体を1行ずつ書き出します。

(後ほどGoTemplateに置き換えます)

// cmd/protoc-gen-hoge/main.go
package main

import (
	"google.golang.org/protobuf/compiler/protogen"
	"google.golang.org/protobuf/reflect/protoreflect"
)

func main() {
	protogen.Options{}.Run(func(plugin *protogen.Plugin) error {
		for _, file := range plugin.Files {
			if !file.Generate {
				continue
			}
			gf := plugin.NewGeneratedFile(file.GeneratedFilenamePrefix+".gen.go", file.GoImportPath)
			gf.P("// Code generated by protoc-gen-hoge. DO NOT EDIT.")
			gf.P("package ", file.GoPackageName)
			for _, m := range file.Messages {
				gf.P()
				gf.P("type ", m.GoIdent.GoName, " struct {")
				for _, f := range m.Fields {
					gf.P("\t", f.GoName, " ", goType(f))
				}
				gf.P("}")
			}
		}
		return nil
	})
}

// goType protoの型に対応するGoの型名を返す
func goType(f *protogen.Field) string {
	switch f.Desc.Kind() {
	case protoreflect.StringKind:
		return "string"
	default:
		// 必要になった型から順次追加する
		return "any"
	}
}

bufから実行すると、以下のファイルが生成されます。

// card.gen.go(自動生成されたファイル)
// Code generated by protoc-gen-hoge. DO NOT EDIT.
package entity

type Card struct {
	Id   string
	Name string
}

2. GoTemplateで出力する

gf.P()での逐次出力は手軽ですが、生成するコードが複雑になると全体像が掴みにくいです。

そこで、出力形式を標準ライブラリのtext/template(GoTemplate)に切り出します。

const entityTemplate = `// Code generated by protoc-gen-hoge. DO NOT EDIT.
package entity

{{ range .Messages }}
type {{ .GoName }} struct {
{{- range .Fields }}
	{{ .GoName }} {{ .GoType }}
{{- end }}
}
{{ end }}
`

次に、{{ .Messages }}{{ .GoName }}に対応するデータ構造を用意し、テンプレートの生成を実行します。

この実行結果をNewGeneratedFileに書き込むだけで、自動生成が完了します。

// テンプレートに渡すデータ構造
type templateData struct {
	Messages []*messageData
}

type messageData struct {
	GoName string // message名(例: Card)
	Fields []*fieldData
}

type fieldData struct {
	GoName string // フィールド名(例: Id)
	GoType string // Goの型名(例: string)
}

func main() {
	protogen.Options{}.Run(func(plugin *protogen.Plugin) error {
		for _, file := range plugin.Files {
			if !file.Generate {
				continue
			}

			// protoの定義情報をテンプレート用の構造体に詰め替える
			data := &templateData{}
			for _, m := range file.Messages {
				msg := &messageData{GoName: m.GoIdent.GoName}
				for _, f := range m.Fields {
					msg.Fields = append(msg.Fields, &fieldData{GoName: f.GoName, GoType: goType(f)})
				}
				data.Messages = append(data.Messages, msg)
			}

			// テンプレートに流し込み、生成ファイルに書き込む
			var buf bytes.Buffer
			tpl := template.Must(template.New("entity").Parse(entityTemplate))
			if err := tpl.Execute(&buf, data); err != nil {
				return err
			}
			gf := plugin.NewGeneratedFile(file.GeneratedFilenamePrefix+".gen.go", file.GoImportPath)
			if _, err := gf.Write(buf.Bytes()); err != nil {
				return err
			}
		}
		return nil
	})
}

生成されるファイルはステップ1とまったく同じですが、「何を生成するか」がテンプレートに集約されたことで、今後フィールドのコメントやメソッドを生成対象に加えたくなっても、テンプレートの修正だけで対応できるようになりました。

3. カスタムオプションで独自のメタデータを扱う

ProtoBufはカスタムオプションを気軽に作れるのも強みの1つです。構造体のフィールドに「PKかどうか」を示すbool値をメタデータとして付帯させてみましょう。

まずはoptionの定義です。カスタムオプションはgoogle/protobuf/descriptor.protoFieldOptionsを拡張して定義します。

// proto/options/entity.proto
syntax = "proto3";

package options;

import "google/protobuf/descriptor.proto";

option go_package = "example.com/hoge/pkg/pb/options";

message FieldOption {
  // 主キー指定
  bool pk = 1;
}

extend google.protobuf.FieldOptions {
  FieldOption field = 50000; // 番号は他のオプション拡張と衝突しない値を選ぶ
}

定義したオプションは、フィールドの[]内に指定できます。

import "options/entity.proto";

message Card {
  string id = 1 [(options.field) = {pk: true}];
  string name = 2;
}

プラグイン側では、proto.GetExtensionでオプションの値を取り出せます。

import (
	"google.golang.org/protobuf/proto"
	"google.golang.org/protobuf/types/descriptorpb"

	optionspb "example.com/hoge/pkg/pb/options"
)

func getFieldOption(field *protogen.Field) *optionspb.FieldOption {
	opts, ok := field.Desc.Options().(*descriptorpb.FieldOptions)
	if !ok {
		return nil
	}
	ext, ok := proto.GetExtension(opts, optionspb.E_Field).(*optionspb.FieldOption)
	if !ok {
		return nil
	}
	return ext
}

あとは、フィールドの走査時にこの関数を呼ぶだけです。

主キーが分かればCREATE TABLE文のPRIMARY KEY句やSelectByPkメソッドの生成の際に便利でしょう。

PKを定義する他にも、indexの指定や、構造体のバリデーションなどにもこのカスタムオプションは活用できるでしょう。

4. Tips: 高速化のためimportはテンプレート内に事前定義する

生成したGoコードはgolang.org/x/tools/imports(goimports)をプラグインに組み込んで整形すると良いでしょう。

インデントの整形に加えて、import文の追加・削除も自動で行ってくれます。

import "golang.org/x/tools/imports"

func format(src []byte) ([]byte, error) {
	return imports.Process("", src, &imports.Options{
		Comments:  true,
		TabIndent: true,
		TabWidth:  8,
	})
}

ただし、生成対象が数百〜数千ファイルの規模になると、このgoimportsが生成時間の支配的なボトルネックになります。

imports.Processは、コード中に未解決のパッケージ参照(enum.Hogeなど)を見つけると、「どのパッケージをimportすべきか」をモジュールから探索するためです。

そこで、goimportsにimport文を「追加」させるのではなく、テンプレート側に使う可能性のあるimportをあらかじめ列挙しておき、goimportsの仕事を「不要なimportの削除」だけに限定します。

削除は構文解析だけで完結するため、探索と比べて桁違いに高速です。

const entityTemplate = `// Code generated by protoc-gen-hoge. DO NOT EDIT.
package entity

import (
	"context"
	"time"

	"example.com/hoge/pkg/domain/enum"
)

生成内容によっては使われないimportも含まれますが、それはgoimportsが安価に削除してくれるため問題ありません。

様々な自動生成対象

ここまでは構造体の生成を例にしてきましたが、この仕組みの適用範囲はエンティティやSQLに限りません。「定義から機械的に導出できる定型物」であれば何でも生成対象にできます。

実際に筆者のプロジェクトでは、protoファイルを1次ソースとして次のようなものを自動生成しています。

  • エンティティ(PostgreSQL・Spanner)の構造体・CRUD処理
  • ランキング(Redis)のキー定義とその操作
  • 環境変数を読み込むコンフィグ構造体
  • ログスキーマ(BigQuery)
  • デバッグ用CLIコマンド

一例として、ランキングを見てみましょう。

RedisのSorted Setでランキングを実装する場合、「キー文字列の組み立て」と「スコア登録・上位取得といった操作」は、ランキングの種類だけ書かれる典型的な定型コードです。

そこで、ランキングキーとその特性をprotoで定義してしまいます。

// カード経験値ランキング
message CardExpRanking {
  option (options.ranking.message) = {
    // 降順ランキング
    order: DESC
  };

  // カードIDごとにランキング
  string card_id = 1;
}

この定義から、キーの構造体を出力します。ポイントは、生成されるすべてのキー構造体が共通のインターフェースを満たすように生成することです。

type CardExpRankingKey struct {
	CardId string
}

func (r *CardExpRankingKey) GetRankingKey() string {
	return "CardExpRanking" + ":" + r.CardId
}

func (r *CardExpRankingKey) GetScoreOrder() dto.RankingScoreOrder {
	return dto.RankingScoreOrderDesc
}

var _ dto.RankingKey = (*CardExpRankingKey)(nil)

以下のように共通のインターフェースを受け取るクライアント定義にしておけば、新しいランキングが必要になってもprotoにmessageを1つ足して再生成するだけで良くなりとても便利です。

type Ranking interface {
	Add(ctx context.Context, key dto.RankingKey, userId string, score int) error
	Rank(ctx context.Context, key dto.RankingKey, userId string) (rank int, err error)
	...
}

このように、自動生成を用いることで様々なドメインに対して自動化を実現でき、容易に拡張することができます。

1次ソースは何を選ぶべきか

ここまでProtoBufを1次ソースとした自動生成を紹介してきましたが、定義言語の選択肢はProtoBufだけではありません。

プロジェクトの技術スタックとの親和性も鑑みて、最も使い易いものを選択すると良いでしょう。

  • SQL: DBスキーマ周辺の自動生成から着手したい場合最適
  • GraphQL: WebフロントエンドとのAPI定義が中心ならスキーマファーストで活用できる
  • CUE: 制約や検証を型レベルで表現でき、設定ファイルの生成・検証に強い
  • YAML / JSON: 学習コストが最も低いが、型やコメントの表現力は限定的
  • Goコード自体: go/astで解析すれば、Goの型定義を1次ソースにもできる

おわりに

本記事では、ProtoBufを1次ソースとしたGoコードの自動生成について、独自protocプラグインの実装方法を中心に紹介しました。

  • 再生成されるコードを自動生成に任せることで、不要なレビューやヒューマンエラーを減らすことができる
  • ProtoBufを1次ソースに選択した場合、protocプラグインを自作することでそれを実現できる
  • カスタムオプションとtext/templateを組み合わせることで、エンティティ・SQL・バリデーション・設定ファイルなど、あらゆる成果物を1つの定義から導出できる

「同じ情報を複数の場所に手で書き写している」と感じたら、それは自動生成の出番です。

ぜひ皆さんのプロジェクトでも、1次ソースを軸にした自動生成パイプラインの構築に挑戦してみてください。

人気記事トップ10

人気記事ランキングをもっと見る

企画広告も役立つ情報バッチリ! Sponsored