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 是一组 Method 的集合。在 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,并且方法描述符 将自动生成。
发送 RPCs
现在我们可以对 JSON 请求和响应进行编组了,我们需要更新我们的 KvClient(上一篇文章中使用的 gRPC 客户端)来使用我们的 MethodDescriptor。此外,由于我们不会使用任何 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,而不是 future stub。
接收 RPCs
要更新服务器,我们需要创建一个键值服务和实现。回想一下,在 gRPC 中,一个 Server 可以处理一个或多个 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/秒。JSON 确实慢,但没慢到那种程度。我们可以通过打印出 JSON 数据在 marshaller 中传递时的样子来找出问题
{"key":[4,-100,-48,22,-128,85,115,5,56,34,-48,-1,-119,60,17,-13,-118]}
这不对!查看 RetrieveRequest
,我们看到 key 的字节被编码成一个数组,而不是一个字节字符串。线路大小比实际需要的要大得多,并且可能与其他 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 上获得,如果你想查看一个完全可用的实现。