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 允许我们在代码中使用自己的类型,但提供了一种将这些类型序列化为字节的方法。

让我们继续使用键值存储服务。我们将修改我之前的 所以你想优化 gRPC 文章中使用的代码。

服务究竟是什么?

从 gRPC 的角度来看,服务方法的集合。在 Java 中,方法表示为 MethodDescriptor。每个 MethodDescriptor 都包含方法的名称、用于编码请求的 Marshaller 和用于编码响应的 Marshaller。它们还包括其他详细信息,例如调用是否为流式传输。为简单起见,我们将坚持使用具有单个请求和单个响应的一元 RPC。

由于我们不会生成任何代码,因此我们需要自己编写消息类。有四种方法,每种方法都有请求和响应类型。这意味着我们需要制作八条消息

  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。marshaller 知道如何将任意类型转换为 InputStream,然后将其传递到 gRPC 核心库中。它还能够在从网络解码数据时进行反向转换。对于 GSON,以下是 marshaller 的样子

  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 对象,此函数将生成一个 marshaller。使用 marshaller,我们可以为四种 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 请求和响应了,我们需要更新我们的 KvClient,即前一篇博文中使用的 gRPC 客户端,以使用我们的 MethodDescriptors。此外,由于我们不会使用任何 Protobuf 类型,代码需要使用 ByteBuffer 而不是 ByteString。也就是说,我们仍然可以在 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 存根。

接收 RPC

要更新服务器,我们需要创建一个键值服务和实现。回想一下,在 gRPC 中,一个Server可以处理一个或多个Services。同样,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/秒。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 上找到。