帰ってきた optional - Protocol Buffers v3.12 から Field presence が導入
電通デジタルでバックエンド開発をしている齋藤です。
今回は Protocol Buffers v3.12 のリリースで追加された Field presence 機能について調べたことをご紹介します。
前提:v3.12 以前の Protocol Buffers v3 における optional な値の扱い
Protocol Buffers v3 (proto3) では v2 (proto2) にはあった optional ながなくなり、optional を扱うにはひと工夫必要でした。加えて、Message の Filed に値を入れなかった場合は、Fieldの型のディフォルト値が送られてきたとみなす仕様になっています(各型のディフォルト値はこちら)。
そのため、開発者は optional な値を使いたい場合
・google/protobuf/wrappers.proto を使って定義する (プリミティブな値の場合のみ)
・oneofを使って定義する
・値そのものと、値の存在を判定するbool値を持つラッパーを定義する
・運用でカバー
のいずれかの対応をする必要がありました。それでも、特定のケースでは値を指定していないのかディフォルト値なのかを厳密に識別するのが難しいケースが発生していました。
例えばシリアライズする場合にディフォルト値が無くなってしまうので、JSON形式でやりとりをするクライアントでのテストの確認に気をつける必要がありました。特に proto3 では Enum の最初の値を0にすることが仕様で決められており、諸々考えなければいけません。
proto3 の optional 取り扱いの議論は protocolbuffers/protobuf Issue 1606 で2016年5月(Issue自体は2017年3月で「今は何もしない」でCloseされています)から現在に至るまで続いていました。
そして、Protocol Buffers v3.12 で Field presence として optional が復活しました。
・protocolbuffers/protobuf - Application note: Field presence
・protocolbuffers/protobuf - How To Implement Field Presence for Proto3
なお、現在 Field presence は experimental ステータスなので、今後変更の可能性があります。
proto3 optional の実体は何か?
内部実装的には上記ドキュメントの Updating Code Generator に以下の記述があります。
syntax = "proto3";
message Foo {
// Experimental feature, not generally supported yet!
optional int32 foo = 1;
// Internally rewritten to:
// oneof _foo {
// int32 foo = 1 [proto3_optional=true];
// }
//
// We call _foo a "synthetic" oneof, since it was not created by the user.
}
記載がある通り、proto3 の optional を使った場合、(カスタムオプション付きの)要素が一つの oneof として扱われるようです。
proto3 optional を試してみる
gPRC のリクエスト/レスポンスメッセージで proto3 optional を試してみます。Go + gRPC で Protocol Buffers v3.12 に対応しているのは
・Go: protocolbuffer/protocolbuf-go v1.22.0 以降
・Go gRPC の protoc プラグイン: golang/protobuf v1.4.1 以降
です。今回は
・protoc v3.12
・golang/protouf v1.4.2
・gRPC アプリケーションとして grpc/grpc-go の helloworld
※ 今回は記載しませんがgRPC Server Reflection 適応済とします
を使って試してみます。
protoのリクエスト/レスポンスメッセージを修正する
helloworld/helloworld.proto のリクエスト/レスポンスメッセージにoptionalあり/なしのフィールドを追加してそのまま返すようにします。
optional あり/なしの nicknameを以下のように追加します。
// The request message containing the user's name.
message HelloRequest {
string name = 1;
string nickname_def = 2;
optional string nickname_opt = 3;
}
// The response message containing the greetings
message HelloReply {
string message = 1;
string nickname_def = 2;
optional string nickname_opt = 3;
}
gRPC 用の Go ファイルを生成します。この際 --experimental_allow_proto3_optional をつける必要があります。
$ protoc --experimental_allow_proto3_optional \
-I . \
--go_out=plugins=grpc:. \
helloworld/helloworld.proto
生成された helloworld.pb.go を見ると
・nickname_def: string 型
・nickname_opt: *string 型
になっています。つまり optional を指定した nickname_opt は
・値が渡されていない: nil
・空文字列が渡されている:空文字列のポインタ ※ 値が入っているのが重要
・空文字列以外の値が渡されている:その文字列のポインタ
になります。
gRPC サーバの修正
greeter_server/main.go の SayHello 関数を以下のように変更します。
// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
name := in.GetName()
nicknameDef := in.GetNicknameDef()
resp := &pb.HelloReply{Name: name, NicknameDef: nicknameDef}
// nickname_opt がリクエストで指定されている場合のみ値をセット
if in.NicknameOpt != nil {
resp.NicknameOpt = in.NicknameOpt
}
return resp, nil
}
リクエストのレスポンスの対応関係は以下になる想定です。
grpcurl / evans によるテスト
それではテストをします。まずはサーバを起動します。
$ go run greeter_server/main.go
fullstorydev/grpcurl (v1.6.0) とktr0731/evans (v0.9.0) でそれぞれテストしてみます。
nickname_def, nickname_opt 共にリクエストで値を渡さない
### grpcurl
$ grpcurl -plaintext -d '{"name":"alice"}' \
localhost:50051 helloworld.Greeter.SayHello
{
"message": "Hello alice"
}
### evans
$ echo '{"name":"alice"}' \
| evans -r cli call helloworld.Greeter.SayHello
{
"message": "Hello alice"
}
予想通り、nickname_def, nickname_opt 共に要素ごとない状態です。
nickname_def, nickname_opt 共に空文字列で値を渡す
### grpcurl
$ grpcurl -plaintext \
-d '{"name":"alice", "nickname_def":"", "nickname_opt":""}' \
localhost:50051 helloworld.Greeter.SayHello
{
"message": "Hello alice",
"nickname_opt": ""
}
### evans
$ echo '{"name":"alice", "nickname_def":"", "nickname_opt":""}' \
| evans -r cli call helloworld.Greeter.SayHello
{
"message": "Hello alice",
"nickname_opt": ""
}
予想通り、nickname_opt のみ空文字列が値の要素が返ってきます。
nickname_def, nickname_opt 共に非空文字列で値を渡す
### grpcurl
$ grpcurl -plaintext \
-d '{"name":"alice", "nickname_def":"ali", "nickname_opt":"ali"}' \
localhost:50051 helloworld.Greeter.SayHello
{
"message": "Hello alice",
"nickname_def": "ali",
"nickname_opt": "ali"
}
### evans
$ echo '{"name":"alice", "nickname_def":"ali", "nickname_opt":"ali"}' \
| evans -r cli call helloworld.Greeter.SayHello
{
"message": "Hello alice",
"nickname_def": "ali",
"nickname_opt": "ali"
}
予想通り、nickname_def, nickname_opt の共に非文字列が値の要素が返ってきます。
まとめ
Protocol Buffers v3.12 から導入された Field presence を試してみました。
プロダクション環境で利用できるにはまだ時間がかかると思いますが、特定のケースでは有用な機能だと思います。今後もリリース状況を追っていきたいと思います。