Usual Software Engineer

よくあるソフトウェアエンジニアのブログ

Protocol Buffers の oneof で message の形式変更に耐える

若干タイトルが迷子な気がしますが、大きいことを言っているようで細かい話になります。 最近 grpc-java で protobuf の oneof の機能を使っていて疑問に思ったことがあったので試してみた記事になります。

その疑問とは、 oneof を使えば message のフィールドを追加削除したり type 変えたりしたい時でも古いクライアントの挙動を保ったまま変更できるのではないか、というものです。 新年一発目の記事ですが早くもネタが思いつかず、意外とサクッと読める記事になっていますw

早速ですが今回は面倒なので公式の grpc-javaリポジトリを fork してサンプルコードをちょっといじって試しました。 fork して v1.18.0 からブランチしていじったコードはこちらです。

github.com

そもそも oneof が何かわかりづらい気がするのは僕だけなのかわかりませんが、公式のドキュメントにちゃんと記述はあります。

https://developers.google.com/protocol-buffers/docs/proto3#oneof

要するに、複数のフィールドのうち多くとも 1 つだけセット可能にしたい時に使う機能で、共有のメモリを使うのでメモリの節約にもなります。 それだけであれば使う機会は少ないように思うのですが、この oneof が意外といくつか別の意図で役立つ時があります。

1 つは proto3 では optional の値を表現しづらいので oneof を使うことでそのフィールドを optional 扱いすることができます。 この話は今回深掘りしませんが、気になる人は下記の stackoverflow を読むだけでも察することができると思います。

stackoverflow.com

もう 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では enumid がセットされているかどうかを判定することができます。 一方クライアント側は 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 の変更なのか、というところが焦点になるのでしょうね。