フューチャー技術ブログ

protoファイルからコードを自動生成するprotocのプラグインの作り方

はじめに

関です。以前、grpc-gatewayでgRPCとREST両対応のサーバを作るを書きました。その記事でほんの少し触れていたprotocのプラグインについて深掘りします。

内容は以下です。

  • Protocol Buffers概観
  • proto compiler(protoc)とそのプラグインがコードを生成する仕組み
  • protocのプラグインをgolangで実装する方法

Protocol Buffers概観

Protocol Buffersは、構造化データをシリアライズするための拡張可能な機構です。言語やプラットフォームに対して中立であるため、拡張機能が対応する範囲で様々なプログラミング言語やプラットフォームに対応できます。また、自分で拡張機能を用意することで対応範囲を広げることもできます。

イメージとしては、JSONに近く、Protocol Buffersで定義した構造化データはJSONと相互に変換可能なのですが、シリアライズ結果はJSONに比べてコンパクトな代わりに、データの解釈には事前にシリアライズやデシリアライズの方法を知っておく必要があるのが大きな違いです。

用途として最も有名なものはgRPCにおけるIDL(Interface Definition Language)でしょう。これ以外にも、レコードライクで型付きの構造化データをシリアライズし、言語やプラットフォームに依存しない形で利用可能にしたいというようなユースケースにはフィットします。

Protocol Buffersを利用する際の基本的なワークフローは以下です。

  • データ構造を定義する.protoファイルを作成する
  • 作成した.protoファイルを入力にして、proto compiler(protoc)を起動、コードを生成する
  • 生成されたコードを使ってプロジェクトのコードを実装

先ほど触れた、シリアライズやデシリアライズの方法が、proto compilerが生成するコードの中に含まれているイメージです。

proto compiler(protoc)とそのプラグインがコードを生成する仕組み

全体の流れ

overview.jpg

まず、簡単な例を使って、全体の流れを説明します。protoc-gen-mypluginというプラグインを使ってコードを生成したいと仮定します。また、コンパイルの対象ファイルはexample1.proto, example2.protoの2つであるとして、コンパイル結果の出力先ディレクトリはoutディレクトリであるとします。この時、protocの呼び出しは以下のように行います。

protoc --myplugin_out=out example1.proto example2.proto

上記のコマンドが実行されると、protocはコンパイル対象がexample1.proto, example2.protoの2つであること、オプションのmyplugin_outから利用プラグインがprotoc-gen-mypluginであることと、そのプラグインの出力先ディレクトリがoutであることを把握します(※プラグインは、protoc-gen-${NAME}という名前でPATH上に配置されている必要があり、protocがプラグインを使う時には-${NAME}_outというオプションで出力先ディレクトリを指定する必要があります)。その後、コンパイル対象ファイルとその依存先ファイルを解析し、その結果をCodeGeneratorRequestに詰めます。次に、利用プラグインを呼び出した上で、その標準入力に解析結果であるCodeGeneratorRequestをシリアライズしたバイト列を書き込みます。プラグインは書き込まれたCodeGeneratorRequestをデシリアライズの上、自身の処理を実行し、実行結果をCodeGeratorResponseに詰め、シリアライズしたバイト列を標準出力に書き込みます。protocはプラグインの実行結果であるCodeGeratorResponseを受け取ったら、そこに書かれている指示を元にファイルを生成します。

次に、プラグインに対して何らかのパラメータを渡すケースを見てみます。下記はいずれも同じ意味になります。

# ${NAME}_opt オプションを使って渡す。
protoc --myplugin_out=gen --myplugin_opt=param1=foo1,param2=foo2 proto/example1.proto

# ${NAME}_out オプションに含めて渡す。
protoc --myplugin_out=param1=foo1,param2=foo2:gen proto/example1.proto

ここで渡すパラメータは、コマンドライン引数ではなく、CodeGeneratorRequestpamrameterフィールドに、param1=foo1,param2=foo2という文字列が設定されて渡されます。このため、実際に利用する際にはこの文字列を解析する必要があります。

protocのプラグインに対する要件

以上を踏まえると、以下のような要件を満たすように実装すればprotocのプラグインとして動作させられることがわかります。

  • 標準入力からバイト列を読み取り、それをCodeGeneratorRequestとして解釈できること
  • 解釈したCodeGeneratorを元に、自身の処理結果をCodeGeratorResponseに詰め、シリアライズしたバイト列を標準出力に書き込むこと
  • PATH上にprotoc-gen-${NAME}というファイル名で配置されていること

以上から、protocのプラグインは言語に依存せず実装ができ、GoのプラグインはGoで、C++のプラグインはC++でといった実装が可能な仕組みになっています。

protocのプラグインをGoで実装する方法

ナイーブな実装

