RSS

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 の観点から、ServiceMethod の集まりです。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 で利用可能です。