1 gRPC
Google 开发并且开源的一款高性能、跨语言的 RPC 框架,当前支持 C、Java 和 Go。跨语言,通信协议基于HTTP/2,序列化支持 PB(Protocol Buffer)和 JSON。
调用示例:
定义一个 say 方法,调用方通过 gRPC 调用服务提供方,然后服务提供方会返回一个字符串给调用方。
为了保证调用方和服务提供方能够正常通信,我们需要先约定一个通信过程中的契约,即 Java 里说的定义一个接口,接口只包含一个 say 方法。在 gRPC 里定义接口是写 Protocol Buffer 代码。
HelloWord 的 Protocol Buffer 代码:
syntax = "proto3";
option java_multiple_files = true;
option java_package = "io.grpc.hello";
option java_outer_classname = "HelloProto";
option objc_class_prefix = "HLW";
package hello;
service HelloService{
rpc Say(HelloRequest) returns (HelloReply) {}
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
就能为客户端和服务器端生成消息对象和 RPC 基础代码。利用 Protocol Buffer 的编译器 protoc,再配合 gRPC Java 插件(protoc-gen-grpc-java),通过命令行 protoc3 加上 plugin 和 proto 目录地址参数,就可生成消息对象和 gRPC 通信所需要的基础代码。Maven 工程用Maven 插件也可生成同样代码。
2 发送原理
生成完基础代码后,就可基于生成的代码写调用端代码:
package io.grpc.hello;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.StatusRuntimeException;
import java.util.concurrent.TimeUnit;
public class HelloWorldClient {
private final ManagedChannel channel;
private final HelloServiceGrpc.HelloServiceBlockingStub blockingStub;
/**
* 构建Channel连接
**/
public HelloWorldClient(String host, int port) {
this(ManagedChannelBuilder.forAddress(host, port)
.usePlaintext()
.build());
}
/**
* 构建Stub用于发请求
**/
HelloWorldClient(ManagedChannel channel) {
this.channel = channel;
blockingStub = HelloServiceGrpc.newBlockingStub(channel);
}
/**
* 调用完手动关闭
**/
public void shutdown() throws InterruptedException {
channel.shutdown().awaitTermination(5, TimeUnit.SECONDS);
}
/**
* 发送rpc请求
**/
public void say(String name) {
// 构建入参对象
HelloRequest request = HelloRequest.newBuilder().setName(name).build();
HelloReply response;
try {
// 发送请求
response = blockingStub.say(request);
} catch (StatusRuntimeException e) {
return;
}
System.out.println(response);
}
public static void main(String[] args) throws Exception {
HelloWorldClient client = new HelloWorldClient("127.0.0.1", 50051);
try {
client.say("world");
} finally {
client.shutdown();
}
}
}
调用端代码步骤
- 用 host 和 port 生成 channel 连接
- 用前面生成的 HelloService gRPC 创建 Stub 类
- 用生成的这个 Stub 调用 say 方法发起真正的 RPC 调用
2.1 ClientCalls.blockingUnaryCall核心逻辑
调用端代码里,只需一行代码
response = blockingStub.say(request);
即可发起一个 RPC 调用,这请求怎么发到服务提供者的?这对gRPC 使用者完全透明,我们只需关注是怎么创建出 stub 对象。
只有二进制才能在网络中传输,但若调用端代码入参是个字符对象,gRPC怎么把对象转成二进制数据?
流程图第3步,在 writePayload 之前,ClientCallImpl 里面有一行代码就是 method.streamRequest(message):
把对象转成一个 InputStream,就容易获得入参对象的二进制数据了。这方法不直接返回二进制数组,而是返回一个 InputStream 对象。
streamRequest的拥有者 method 是 MethodDescriptor 对象关联的一个实例,而 MethodDescriptor 存放要调用 RPC 服务的接口名、方法名、服务调用的方式及请求和响应的序列化和反序列化实现类。
即MethodDescriptor存储一些 RPC 调用过程的元数据,MethodDescriptor 里面 requestMarshaller 是在绑定请求的候,用来序列化方式对象,所以调用 method.streamRequest(message) 时,实际调用 requestMarshaller.stream(requestMessage),而 requestMarshaller 里会绑定一个 Parser,真正把对象转成 InputStream 对象。
3 请求数据“断句”
即二进制流经过网络传输后,如何还原请求前的语义。
gRPC通信协议基于标准 HTTP/2,相对HTTP/1.X ,最大特点多路复用、双向流,好比单行道和双行道。
既然在请求收到后需要进行请求“断句”,就要在发送的时候把断句的符号加上。gRPC 是基于 HTTP/2 协议,而 HTTP/2 传输基本单位 Frame。
3.1 Frame 格式
以固定 9 字节长度的 header,后面加上不定长 payload:
gRPC 里面就变成怎么构造一个 HTTP/2 的 Frame。
流程图的第 4 步,在 write 到 Netty 里面之前,在 MessageFramer.writePayload 方法里面会间接调用 writeKnownLengthUncompressed:
- 构造 Frame Header 和 Frame Body
- 再把构造的 Frame 发送到 NettyClientHandler
- 最后将 Frame 写入到 HTTP/2 Stream 中,完成请求消息的发送
4 接收原理
服务提供方收到请求后会怎么处理?看服务提供方代码:
static class HelloServiceImpl extends HelloServiceGrpc.HelloServiceImplBase {
@Override
public void say(HelloRequest req, StreamObserver<HelloReply> responseObserver) {
HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
}
HelloServiceImpl 类按 gRPC 实现了 HelloService 接口逻辑,但对调用者,并不能把它调用过来,因为我们没有把这个接口对外暴露,在 gRPC 里面我们是采用 Build 模式对底层服务绑定:
package io.grpc.hello;
import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.stub.StreamObserver;
import java.io.IOException;
public class HelloWorldServer {
private Server server;
/**
* 对外暴露服务
**/
private void start() throws IOException {
int port = 50051;
server = ServerBuilder.forPort(port)
.addService(new HelloServiceImpl())
.build()
.start();
Runtime.getRuntime().addShutdownHook(new Thread() {
@Override
public void run() {
HelloWorldServer.this.stop();
}
});
}
/**
* 关闭端口
**/
private void stop() {
if (server != null) {
server.shutdown();
}
}
/**
* 优雅关闭
**/
private void blockUntilShutdown() throws InterruptedException {
if (server != null) {
server.awaitTermination();
}
}
public static void main(String[] args) throws IOException, InterruptedException {
final HelloWorldServer server = new HelloWorldServer();
server.start();
server.blockUntilShutdown();
}
}
服务对外暴露的目的
让过来的请求在被还原成信息后,能找到对应接口实现。在这之前,先保证能正常接收请求,即要先开启一个 TCP 端口,让调用方可建立连接,并把二进制数据发送到这个连接通道。
这四个步骤是用来开启一个 Netty Server,并绑定编解码逻辑。
NettyServerHandler里会绑定一个 FrameListener,gRPC 会在这 Listener 里面处理收到数据请求的 Header 和 Body,并且也会处理 Ping、RST 命令等:
在收到 Header 或 Body 二进制数据后,NettyServerHandler 上绑定的FrameListener 会把这些二进制数据转到 MessageDeframer 里面,实现 gRPC 协议消息的解析 。
这些 Header 和 Body 数据是怎么分离出来的?
调用方发过来一串二进制数据,即前面开启 Netty Server 时绑定 Default HTTP/2FrameReader 的作用,帮助我们按照 HTTP/2 协议格式自动切出 Header 和 Body 数据。对我们上层应用 gRPC 来说,它可直接拿拆分后的数据用。
5 总结
实现了这两个过程,我们就可以完成一个点对点的 RPC 功能,但在实际使用的时候,我们的服务提供方通常都是以一个集群的方式对外提供服务的,所以在 gRPC 里面你还可以看到负载均衡、服务发现等功能。而且 gRPC 采用的是 HTTP/2 协议,我们还可以通过 Stream 方式来调用服务,以提升调用性能。
总的来说,其实我们可以简单地认为gRPC 就是采用 HTTP/2 协议,并且默认采用 PB 序列化方式的一种 RPC,它充分利用了 HTTP/2 的多路复用特性,使得我们可以在同一条链路上双向发送不同的 Stream 数据,以解决 HTTP/1.X 存在的性能问题。
FAQ
gRPC 调用的时候,关键把对象转成可传输的二进制,但gRPC没有直接转成二进制数组,而是返回一个 InputStream,why?
InputStream封装了底层传输的字节缓冲区实现,它通常是一组通过指针连接起来的内存块集,这些内存块由网络的零拷贝获取。由于不能保证能够从内存块中获取一个byte[],我们不能传递一个简单的byte[]或byte,并且可能需要一个目标byte[]来从缓冲区中获取数据。 byte[]缺点是需要从缓冲区中复制一个大的、连续数据,而实际上没有什么方法可以使它执行得更好。当使用压缩时,也不知道消息未压缩的长度,它是动态解压缩的。
Inputstream——避免二次拷贝(序列化+encode)——更高的性能。
A stream also has the advantage that you don't have to have all bytes in memory at the same time, which is convenient if the size of the data is large and can easily be handled in small chunks.
http2的核心实现不就是基于流,stream传输是建立在多路复用的基础上
内部调用用rpc 外部用http,why?内部应用之间通信更强调性能。