protobuf
ProtocolBuffers
gRPC

protocプラグインとカスタムオプション

以前の記事ではprotocプラグインの書き方を紹介したが、実は1つ問題があった。
実用的なプラグインを書こうとした場合に、しばしば生成時に必要な、ドメイン固有の情報が足りないのである。本稿ではそれを補うカスタムオプションの話をする。

ここでもう一度確認しよう。protocのプラグインはProtocol Buffersのスキーマを読んで任意の処理を行える仕組みだ。それはCodeGeneratorRequest内のFileDescriptorProto messageを読み取って任意のバイト列を出力し、出力を受け取ったprotocが指定通りにファイルにバイト列を書き込んでくれる。

ただ、FileDescriptorProtoはprotobufのスキーマ言語の文法をprotobufメッセージとして表現したものに過ぎないから、極めて一般的なデータ構造とサービス定義を表現する能力しか持たない。プログラミング言語依存の機能は意図的に除かれている。
でも、特定のシステム向けにスキーマを変換したり、特定の言語向けにスタブライブラリを生成したりするにはこうした一般的な情報では足りないのである。

自分のプラグインを書く場合も、例えばProtobufではサポートされていない型へのマッピングを指定するとか、サービスの認証設定を書くとか、何か固有の情報を付加してコード生成に反映させたい場合がありがちかと思う。

オプション

ここで、生成対象に依存する情報を記述するためにoptionというprotobufの機能が必要になる。optionとはprotobufスキーマを構成する様々な要素に追加のメタデータを付与する仕組みだ。

たとえば、スキーマから生成されるGo用のスタブライブラリが属するpackage名を制御したいとする。当然のことながらprotobufスキーマ文法それ自体にはGo固有の機能は含まれていないから、次のようにoptionで指定することになる。

syntax = "proto3";
package example.protobuf;
option go_package = "example.com/example-package";

message SimpleMessage {
    uint64 id = 1;
    bytes blob = 4;
}

3行目でこのファイルに対するgo_packageというオプションを指定している。Go用プラグインprotoc-gen-goはこれを読み取り、生成されるコードに反映する。
go_packageのようなオプションの値もCodeGeneratorRequestメッセージの中に入っているので、自分でプラグインを書くときはそれを適切なAPIで読み取って好きな処理をすれば良い。

オプション指定の書き方

optionはファイル単位で指定できるのみではない。文法的には下記のものを対象にoptionを指定できる。

  • ファイル
  • メッセージ定義
  • メッセージ定義内の各フィールド
  • メッセージ定義内の各oneof定義
  • 列挙型定義
  • 列挙型定義の中の各列挙値
  • サービス定義
  • サービス定義内の各メソッド定義

上では文字列型のオプションgo_packageを使ったが、オプションはメッセージ型を含む任意のprotobufのデータ型を使えるので割とリッチな情報を載せられる。

例えば、grpc-gatewayの場合を見てみよう。

echo_service.proto
service EchoService {
    // Echo method receives a simple message and returns it.
    //
    // The message posted as the id parameter will also be
    // returned.
    rpc Echo(SimpleMessage) returns (SimpleMessage) {
        option (google.api.http) = {
            post: "/v1/example/echo/{id}"
            additional_bindings {
                get: "/v1/example/echo/{id}/{num}"
            }
        };
    }
    // (引用註:中略)
    //...
}

ここでは、EchoServiceというサービス定義内のEchoというメソッド定義にメッセージ型のオプションgoogle.api.httpを付与している。
付与されているのはgoogle.api.HttpRuleメッセージ型の値で、Protobufのテキスト形式で表記されている。

その他の文法要素に対するオプション指定例を以下に示す。
メッセージや列挙型のような{ ... }で括られた内部構造を持つ文法要素に対してはoptionキーワードを用いて指定し、それ以外の文法要素に対しては[ ... ]を後置して修飾していることが分かる。

syntax = "proto3";
package example.protobuf;

// ファイルに対するオプションjava_package
// 生成されるJava向けコードのパッケージ名を指定
option java_package = "com.example.protocplugin";

message SimpleMessage {
    // メッセージに対するオプションdeprecated。
    // 幾つかの生成先言語で、生成されるクラスに
    // @Deprecatedのようなアノテーションを付ける。
    option deprecated = true;

    message HeaderItem {
        string name = 1;
        string value = 2;
    }

    enum Type {
        // 列挙型に対するオプション。
        // 同じ値に対して複数のラベルの存在を許す。
        option allow_alias = true;

        START = 0 [deprecated = true]; // 列挙値に対するdeprecatedオプション
        BEGIN = 0;
        BLOB = 1;
        END = 2;
    }

    // フィールドに対するオプションjstype。
    // protobufのuint64型をJavaScriptのStringクラスと対応させる。
    uint64 id = 1 [jstype = JS_STRING];
    Type message_type = 2;
    repeated HeaderItem headers = 3;
    bytes blob = 4;
}

