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 的角度来看,一个 服务(Service)方法(Methods) 的集合。在 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) 方法的 Method descriptor 示例:

  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 marshaller,并且方法描述符(method descriptors)将自动生成。

发送 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 对象,创建请求,然后使用 stub 库中的 ClientCalls.futureUnaryCall 发送它。gRPC 会为我们处理其余部分。你也可以创建阻塞式 stub 或异步 stub,而不是未来式 stub。

接收 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 数据在 marshaller 中传输时的样子来找出问题所在:

{"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();

在我们的 marshaller 中使用此方法,我们可以看到显著的性能差异:

./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 上找到。