Protocol Buffers の oneof で message の形式変更に耐える
若干タイトルが迷子な気がしますが、大きいことを言っているようで細かい話になります。 最近 grpc-java で protobuf の oneof の機能を使っていて疑問に思ったことがあったので試してみた記事になります。
その疑問とは、 oneof を使えば message のフィールドを追加削除したり type 変えたりしたい時でも古いクライアントの挙動を保ったまま変更できるのではないか、というものです。 新年一発目の記事ですが早くもネタが思いつかず、意外とサクッと読める記事になっていますw
早速ですが今回は面倒なので公式の grpc-java のリポジトリを fork してサンプルコードをちょっといじって試しました。
fork して v1.18.0
からブランチしていじったコードはこちらです。
そもそも oneof が何かわかりづらい気がするのは僕だけなのかわかりませんが、公式のドキュメントにちゃんと記述はあります。
https://developers.google.com/protocol-buffers/docs/proto3#oneof
要するに、複数のフィールドのうち多くとも 1 つだけセット可能にしたい時に使う機能で、共有のメモリを使うのでメモリの節約にもなります。 それだけであれば使う機会は少ないように思うのですが、この oneof が意外といくつか別の意図で役立つ時があります。
1 つは proto3 では optional の値を表現しづらいので oneof を使うことでそのフィールドを optional 扱いすることができます。 この話は今回深掘りしませんが、気になる人は下記の stackoverflow を読むだけでも察することができると思います。
もう 1 つは、これが本題になりますが、 oneof を使えば古いバージョンで必要だったフィールドを保ったまま新しいバージョンで別のフィールドを追加し、どちらのフィールドがセットされている場合でもサーバ側で吸収することができるのではないかと思いました。 下記のコードで検証していきましょう。
まずは fork した公式リポジトリで v1.18.0
をチェックアウトしドキュメント通りに helloworld のサンプルを動かします。ビルドしたものは取っておきたいので build-1
というディレクトリに移動しました。
https://grpc.io/docs/tutorials/basic/java.html https://github.com/grpc/grpc-java/blob/v1.18.0/examples/README.md
$ cd examples $ ./gradlew installDist $ mv build build-1
で、そのまま何もいじらずにサーバとクライアントを起動すると想定通り Greeting: Hello world
の出力が確認できます。
$ ./build-1/install/examples/bin/hello-world-server Jan 27, 2019 1:42:36 PM io.grpc.examples.helloworld.HelloWorldServer start INFO: Server started, listening on 50051
$ ./build-1/install/examples/bin/hello-world-client Jan 27, 2019 1:42:49 PM io.grpc.examples.helloworld.HelloWorldClient greet INFO: Will try to greet world ... Jan 27, 2019 1:42:49 PM io.grpc.examples.helloworld.HelloWorldClient greet INFO: Greeting: Hello world
続いて proto の HelloRequest
に oneof のフィールドを追加してみます。 examples-helloworld-oneof
ブランチにコミットしてありますが、変更内容は以下です。
--- a/examples/src/main/proto/helloworld.proto +++ b/examples/src/main/proto/helloworld.proto @@ -28,7 +28,10 @@ service Greeter { // The request message containing the user's name. message HelloRequest { - string name = 1; + oneof id_oneof { + string name = 1; // Deprecated. Use "id" + int64 id = 2; + } } // The response message containing the greetings
合わせてサーバとクライアントを適当に変更しています。
--- a/examples/src/main/java/io/grpc/examples/helloworld/HelloWorldServer.java +++ b/examples/src/main/java/io/grpc/examples/helloworld/HelloWorldServer.java @@ -77,7 +77,10 @@ public class HelloWorldServer { @Override public void sayHello(HelloRequest req, StreamObserver<HelloReply> responseObserver) { - HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build(); + String message = (HelloRequest.IdOneofCase.ID.equals(req.getIdOneofCase())) + ? "ID:" + req.getId() + : req.getName(); + HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + message).build(); responseObserver.onNext(reply); responseObserver.onCompleted(); }
--- a/examples/src/main/java/io/grpc/examples/helloworld/HelloWorldClient.java +++ b/examples/src/main/java/io/grpc/examples/helloworld/HelloWorldClient.java @@ -54,7 +54,8 @@ public class HelloWorldClient { /** Say hello to server. */ public void greet(String name) { logger.info("Will try to greet " + name + " ..."); - HelloRequest request = HelloRequest.newBuilder().setName(name).build(); +// HelloRequest request = HelloRequest.newBuilder().setName(name).build(); + HelloRequest request = HelloRequest.newBuilder().setId(123456L).build(); HelloReply response; try { response = blockingStub.sayHello(request);
期待としては新バージョンのサーバは id
がセットされていた時だけ Hello ID:XXX
という出力をし、それ以外ではもとの挙動と同じになるはずです。 grpc-javaでは enum で id
がセットされているかどうかを判定することができます。
一方クライアント側は id
をセットするように変更しました。
では新しいバージョン同士での挙動を見てみましょう。
$ ./build-2/install/examples/bin/hello-world-server Jan 27, 2019 2:09:27 PM io.grpc.examples.helloworld.HelloWorldServer start INFO: Server started, listening on 50051
$ ./build-2/install/examples/bin/hello-world-client Jan 27, 2019 2:09:32 PM io.grpc.examples.helloworld.HelloWorldClient greet INFO: Will try to greet world ... Jan 27, 2019 2:09:32 PM io.grpc.examples.helloworld.HelloWorldClient greet INFO: Greeting: Hello ID:123456
期待通り Hello ID:123456
が表示されていますね。
さあ最後にお目当ての新しい (build-2) サーバと古い (build-1) クライアントの接続の確認です。ワクワク。
$ ./build-2/install/examples/bin/hello-world-server Jan 27, 2019 2:09:27 PM io.grpc.examples.helloworld.HelloWorldServer start INFO: Server started, listening on 50051
$ ./build-1/install/examples/bin/hello-world-client Jan 27, 2019 2:09:41 PM io.grpc.examples.helloworld.HelloWorldClient greet INFO: Will try to greet world ... Jan 27, 2019 2:09:41 PM io.grpc.examples.helloworld.HelloWorldClient greet INFO: Greeting: Hello world
エラーなど無くちゃんと Hello world
と出力されましたね!
当然 build-1 のクライアントは id
どころか oneof なフィールドであることさえ知らないわけですが、それでも build-2 のサーバに name
を渡して問題なくやり取りができました。
ここまで確認したところで、実は公式ドキュメントにそこまで書いてあることに今更気づきましたw
https://developers.google.com/protocol-buffers/docs/proto3#backwards-compatibility-issues
変更のケースによって挙動が保てないものもあるようですが、少なくとも今回試した
However, you can safely move a single field into a new oneof and may be able to move multiple fields if it is known that only one is ever set.
のケースでは message の形式変更に耐えられることがわかりました。結局のところシリアライズとパースに影響がない proto の変更なのか、というところが焦点になるのでしょうね。