Edited at

gRPCのシリアライゼーション形式をJSONにする

More than 1 year has passed since last update.

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でシリアライズしている。


greeter.proto

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へのシリアライゼーションはサポートしているため、structclassとJSONのマッピングを自作する必要はない。一方、実際に他の形式を使いたい場合はそのサポートを得られないので、自分で何らかのアダプターを書かねばならないだろう。


C++

C++のシリアライズ方式カスタマイズは、ある意味とても「らしい」造りをしている。


  • ProtobufメッセージのクラスとJSONの間のシリアライズ/デシリアライズにはgoogle/protobuf/util/json_util.hの関数を使う。

  • ランタイムが特定のprotobufメッセージ型をシリアライズする方式をカスタマイズするには、Traitクラスを特殊化する


serialization.h

#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の間をうまく変換するように実装すれば良いことになっている。実装に目を移すと、


serialization.cc

...

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-TypeAcceptヘッダを見て適切なCodecを選択する。

  • クライアントはリモートメソッド呼び出しの都度CallOptionで、今回の通信で使うシリアライズ形式を指定できる。


    • 他のCallOptionと同じく、Dial時にgrpc.WithDefaultCallOptionsを渡してデフォルトのシリアライズ方式を指定しておくこともできる。




encoding/json.go

// 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-TypeAcceptapplication/grpc+jsonになるようにクライアントを設定するにはどうすれば良いのか。それが下記である。


client/main.go

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に登録されている。


lib/greeter/greeter_services_pb.rb

* # 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ファイルを修正してコードが再生成されるたびにまた手で書き換えるのは嫌だ)なので他の方式が必要だ。

そこで、こんなライブラリを用意した。


lib/greeter/json_serialization.rb

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のカスタマイズから見ていこう。.protorpc Greet(RequestProto) returns(ResponseProto)に対応して次のようなコードが生成されている。


target/generated-sources/protobuf/grpc-java/com/github/yugui/grpc_custom_serializer/GreeterGrpc.java

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;
}
... // (中略)
}


これをカスタマイズしたバージョンを作れば良いので、次のようなクラスを作った。


src/main/java/com/github/yugui/grpc_custom_serializer/GreeterDescriptions.java

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を喰わせれば良いのだが、


src/main/java/com/github/yugui/grpc_custom_serializer/GreeterServer.java

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回書くのである。辛い。

クライアントのほうは別の辛さがある。 


src/main/java/com/github/yugui/grpc_custom_serializer/GreeterClient.java

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なので、ほぼ同じコードを自分で書き直すことにより、カスタムのMethodDescriptorblockingUnaryCallに渡す。


まとめ

言語
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版のサーバーはシリアライズ方式をカスタマイズした他の言語のクライアントとは互換性がない。