gRPC + JSON
RPC というものに興味を持ち、試してみたいと思ったものの、Protocol Buffers についてはいまいちよくわからない。既存のコードは独自のオブジェクトをエンコードしているか、あるいは特定のエンコーディングを必要とするコードがあるかもしれない。どうすればいいのだろうか?
幸いなことに、gRPC はエンコーディングに依存しません!Protobuf を使用しなくても、gRPC の多くのメリットを享受できます。この記事では、gRPC を他のエンコーディングや型と連携させる方法を説明します。JSON を使ってみましょう。
gRPC は、単一の、モノリシックなフレームワークではなく、高い凝集度を持つ技術の集合体です。これは、gRPC の一部を置き換えても、gRPC のメリットを活用できることを意味します。Gson は、Java で JSON エンコーディングを行うための人気のライブラリです。Protobuf 関連のものをすべて削除し、Gson に置き換えてみましょう。
- Protobuf wire encoding
- Protobuf generated message types
- gRPC generated stub types
+ JSON wire encoding
+ Gson message types
以前は、Protobuf と gRPC がコードを生成してくれていましたが、独自の型を使用したいと考えています。さらに、独自のエンコーディングも使用します。Gson は、コード内に独自の型を取り込むことを可能にしますが、それらの型をバイトにシリアライズする方法を提供します。
以前の「So You Want to Optimize gRPC」の投稿で使用したコードを基に、Key-Value ストアサービスを続行します。この投稿では、そのコードを変更します。
そもそもサービスとは?
gRPC の観点から、Service は Method の集まりです。Java では、メソッドは MethodDescriptor として表されます。各 MethodDescriptor には、メソッドの名前、リクエストをエンコードするための Marshaller、そしてレスポンスをエンコードするための Marshaller が含まれます。また、呼び出しがストリーミングかどうかなどの追加情報も含まれます。簡単のため、単一のリクエストと単一のレスポンスを持つユニタリー RPC を使用します。
コードを生成しないため、メッセージクラスは自分で記述する必要があります。4 つのメソッドがあり、それぞれにリクエストとレスポンスの型があります。これは、8 つのメッセージが必要であることを意味します。
static final class CreateRequest {
byte[] key;
byte[] value;
}
static final class CreateResponse {
}
static final class RetrieveRequest {
byte[] key;
}
static final class RetrieveResponse {
byte[] value;
}
static final class UpdateRequest {
byte[] key;
byte[] value;
}
static final class UpdateResponse {
}
static final class DeleteRequest {
byte[] key;
}
static final class DeleteResponse {
}
GSON はリフレクションを使用して、クラスのフィールドがシリアライズされた JSON にどのようにマッピングされるかを決定するため、メッセージに注釈を付ける必要はありません。
クライアントとサーバーのロジックはリクエストとレスポンスの型を使用しますが、gRPC はこれらのメッセージを生成および消費する方法を知る必要があります。これを行うには、Marshaller を実装する必要があります。マーシャラーは、任意の型から gRPC コアライブラリに渡される InputStream への変換方法を知っています。また、ネットワークからデータをデコードする際に逆変換を行うこともできます。GSON の場合、マーシャラーは以下のようになります。
static <T> Marshaller<T> marshallerFor(Class<T> clz) {
Gson gson = new Gson();
return new Marshaller<T>() {
@Override
public InputStream stream(T value) {
return new ByteArrayInputStream(gson.toJson(value, clz).getBytes(StandardCharsets.UTF_8));
}
@Override
public T parse(InputStream stream) {
return gson.fromJson(new InputStreamReader(stream, StandardCharsets.UTF_8), clz);
}
};
}
任意の型のリクエストまたはレスポンスの Class オブジェクトが与えられると、この関数はマーシャラーを生成します。マーシャラーを使用して、4 つの CRUD メソッドそれぞれに対して完全な MethodDescriptor を構成できます。以下は、Create のメソッドディスクリプタの例です。
static final MethodDescriptor<CreateRequest, CreateResponse> CREATE_METHOD =
MethodDescriptor.newBuilder(
marshallerFor(CreateRequest.class),
marshallerFor(CreateResponse.class))
.setFullMethodName(
MethodDescriptor.generateFullMethodName(SERVICE_NAME, "Create"))
.setType(MethodType.UNARY)
.build();
Protobuf を使用していた場合、既存の Protobuf マーシャラーを使用し、メソッドディスクリプタ は自動生成されていたことに注意してください。
RPC の送信
JSON リクエストとレスポンスをマーシャルできるようになったので、前の投稿で使用した gRPC クライアントである KvClient を、MethodDescriptors を使用するように更新する必要があります。さらに、Protobuf 型を使用しないため、コードは ByteString ではなく ByteBuffer を使用する必要があります。とはいえ、Maven の grpc-stub パッケージを使用して RPC を発行することはまだ可能です。ここでは、Create メソッドを例として、RPC の作成方法を示します。
ByteBuffer key = createRandomKey();
ClientCall<CreateRequest, CreateResponse> call =
chan.newCall(KvGson.CREATE_METHOD, CallOptions.DEFAULT);
KvGson.CreateRequest req = new KvGson.CreateRequest();
req.key = key.array();
req.value = randomBytes(MEAN_VALUE_SIZE).array();
ListenableFuture<CreateResponse> res = ClientCalls.futureUnaryCall(call, req);
// ...
ご覧のとおり、MethodDescriptor から新しい ClientCall オブジェクトを作成し、リクエストを作成して、スタブライブラリの ClientCalls.futureUnaryCall を使用して送信します。gRPC は残りの処理をすべて担当します。Future スタブの代わりに、blocking スタブや async スタブを作成することもできます。
RPC の受信
サーバーを更新するには、キー・バリュー・サービスとその実装を作成する必要があります。gRPC では、Server は 1 つ以上の Service を処理できることを思い出してください。ここでも、Protobuf が通常生成してくれるものを、自分で記述する必要があります。ベースとなるサービスは以下のようになります。
static abstract class KeyValueServiceImplBase implements BindableService {
public abstract void create(
KvGson.CreateRequest request, StreamObserver<CreateResponse> responseObserver);
public abstract void retrieve(/*...*/);
public abstract void update(/*...*/);
public abstract void delete(/*...*/);
/* Called by the Server to wire up methods to the handlers */
@Override
public final ServerServiceDefinition bindService() {
ServerServiceDefinition.Builder ssd = ServerServiceDefinition.builder(SERVICE_NAME);
ssd.addMethod(CREATE_METHOD, ServerCalls.asyncUnaryCall(
(request, responseObserver) -> create(request, responseObserver)));
ssd.addMethod(RETRIEVE_METHOD, /*...*/);
ssd.addMethod(UPDATE_METHOD, /*...*/);
ssd.addMethod(DELETE_METHOD, /*...*/);
return ssd.build();
}
}
KeyValueServiceImplBase は、サービス定義(サーバーが処理できるメソッドを記述するもの)と実装(各メソッドで何を実行するかを記述するもの)の両方として機能します。これは、gRPC とアプリケーションロジック間の接着剤として機能します。Proto から GSON への切り替えにおいて、サーバーコードでは実質的に変更は必要ありません。
final class KvService extends KvGson.KeyValueServiceImplBase {
@Override
public void create(
KvGson.CreateRequest request, StreamObserver<KvGson.CreateResponse> responseObserver) {
ByteBuffer key = ByteBuffer.wrap(request.key);
ByteBuffer value = ByteBuffer.wrap(request.value);
// ...
}
サーバーで全メソッドを実装した後、完全に機能する gRPC Java JSON エンコーディング RPC システムが完成しました。そして、私が何も隠していないことを示すために、
./gradlew :dependencies | grep -i proto
# no proto deps!
コードの最適化
Gson は Protobuf ほど高速ではありませんが、簡単に実現できることを逃す理由はありません。コードを実行すると、パフォーマンスが非常に遅いことがわかります。
./gradlew installDist
time ./build/install/kvstore/bin/kvstore
INFO: Did 215.883 RPCs/s
何が起こったのでしょうか?以前の 最適化 の投稿では、Protobuf バージョンで 2,500 RPC/s 近くを達成したのを見ました。JSON は遅いですが、そこまで 遅くはありません。マーシャラーを通過する JSON データを表示することで、問題の原因を確認できます。
{"key":[4,-100,-48,22,-128,85,115,5,56,34,-48,-1,-119,60,17,-13,-118]}
これは正しくありません!RetrieveRequest を見ると、キーのバイトがバイト文字列ではなく配列としてエンコードされていることがわかります。ワイヤーサイズは必要以上に大きくなっており、他の JSON コードとの互換性がない可能性があります。これを修正するために、GSON にこのデータを base64 エンコードされたバイトとしてエンコードおよびデコードするように指示しましょう。
private static final Gson gson =
new GsonBuilder().registerTypeAdapter(byte[].class, new TypeAdapter<byte[]>() {
@Override
public void write(JsonWriter out, byte[] value) throws IOException {
out.value(Base64.getEncoder().encodeToString(value));
}
@Override
public byte[] read(JsonReader in) throws IOException {
return Base64.getDecoder().decode(in.nextString());
}
}).create();
これをマーシャラーで使用すると、劇的なパフォーマンスの違いが見られます。
./gradlew installDist
time ./build/install/kvstore/bin/kvstore
INFO: Did 2,202.2 RPCs/s
以前より10倍近く高速になりました!独自のエンコーダーとメッセージを使用しながら、gRPC の効率性を引き続き活用できます。
結論
gRPC では、Protobuf 以外のエンコーダーも使用できます。Protobuf への依存はなく、さまざまな環境で動作するように特別に作られています。少し追加のボイラープレートコードを書くことで、好きなエンコーダーを使用できることがわかります。この記事では JSON だけを扱いましたが、gRPC は Thrift、Avro、Flatbuffers、Cap'n Proto、さらには生のバイトとも互換性があります!gRPC では、データの処理方法を自分で制御できます。(ただし、強力な後方互換性、型チェック、およびパフォーマンスの観点から、Protobuf を推奨します。)
完全に動作する実装を見たい場合は、すべてのコードが GitHub で利用可能です。