CodeGeneratorRequestCodeGeratorResponseのコンパイル結果は、Goだとgoogle.golang.org/protobuf/types/pluginpbパッケージ(公式ドキュメント)に含まれています。

この方針での実装方法については他記事のprotocプラグインの書き方が詳しいのでそちらを参照していただけたらと思います。

プラグイン実装用のライブラリを使った実装

上記の方針に則っても良いのですが、実際にコード生成をしようとすると、依存パッケージをimportしたり、protoファイルのsnake_caseからGoファイルで利用するCamelCaseへの変換が必要だったりと、全てを自分でやるのはいささか面倒です。

Goの場合、プラグイン実装に便利な,google.golang.org/protobuf/compiler/protogenというライブラリ(公式ドキュメント)が整備されています。このライブラリは、標準のプラグインであるprotoc-gen-goprotoc-gen-go-grpc実装protoc-gen-grpc実装protoc-gen-connect-go実装でも利用されている実績あるものです。今回はこちらを使って典型的なプラグインの構造を説明します。

プラグイン実装のアウトライン

package main

import (
"flag"
"log"

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

func main() {
// CodeGeneratorRequestからパラメータを読み出すための変数.
flags := flag.NewFlagSet("", flag.ContinueOnError)
param1 := flags.String("param1", "default", "")
param2 := flags.String("param2", "default", "")

opt := protogen.Options{
// ParamFuncは, opt.Runした際にCodeGeneratorRequestのparameterごとに呼び出される.
// これにより, 前段で宣言していた変数に値がセットされる.
ParamFunc: flags.Set,
}

opt.Run(func(plugin *protogen.Plugin) error {
// ここの処理が実行されるタイミングでは、paramに値がセット済み.
// 行末コメントは以下コマンド実行時の標準エラー出力.
// protoc --myplugin_out=gen --myplugin_opt=param1=foo1,param2=foo2,module=github.com/sayshu-7s/protoc-gen-myplugin/gen proto/example1.proto
log.Print(*param1) // foo1
log.Print(*param2) // foo2

for _, f := range plugin.Files {
// protoファイルごとの処理
// 引数で渡したファイル以外に, その依存先のprotoファイルもFilesには含まれる.
// トポロジカルソートされているため, あるファイルが依存している先は必ずそのファイルより先に現れる.

if f.Generate {
// 引数で指定したファイルが生成対象とされ, f.Generate == trueとなっている.
outFile := plugin.NewGeneratedFile(f.GeneratedFilenamePrefix+".myplugin.go", f.GoImportPath)
if _, err := outFile.Write([]byte(`// Code generated by protoc-gen-myplugin. DO NOT EDIT.
package ` + f.GoPackageName + "\n")); err != nil {
return err
}
// TODO: 生成先ファイルへの書き込み.
}
}

return nil
})
}

CodeGeneratorRequestに含まれているプラグインに渡されるパラメータは、main関数冒頭のように, flag.FlagSetを使うことで変数に読み出すことができます。

実際にコードを生成する処理は、opt.Run()に渡した関数の中で指定します。ここで渡されてくるpluginには、生のCodeGeneratorRequestや、そこから読み出した情報を元にGoのコードの生成に便利な機能を追加した様々な構造体が含まれています。ファイルの生成は、plugin.NewGeneratedFile()ででき、生成したoutFileに対して, Writeすると、あとはpluginがCodeGeneratorResponseに変換してよしなにやってくれる仕組みになっています。

TODOの箇所では、templateパッケージなどを使って必要なコードを生成し、書き込むと良いでしょう。ライブラリの機能を使うことで、importなどが楽に行えます。

実装例

簡単な実装例をprotoc-gen-mypluginとして解説コメント付きで実装しました。このプラグインは、protoファイルに含まれるMessageから、その名前をPrintするだけの簡単な関数が書かれたコードを生成し、その際に渡されてきたCodeGeneratorRequestをJSONとして生成する機能があります。

protoディレクトリにサンプルのprotoファイルを、genディレクトリにコンパイル結果を配置しています。中身を確認するとprotocが行なっている解析がどのようなものかのイメージが掴みやすくなると思います。

また、Makefileに記載してあるmake installでプラグインのインストールが、make genでコードの生成ができます。コマンドのインストール先がPATHに含まれる必要があることに注意してください。protoファイルに自前のprotoファイルを作成し、CondeGeneratorRequestに何が入るのか確認してみるとプラグインを自作する際に便利かもしれません。

おわりに

protocのプラグインを全体像と、Goの実践的な実装で利用されるライブラリを紹介しました。

ナイーブな実装について解説する記事はあったものの、より実践的なライブラリについて触れられている記事はあまりなかったのでこの記事を書きました。プラグイン実装する際の参考になれたら幸いです。

参考