service Echo {
    // サービスに対するオプション
    option deprecated = true;

    rpc Echo(SimpleMessage) returns(SimpleMessage) {
        option idempotency_level = NO_SIDE_EFFECTS;
    }
}

(なんか例がdeprecatedオプションばかりになった。)

オプションのスキーマ

一般にどの文法要素に対してどういう名前のオプションが利用可能かは、descriptor.protoに書いてあるスキーマを読めば分かる。各文法要素に対応してFileOptionsとか、MessageOptionsとかいうメッセージが定義されているので、それを見れば良い。これらの中の各々のフィールドが利用可能なオプションである。

例えば、FileOptionsの定義を見てみると、次のようなフィールドがある。

descriptor.protoより
message FileOptions {
  ...
  // Generated classes can be optimized for speed or code size.
  enum OptimizeMode {
    SPEED = 1;        // Generate complete code for parsing, serialization,
                      // etc.
    CODE_SIZE = 2;    // Use ReflectionOps to implement these methods.
    LITE_RUNTIME = 3; // Generate code using MessageLite and the lite runtime.
  }
  optional OptimizeMode optimize_for = 9 [default=SPEED];

  ...
}

よって、ファイルのトップレベルにoption optimize_for = CODE_SIZE;とか書けて、(C++向けなどの)コード生成戦略を指定できることが分かる。

カスタムオプションの定義の仕方

ちょっと待って欲しい。上では、利用可能なオプションがdescriptor.protoの中のメッセージのフィールドとして定義されていると言った。確かにgo_packageだのoptimized_forだのdeprecatedだのはそこに定義されているから良い。では、自分のアプリケーションに特化したoptionを定義するにはどうしたらよいのだろうか。
まさか、個別のアプリケーションのためにいちいちProtocol Buffersにプルリクエストを送ってdescriptor.protoを拡張してもらうわけにも行くまい。

ここで、extensionsというprotobufの仕組みが登場する。上の例で採り上げたgoogle.api.httpオプションも同じ仕組みで定義されている。extensionsで作ったオプションのことをカスタムオプションと呼んでいる。

extensionsとは

extensionsとは、既存のメッセージ定義を外部から拡張してフィールドを足す仕組みだ。

これは少し分かりづらいので、まずは例を見てみたい。たとえば、EnumValueOptionsの定義をよく見てみよう。

descriptor.protoより
message EnumValueOptions {
  // Is this enum value deprecated?
  // Depending on the target platform, this can emit Deprecated annotations
  // for the enum value, or it will be completely ignored; in the very least,
  // this is a formalization for deprecating enum values.
  optional bool deprecated = 1 [default=false];

  // The parser stores options it doesn't recognize here. See above.
  repeated UninterpretedOption uninterpreted_option = 999;

  // Clients can define custom options in extensions of this message. See above.
  extensions 1000 to max;
}

末尾にextensions 1000 to maxと書いてある。これがextensionsの用意である。

ここで、protobufメッセージのフィールドは内部的にはフィールド名ではなくタグ番号で識別されるということを思い出してもらいたい1extensions 1000 to maxとはそのタグ番号に関する話で、EnumValueOptionsのタグ番号のうち1000から最大値(536,870,911)までは第三者による拡張のために予約されていることを意味する。

さて、では拡張したい第三者としてはどうしたらよいのか。それには、自分の手元の適切な.protoファイルに次のように書けば良い2
5-7行目でEnumValueOptionsを拡張し、タグ番号50000のフィールドexample.mypackage.descriptionを定義している。

custom-option.proto
syntax = "proto3";
package example.mypackage;
import "google/protobuf/descriptor.proto";

extend google.protobuf.EnumValueOptions {
  string description = 50000;
}

これで、次のようにオプションを指定できる。

my-message.proto
syntax = "proto3";
package example.protobuf;

import "path/to/custom-option.proto";

message SimpleMessage {
    message HeaderItem {
        string name = 1;
        string value = 2;
    }

    enum Type {
        BEGIN = 0 [(example.mypackage.description) = "headersに値が入っている"];
        BLOB = 1  [(example.mypackage.description) = "blobにペイロードが入っている"];
        END = 2   [(example.mypackage.description) = "ストリームの終了"];
    }

    uint64 id = 1;
    Type message_type = 2;
    repeated HeaderItem headers = 3;
    bytes blob = 4;
}

注意事項

カスタムオプションやextensionsに関して幾つか注意事項があるので、ここで列挙しておく。

カスタムオプションのタグ番号の私用領域

カスタムオプションを定義する場合、原則的には拡張フィールドのタグ番号として50000から99999を使うこと。

