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