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