この範囲の番号は私的利用のために予約されているので3、外部とのタグ番号衝突を心配せずに自分たちの組織内で好きなように利用できる。

一意タグ番号の発行依頼

プラグインを広く一般に公開する場合、逆に私用領域のタグ番号を利用するのは望ましくない。そのプラグインのユーザーが自組織内で使っているプラグイン用のタグ番号と衝突するかもしれないからである。

このときは、protobuf-global-extension-registry@google.com に(英語で)メールを送って全世界で一意な番号を発行してもらう必要がある。メールに利用を希望するプロジェクト名とプロジェクトのURLを書くと、翌日〜数営業日ぐらいでそのプロジェクト専用のタグ番号をくれる。

メッセージ型の利用

上の例では文字列型のカスタムオプションを定義したが、これは実用上は避けるべきである。原則的にカスタムオプションはメッセージ型にした方が良い。

なぜならば、大抵の場合は後から2番目のカスタムオプションを足したくなるものだからだ。このとき、2つの理由から次のように2つのオプションを1つのメッセージに束ねた方が良い。

option (example.my_option) = {
  foo: 1
  bar: 2
};

理由その1は、このほうが関連するオプションがグループ化されて見やすいこと。下記のようにばらけているよりも、プラグインAで利用するためのオプションはその単位でまとまっていた方が良い。

グループ化されていない例
option (example.my_option_foo) = 1;
option (example.my_option_bar) = 2;

理由その2は、 タグ番号を節約するため。私用領域は限られているし、全世界で一意な番号はなおのこと希少だ。4
その点、foobarという2つの設定をexample.my_optionという1つのメッセージ型オプションに束ねてしまえば、(example.my_options)用の番号が1個あれば済む。

番号の浪費
extend google.protobuf.MessageOptions {
  uint32 my_option_foo = 1022;
  uint32 my_option_bar = 1023;
  uint32 my_option_baz = 1024;
}
番号の節約
message MyOptions {
  uint32 foo = 10;
  uint32 bar = 11;
  uint32 baz = 12;
}

extend google.protobuf.MessageOptions {
  MyOptions my_option = 1022;
}

たとえ「このプラグイン用のカスタムオプションはbool型の1個で十分だろう」とそのとき確信していたとしても、将来に備えてメッセージ型のオプションを定義しておくべきである。

extensionsとAny

extensionsは原理的には上のようにカスタムオプションを定義するためだけではなく、任意のメッセージを拡張できる仕組みである。
ただし、カスタムオプション以外の目的で新規に利用するのは推奨されていない。

Protobuf 3.0以降で一般のメッセージに対してそういうことをしたい場合、Anyを使うべきとされている。

protocプラグインからの利用

先に言ったように、スキーマに書いたオプション指定はプラグインが受け取るCodeGeneratorRequestの中に入っている。だから後はリファレンスでも読んで好きなように処理すれば良い。
ただ、参考までにカスタムオプションをプラグインで処理する例をGoで紹介して締めくくろうと思う。

入力されたスキーマのうちoption (example.message_list) = {target: true}というoptionを付けたメッセージだけ名前をリストアップするプラグインを作ってみようと思う。

資料の確認

繰り返しになるが、CodeGeneratorRequestのスキーマはplugin.protodescriptor.protoを読めば分かる。このスキーマから生成されたGoのコードのAPIも知る必要があるが、それはGo Generated Codeというドキュメントに書いてある。
それから、生成済みのGoコードがgithub.com/golang/protobuf/protoc-gen-go/descriptorパッケージgithub.com/golang/protobuf/protoc-gen-go/pluginパッケージにもあるのでこれを見ても良い。

他の言語の場合は下記を見る。

カスタムオプション定義

では、上記資料をもとに作り始めよう。今回のカスタムオプションはこんな感じになるだろう。

custom-options.proto
syntax = "proto3";
package example;
option go_package = "gist.github.com/e179eee28268e85c5036859987f8a15e";

import "google/protobuf/descriptor.proto";

message MessageListOptions {
    bool target = 10;
}

extend google.protobuf.MessageOptions {
    MessageListOptions message_list = 50000;
}

ここから、Goのコードを生成しておく。

$ protoc --go_out=. -I. custom-options.proto

プラグインの骨格

骨格は以前の記事で作ったのと同じで良いはずだ。processReq関数だけ今回に合わせて改造する。

func processReq(req *plugin.CodeGeneratorRequest) *plugin.CodeGeneratorResponse {
        files := make(map[string]*descriptor.FileDescriptorProto)
        for _, f := range req.ProtoFile {
                files[f.GetName()] = f
        }

        var buf bytes.Buffer
        for _, fname := range req.FileToGenerate {
                f := files[fname]
                for _, name := range listNames(f) {
                        io.WriteString(&buf, name)
                        io.WriteString(&buf, "\n")
                }
        }

        return &plugin.CodeGeneratorResponse{
                File: []*plugin.CodeGeneratorResponse_File{
                        {
                                Name:    proto.String("messages.txt"),
                                Content: proto.String(buf.String()),
                        },
                },
        }
}


