gRPCで送受信されるメッセージは、標準ではProtocol Buffersでシリアライゼーションされることになっている。一方、gRPCのwire protocolはそこは柔軟になっていて、実際、多くの実装ではシリアライゼーション形式をカスタマイズ可能だ。
たとえば、ある種のニーズのためにFlatBuffersが必要だとか、HTTP/2をサポートする標準的なツールでの解析のためにJSONのほうが可読性が良いとか、社内のバイナリ表現の一貫性のためMsgPackが必要だとか、宗教的な理由でProtobufを使えないとか、そういうときはスキーマだけprotobufで書いておいて、シリアライズは好きなようにやれば良い。
で、具体的にはそれはどうやったらできるのだろう。これが本稿の話題である。いくつかの言語で実際にJSONでシリアライズするクライアントとサーバーを書いてみたので、その結果を紹介する。各言語の特色が良く出ていて面白かった。
言語 | JSON変換ユーティリティ | カスタマイズ単位 | カスタマイズ方法 |
---|---|---|---|
C++ | google/protobuf/util/json_util.h |
メッセージクラス毎 |
SerializationTraits を特殊化 |
Go | github.com/golang/protobuf/jsonpb |
呼び出し毎 / クライアントインスタンス毎 |
CallOption を渡す |
Ruby | .encode_json , .decode_json |
メソッド定義毎 |
RpcDesc のインスタンスをメタプログラミングで書き換え |
Java | io.grpc.protobuf.ProtoUtils.jsonMarshaller |
メソッド定義毎 | カスタムのMethodDescriptor インスタンスを作り、それを用いてサーバー/クライアントのメソッドを再実装/オーバーライド |
Node | シリアライズはできるが、デシリアライズAPIがない! | あとで書く | あとで書く |
例としては、次のようなサービスを各言語で実装しJSONでシリアライズしている。
syntax = "proto3";
package greeter;
option go_package = "greeterproto";
option java_package = "com.github.yugui.grpc_custom_serializer";
message RequestProto {
string name = 1;
}
message ResponseProto {
string message = 1;
}
service Greeter {
rpc Greet(RequestProto) returns (ResponseProto);
}
なお、protobufのランタイムライブラリは標準でprotobuf形式やJSONへのシリアライゼーションはサポートしているため、struct
やclass
とJSONのマッピングを自作する必要はない。一方、実際に他の形式を使いたい場合はそのサポートを得られないので、自分で何らかのアダプターを書かねばならないだろう。
C++
C++のシリアライズ方式カスタマイズは、ある意味とても「らしい」造りをしている。
- ProtobufメッセージのクラスとJSONの間のシリアライズ/デシリアライズには
google/protobuf/util/json_util.h
の関数を使う。 - ランタイムが特定のprotobufメッセージ型をシリアライズする方式をカスタマイズするには、Traitクラスを特殊化する
#include <grpc++/impl/codegen/config_protobuf.h>
#include <grpc++/impl/codegen/proto_utils.h>
namespace greeter {
class RequestProto;
class ResponseProto;
} // namespace greeter
namespace grpc {
template <>
class SerializationTraits<greeter::RequestProto> {
public:
static Status Serialize(const grpc::protobuf::Message& msg,
grpc_byte_buffer** bp, bool* own_buffer);
static Status Deserialize(grpc_byte_buffer* buffer,
grpc::protobuf::Message* msg);
};
// (引用註:ResponseProto用特殊化は略)
...
} // namespace grpc
見て分かるように、grpc::SerializationTraits
をメッセージクラスRequestProto
向けに特殊化すれば、サーバーはRequestProto
をシリアライズ/デシリアライズするときにそのstatic
メンバー関数を呼び出す。
で、そのメンバー関数のほうはgrpc::protobuf::Message&
とgrpc_byte_buffer
の間をうまく変換するように実装すれば良いことになっている。実装に目を移すと、
...
namespace {
grpc::Status GenericJSONSerialize(const grpc::protobuf::Message& msg,
grpc_byte_buffer** bp, bool* own_buffer) {
std::string buf;
const auto status = google::protobuf::util::MessageToJsonString(msg, &buf);
if (!status.ok()) {
std::cerr << "Failed to serialize message: " << status.error_code() << ":"
<< status.error_message() << std::endl;
return grpc::Status(grpc::StatusCode::INTERNAL,
"Failed to serialize message");
}
auto buf_slice = grpc::SliceFromCopiedString(buf);
*bp = ::grpc_raw_byte_buffer_create(&buf_slice, 1);
grpc_slice_unref(buf_slice);
*own_buffer = true;
return grpc::Status::OK;
}
grpc::Status GenericJSONDeserialize(grpc_byte_buffer* buffer,
grpc::protobuf::Message* msg) {
// (引用註:Deserializeも似た感じなので略)
...
}
} // namespace
namespace grpc {
using greeter::RequestProto;
using greeter::ResponseProto;
Status SerializationTraits<RequestProto>::Serialize(
const grpc::protobuf::Message& msg, grpc_byte_buffer** bp,
bool* own_buffer) {
return GenericJSONSerialize(msg, bp, own_buffer);
}
Status SerializationTraits<RequestProto>::Deserialize(
grpc_byte_buffer* buffer, grpc::protobuf::Message* msg) {
return GenericJSONDeserialize(buffer, msg);
}
// (引用註:ResponseProto用特殊化は略)
...
} // namespace grpc
なんか、APIのお約束の関係で多少複雑であるものの、基本的にはgoogle::protobuf::util::MessageToJsonString
(デシリアライズならJsonStringToMessage
)を呼び出しているだけだ。
Go
Goも非常に「らしい」造りである。
- ProtobufメッセージをJSONにシリアライズ(あるいはデシリアライズ)するには
github.com/golang/protobuf/jsonpb
パッケージを使う。 - 新しいシリアライズ方式をサーバーに使わせるには、
"google.golang.org/grpc/encoding".Codec
インターフェースを実装し、登録する。 - サーバーはgRPC wire protocolの
Content-Type
やAccept
ヘッダを見て適切なCodecを選択する。 - クライアントはリモートメソッド呼び出しの都度
CallOption
で、今回の通信で使うシリアライズ形式を指定できる。- 他の
CallOption
と同じく、Dial
時にgrpc.WithDefaultCallOptions
を渡してデフォルトのシリアライズ方式を指定しておくこともできる。
- 他の
// Package encoding defines the JSON codec.
package encoding
import (
"bytes"
"fmt"
"github.com/golang/protobuf/jsonpb"
"github.com/golang/protobuf/proto"
"google.golang.org/grpc/encoding"
)
const (
// Name is the name registered for the json encoder.
Name = "json"
)
// codec implements encoding.Codec to encode messages into JSON.
type codec struct {
m jsonpb.Marshaler
u jsonpb.Unmarshaler
}
func (c *codec) Marshal(v interface{}) ([]byte, error) {
msg, ok := v.(proto.Message)
if !ok {
return nil, fmt.Errorf("not a proto message but %T: %v", v, v)
}
var w bytes.Buffer
if err := c.m.Marshal(&w, msg); err != nil {
return nil, err
}
return w.Bytes(), nil
}
func (c *codec) Unmarshal(data []byte, v interface{}) error {
msg, ok := v.(proto.Message)
if !ok {
return fmt.Errorf("not a proto message but %T: %v", v, v)
}
return c.u.Unmarshal(bytes.NewReader(data), msg)
}
// Name returns the identifier of the codec.
func (c *codec) Name() string {
return Name
}
func init() {
encoding.RegisterCodec(new(codec))
}
(引用にあたり、ログ出力とコメントを少し削った)
Codec
の実装は基本的にjsonpb
が提供するmarshaler/unmarshalerのアダプタに過ぎない。最後に、encoding.RegisterCodec
を使って自作したCodec
を登録している。
codec.Name()
は"json"
を返すので、ここで登録したCodec
は要求されたMIME typeがapplication/grpc+json
の場合に利用されることになる。
では、Content-Type
やAccept
がapplication/grpc+json
になるようにクライアントを設定するにはどうすれば良いのか。それが下記である。
func greet(ctx context.Context, conn *grpc.ClientConn, name string) (string, error)
...
func run(ctx context.Context) error {
opts := []grpc.DialOption{
grpc.WithInsecure(),
grpc.WithDefaultCallOptions(grpc.CallContentSubtype(encoding.Name)),
}
conn, err := grpc.DialContext(ctx, *addr, opts...)
if err != nil {
glog.Errorf("Failed to connect to the server %s: %v", *addr, err)
return err
}
defer conn.Close()
msg, err := greet(ctx, conn, *name)
if err != nil {
glog.Errorf("Failed to call greeter: %v", err)
return err
}
_, err = fmt.Fprintln(os.Stdout, msg)
return err
}
...
クライアントスタブのメソッドを呼び出すときにgrpc.callContentSubtype("json")
を渡すか、またはDial
するときにデフォルト設定をしておけば良い。ここでは後者の方式で、DialOption
として指定している。
Ruby
ここまで見てきたようにC++でもGoでもシリアライズ形式はカスタマイズ出来る仕組みが基本にあって、デフォルト設定としてprotobuf形式をインストールしてあるわけだが、Rubyも同じである。Rubyのそれはメタプログラミングでできている。
-
.proto
から生成されたメッセージごとのクラスはencode_json
,decode_json
のようなクラスメソッドを持っている。これをサーバー/クライアントのランタイムに使わせれば良い。 -
.proto
から生成されたサーバー/クライアントのスタブライブラリは、GRPC::Generic::Dslモジュールが提供するDSLを使って、それぞれのgRPCメソッドごとにGRPC::RpcDesc
のインスタンスを生成する。
* 各GRPC::RpcDesc
インスタンスのmarshal_method
,unmarshal_method
というattributeに、マーシャル時に呼び出すメソッド名が設定されている。これを書き換えれば良い。
もう少し詳しく見ていきたい。生成されたスタブはこんな感じである。self.marshal_class_method
とかself.unmarshal_class_method
の設定に従い、rpc :Greet, RequestProto, ResponseProto
という呼び出しの中でRpcDesc
のインスタンスが生成されてGreeter::Greeter::Service
に登録されている。
* # Generated by the protocol buffer compiler. DO NOT EDIT!
# Source: greeter/greeter.proto for package 'greeter'
require 'grpc'
require 'greeter/greeter_pb'
module Greeter
module Greeter
class Service
include GRPC::GenericService
self.marshal_class_method = :encode
self.unmarshal_class_method = :decode
self.service_name = 'greeter.Greeter'
rpc :Greet, RequestProto, ResponseProto
end
Stub = Service.rpc_stub_class
end
end
この自動生成コードを手で書き換えてself.marshal_class_method = :encode_json
とかしてしまえば話は早いが、メンテナンス性が最悪(.proto
ファイルを修正してコードが再生成されるたびにまた手で書き換えるのは嫌だ)なので他の方式が必要だ。
そこで、こんなライブラリを用意した。
require 'grpc'
module GRPC::GenericService::Dsl
def use_json_marshaler(*method_names)
targets = rpc_descs
unless method_names.empty?
targets = targets.select {|name,| method_names.include?(name)}
end
targets.each do |name, desc|
desc.marshal_method = :encode_json
desc.unmarshal_method = :decode_json
end
self
end
end
このライブラリはGRPC::GenericService::Dsl
にモンキーパッチを当ててuse_json_marshaler
というメソッドを生やす。use_json_marshaler
は、レシーバーであるクラスに設定済みのRpcDesc
インスタンスを探索し、対象となるインスタンスのmarshal_method
, unmarshal_method
attributesを書き換える。
下記のように対象となるgRPCメソッド名をuse_json_marshaler
に渡せば、特定のメソッドのみJSON利用設定をする。
Greeter::Greeter::Service.use_json_marshaler :Greet
一方、メソッド名を渡さずにuse_json_marshaler
を呼び出すとレシーバ内の全gRPCメソッドにJSON利用設定をする。
Java
JavaもJavaらしいつくりのコードだったと思う。つまり、Java版ランタイムも基本的にRubyと同じ構造になっているのだが、メタプログラミングとか無しで自分で(あるいはIDEが)ちまちまコードを書く方式になっているという意味で。
- protoメッセージとJSONの間のシリアライズ/デシリアライズには
io.grpc.protobuf.ProtoUtils.jsonMarshaller
メソッドが返すインスタンスを使う。 - 各gRPCメソッドごとに
io.grpc.MethodDescriptor
クラスのインスタンスが生成されている。これを元にして、ProtoUtils.jsonMarshaler
を使うバージョンをメソッドごとに生成し直す - クライアントスタブとサーバースタブで
MethodDescriptor
を参照している部分をオーバーライドして、自作のバージョンを使うようにする。
Rubyだとこの辺は全部メタプログラミングの背後に隠れたんだが。
まず、MethodDescriptor
のカスタマイズから見ていこう。.proto
のrpc Greet(RequestProto) returns(ResponseProto)
に対応して次のようなコードが生成されている。
public final class GreeterGrpc {
... // (中略)
public static final io.grpc.MethodDescriptor<com.github.yugui.grpc_custom_serializer.GreeterOuterClass.RequestProto,
com.github.yugui.grpc_custom_serializer.GreeterOuterClass.ResponseProto> METHOD_GREET = getGreetMethod();
... // (中略)
@io.grpc.ExperimentalApi("https://github.com/grpc/grpc-java/issues/1901")
public static io.grpc.MethodDescriptor<com.github.yugui.grpc_custom_serializer.GreeterOuterClass.RequestProto,
com.github.yugui.grpc_custom_serializer.GreeterOuterClass.ResponseProto> getGreetMethod() {
io.grpc.MethodDescriptor<com.github.yugui.grpc_custom_serializer.GreeterOuterClass.RequestProto, com.github.yugui.grpc_custom_serializer.GreeterOuterClass.ResponseProto> getGreetMet
hod;
... // (中略)
return getGreetMethod;
}
... // (中略)
}
これをカスタマイズしたバージョンを作れば良いので、次のようなクラスを作った。
package com.github.yugui.grpc_custom_serializer;
import com.github.yugui.grpc_custom_serializer.GreeterOuterClass.RequestProto;
import com.github.yugui.grpc_custom_serializer.GreeterOuterClass.ResponseProto;
import io.grpc.MethodDescriptor;
import io.grpc.protobuf.ProtoUtils;
final class GreeterDescriptions {
static final MethodDescriptor<RequestProto, ResponseProto> METHOD_GREET =
GreeterGrpc.METHOD_GREET
.toBuilder(
ProtoUtils.jsonMarshaller(RequestProto.getDefaultInstance()),
ProtoUtils.jsonMarshaller(ResponseProto.getDefaultInstance()))
.build();
}
あとは、クライアントstubやサーバーstubのメソッドをオーバーライドしてtarget/generated-sources/protobuf/grpc-java/com/github/yugui/grpc_custom_serializer/GreeterGrpc.java
内のMETHOD_GREET
の代わりにGreeterDescriptions.METHOD_GREET
を喰わせれば良いのだが、
import static com.github.yugui.grpc_custom_serializer.GreeterDescriptions.METHOD_GREET;
... // (中略)
public class GreeterServer {
... // (中略)
public static class GreeterImpl implements BindableService {
public ServerServiceDefinition bindService() {
final ServerCallHandler<RequestProto, ResponseProto> greet =
asyncUnaryCall(
new UnaryMethod<RequestProto, ResponseProto>() {
@Override
public void invoke(
RequestProto request, StreamObserver<ResponseProto> responseObserver) {
GreeterImpl.this.greet(request, responseObserver);
}
});
return ServerServiceDefinition.builder(GreeterGrpc.getServiceDescriptor().getName())
.addMethod(METHOD_GREET, greet)
.build();
}
... // (中略)
}
辛い。仮にrpcメソッドが10個あったとしたら、これを10回書くのである。辛い。
クライアントのほうは別の辛さがある。
import static com.github.yugui.grpc_custom_serializer.GreeterDescriptions.METHOD_GREET;
... // (中略)
private static final class GreeterStub extends AbstractStub<GreeterStub> {
... // (中略)
ResponseProto greet(RequestProto request) {
return blockingUnaryCall(getChannel(), METHOD_GREET, getCallOptions(), request);
}
}
自動生成されるコードの中のクライアントのスタブクラスはfinal
なので、ほぼ同じコードを自分で書き直すことにより、カスタムのMethodDescriptor
をblockingUnaryCall
に渡す。
まとめ
言語 | JSON変換ユーティリティ | カスタマイズ単位 | カスタマイズ方法 |
---|---|---|---|
C++ | google/protobuf/util/json_util.h |
メッセージクラス毎 |
SerializationTraits を特殊化 |
Go | github.com/golang/protobuf/jsonpb |
呼び出し毎 / クライアントインスタンス毎 |
CallOption を渡す |
Ruby | .encode_json , .decode_json |
メソッド定義毎 |
RpcDesc のインスタンスをメタプログラミングで書き換え |
Java | io.grpc.protobuf.ProtoUtils.jsonMarshaller |
メソッド定義毎 | カスタムのMethodDescriptor インスタンスを作り、それを用いてサーバー/クライアントのメソッドを再実装/オーバーライド |
Node | シリアライズはできるが、デシリアライズAPIがない! | あとで書く | あとで書く |
ところで、go版のサーバーはContent-Type
を見てCodec
を使い分けるわけだが、他の言語のクライアントでContent-Type
を設定するAPIは無く、常にContent-Type: application/grpc+proto
が付くように見える。従って、go版のサーバーはシリアライズ方式をカスタマイズした他の言語のクライアントとは互換性がない。