func listNames(f *descriptor.FileDescriptorProto) []string {
    // TODO: ここに今回の処理を書く
}

入力はCodeGeneratorRequestメッセージで、plugin.protoを見ればfileフィールドにFileDescriptorProtoが入っていることが分かる。

message CodeGeneratorRequest {
  ...
  repeated FileDescriptorProto proto_file = 15;
  ...
}

descriptor.protoを見に行ってFileDescriptorProtoの定義を見ると、その中のmessage_typeフィールドにファイル内のメッセージ定義が、更にその中のoptionsフィールドにメッセージ定義に対するオプションが入っていることが分かる。

message FileDescriptorProto {
  ...
  repeated DescriptorProto message_type = 4;
  ...
}
message DescriptorProto {
  ...
  optional MessageOptions options = 7;
  ...
}

コア部分の実装

Go Generated Codeの記述のほうを見ると、fooというprotobufフィールドに対してはGetFoo()というgetterメソッドが定義されることが分かるので、次のようにしてみる。

func listNames(file *descriptor.FileDescriptorProto) []string {
        var list []string
        for _, m := range file.MessageType {
                if !isTarget(m) {
                        continue
                }
                list = append(list, m.GetName())
        }
        return list
}

func isTarget(m *descriptor.DescriptorProto) bool {
        var opts = m.GetOptions()
        // TODO: ここで判別する
}

さて、google.protobuf.MessageOptionsメッセージのextensionsに我々のカスタムオプションが入っているのであった。なので、再びドキュメントを見ると"github.com/golang/protobuf/proto".GetExtension関数で取得できることが分かる。

第2引数に期待されているExtensionDesc、すなわちmessage_listオプションの記述子は生成されたコードの中にあるんだろうなと当たりを付けられる。そこで先ほど生成したcustom-options.pb.goの中を探すとE_MessageListがあるのでこれを使う。

func isTarget(m *descriptor.DescriptorProto) bool {
        var opts = m.GetOptions()
        if opts == nil {
                return false
        }

        ext, err := proto.GetExtension(opts, E_MessageList)
        if err == proto.ErrMissingExtension {
                return false
        }
        if err != nil {
                panic("unexpected error")
        }

        mopts := ext.(*MessageListOptions)
        return mopts.GetTarget()
}

完成したコードはhttps://gist.github.com/yugui/e179eee28268e85c5036859987f8a15e#file-main-go においてある。(若干エラー処理をサボっている)

まとめ

  • protocプラグイン開発の時、ターゲット固有の情報をスキーマに埋め込むためにカスタムのoptionを使う
  • カスタムオプションを定義するには、FileOptionsやその他のdescriptor.protoに定義されたメッセージにextension fieldを作れば良い
    • このフィールドは、必ずmessage型で定義すべきである
    • 自組織内だけで流通するプラグインの場合は、私的領域50000-99999から一意なタグ番号を選んでフィールドを定義する
    • 外部に公開するプラグインを定義する場合、protobuf-global-extension-registry@google.com にメールを送って全世界で一意な番号を取得する
  • plugin.proto, descriptor.protoと、各言語別のガイドを読めばプラグイン内で必要になる情報を取得する方法は分かる。
  • ここまでの記事により、読者は次のことができるようになった。
    • Protocol Buffersの読みやすいスキーマ言語でProtobuf自身やJSONを含む様々なデータ形式や通信, ストレージのスキーマを表現する。
    • カスタムオプションを定義し、自分のアプリケーションやライブラリに固有のメタデータをスキーマに盛り込む
    • protocのプラグインを開発し、protobufスキーマから他のスキーマ形式への変換や、スキーマからの情報抽出, その他スキーマの自動処理などをする
    • カスタムオプションの指定やProtobuf処理のデバッグに便利なテキスト形式を読み書きする

脚注


  1. https://qiita.com/yugui/items/160737021d25d761b353#protocol-buffers%E3%81%A8%E3%81%AF も参照 

  2. 言い換えると、extensionsがあればEnumValuesOptionsに新たなフィールドを足すためにdescriptor.proto自体を編集する必要はない。だから自分が書き換える権限を持っていないファイルで定義されたメッセージを拡張できるというわけだ。 

  3. https://developers.google.com/protocol-buffers/docs/proto#customoptions 

  4. それに1個オプションを足したいと思うたびにGoogleにメールを送って返事を待つのもしんどいし、そもそもそういう無駄遣いは断られるような気もする。