This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,573 @@
# 1. RPC 入门
## 1.1 RPC框架原理
RPC框架的目标就是让远程服务调用更加简单、透明RPC框架负责屏蔽底层的传输方式TCP或者UDP、序列化方式XML/Json/二进制)和通信细节。服务调用者可以像调用本地接口一样调用远程的服务提供者,而不需要关心底层通信细节和调用过程。
RPC框架的调用原理图如下所示
<img src="https://static001.geekbang.org/resource/image/b2/fb/b265dc0bd6eae1b88b236f517609c9fb.png" alt="" />
## 1.2 业界主流的RPC框架
业界主流的RPC框架整体上分为三类
1. 支持多语言的RPC框架比较成熟的有Google的gRPC、ApacheFacebook的Thrift
1. 只支持特定语言的RPC框架例如新浪微博的Motan
1. 支持服务治理等服务化特性的分布式服务框架其底层内核仍然是RPC框架,例如阿里的Dubbo。
随着微服务的发展基于语言中立性原则构建微服务逐渐成为一种主流模式例如对于后端并发处理要求高的微服务比较适合采用Go语言构建而对于前端的Web界面则更适合Java和JavaScript。
因此基于多语言的RPC框架来构建微服务是一种比较好的技术选择。例如NetflixAPI服务编排层和后端的微服务之间就采用gRPC进行通信。
## 1.3 gRPC简介
gRPC是一个高性能、开源和通用的RPC框架面向服务端和移动端基于HTTP/2设计。
### 1.3.1 gRPC概览
gRPC是由Google开发并开源的一种语言中立的RPC框架当前支持C、Java和Go语言其中C版本支持C、C++、Node.js、C#等。当前Java版本最新Release版为1.5.0Git地址如下
[https://github.com/grpc/grpc-java](https://github.com/grpc/grpc-java)
gRPC的调用示例如下所示
<img src="https://static001.geekbang.org/resource/image/6d/d9/6d9a335ad96491e4d610a31b5089a2d9.png" alt="" />
### 1.3.2 gRPC特点
1. 语言中立,支持多种语言;
1. 基于IDL文件定义服务通过proto3工具生成指定语言的数据结构、服务端接口以及客户端Stub
1. 通信协议基于标准的HTTP/2设计支持双向流、消息头压缩、单TCP的多路复用、服务端推送等特性这些特性使得gRPC在移动端设备上更加省电和节省网络流量
1. 序列化支持PBProtocol Buffer和JSONPB是一种语言无关的高性能序列化框架基于HTTP/2 + PB,保障了RPC调用的高性能。
# 2. gRPC服务端创建
以官方的helloworld为例介绍gRPC服务端创建以及service调用流程采用简单RPC模式
## 2.1 服务端创建业务代码
服务定义如下helloworld.proto
```
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
```
服务端创建代码如下HelloWorldServer类
```
private void start() throws IOException {
/* The port on which the server should run */
int port = 50051;
server = ServerBuilder.forPort(port)
.addService(new GreeterImpl())
.build()
.start();
...
```
其中服务端接口实现类GreeterImpl如下所示
```
static class GreeterImpl extends GreeterGrpc.GreeterImplBase {
@Override
public void sayHello(HelloRequest req, StreamObserver&lt;HelloReply&gt; responseObserver) {
HelloReply reply = HelloReply.newBuilder().setMessage(&quot;Hello &quot; + req.getName()).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
}
```
## 2.2 服务端创建流程
gRPC服务端创建采用Build模式对底层服务绑定、transportServer和NettyServer的创建和实例化做了封装和屏蔽让服务调用者不用关心RPC调用细节整体上分为三个过程
1. 创建Netty HTTP/2服务端
1. 将需要调用的服务端接口实现类注册到内部的Registry中RPC调用时可以根据RPC请求消息中的服务定义信息查询到服务接口实现类
1. 创建gRPC Server它是gRPC服务端的抽象聚合了各种Listener用于RPC消息的统一调度和处理。
下面我们看下gRPC服务端创建流程
<img src="https://static001.geekbang.org/resource/image/c6/37/c64c0e8e97711dc62e866861cd5c2e37.png" alt="" />
gRPC服务端创建关键流程分析
<li>
**NettyServer实例创建**gRPC服务端创建首先需要初始化NettyServer它是gRPC基于Netty 4.1 HTTP/2协议栈之上封装的HTTP/2服务端。NettyServer实例由NettyServerBuilder的buildTransportServer方法构建NettyServer构建完成之后监听指定的Socket地址即可实现基于HTTP/2协议的请求消息接入。
</li>
<li>
**绑定IDL定义的服务接口实现类**gRPC与其它一些RPC框架的差异点是服务接口实现类的调用并不是通过动态代理和反射机制而是通过proto工具生成代码在服务端启动时将服务接口实现类实例注册到gRPC内部的服务注册中心上。请求消息接入之后可以根据服务名和方法名直接调用启动时注册的服务实例而不需要通过反射的方式进行调用性能更优。
</li>
<li>
**gRPC服务实例ServerImpl构建**ServerImpl负责整个gRPC服务端消息的调度和处理创建ServerImpl实例过程中会对服务端依赖的对象进行初始化例如Netty的线程池资源、gRPC的线程池、内部的服务注册类InternalHandlerRegistryServerImpl初始化完成之后就可以调用NettyServer的start方法启动HTTP/2服务端接收gRPC客户端的服务调用请求。
</li>
## 2.3 服务端service调用流程
gRPC的客户端请求消息由Netty Http2ConnectionHandler接入由gRPC负责将PB消息或者JSON反序列化为POJO对象然后通过服务定义查询到该消息对应的接口实例发起本地Java接口调用调用完成之后将响应消息序列化为PB或者JSON通过HTTP2 Frame发送给客户端。<br />
流程并不复杂但是细节却比较多整个service调用可以划分为如下四个过程
1. gRPC请求消息接入
1. gRPC消息头和消息体处理
1. 内部的服务路由和调用;
1. 响应消息发送。
### 2.3.1 gRPC请求消息接入
gRPC的请求消息由Netty HTTP/2协议栈接入通过gRPC注册的Http2FrameListener将解码成功之后的HTTP Header和HTTP Body发送到gRPC的NettyServerHandler中实现基于HTTP/2的RPC请求消息接入。
gRPC请求消息接入流程如下
<img src="https://static001.geekbang.org/resource/image/b2/cf/b269a81ef5012a8ed5409e97c071eecf.png" alt="" />
关键流程解读如下:
<li>
Netty 4.1提供了HTTP/2底层协议栈通过Http2ConnectionHandler及其依赖的其它类库实现了HTTP/2消息的统一接入和处理。
通过注册Http2FrameListener监听器可以回调接收HTTP2协议的消息头、消息体、优先级、Ping、SETTINGS等。
gRPC通过FrameListener重载Http2FrameListener的onDataRead、onHeadersRead等方法将Netty的HTTP/2消息转发到gRPC的NettyServerHandler中
</li>
<li>
Netty的HTTP/2协议接入仍然是通过ChannelHandler的CodeC机制实现它并不影响NIO线程模型。
因此理论上各种协议、以及同一个协议的多个服务端实例可以共用同一个NIO线程池NioEventLoopGroup,也可以独占。
在实践中独占模式普遍会存在线程资源占用过载问题,很容易出现句柄等资源泄漏。
在gRPC中为了避免该问题默认采用共享池模式创建NioEventLoopGroup所有的gRPC服务端实例都统一从SharedResourceHolder分配NioEventLoopGroup资源实现NioEventLoopGroup的共享。
</li>
### 2.3.2 gRPC消息头和消息体处理
gRPC消息头的处理入口是NettyServerHandler的onHeadersRead(),处理流程如下所示:
<img src="https://static001.geekbang.org/resource/image/12/99/125c42c9d4f333e23b9896f478517099.png" alt="" />
处理流程如下:
<li>
对HTTP Header的Content-Type校验此处必须是&quot;application/grpc&quot;
</li>
<li>
从HTTP Header的URL中提取接口和方法名以HelloWorldServer为例它的method为“helloworld.Greeter/SayHello”
</li>
<li>
<p>将Netty的HTTP Header转换成gRPC内部的MetadataMetadata内部维护了一个键值对的二维数组namesAndValues以及一系列的类型转换方法点击放大图片<br />
<img src="https://static001.geekbang.org/resource/image/7c/b9/7c833bc328f64ef864b430c45d68fab9.png" alt="" /></p>
</li>
<li>
创建NettyServerStream对象它持有了Sink和TransportState类负责将消息封装成GrpcFrameCommand与底层Netty进行交互实现协议消息的处理
</li>
<li>
创建NettyServerStream之后会触发ServerTransportListener的streamCreated方法在该方法中主要完成了消息上下文和gRPC业务监听器的创建
</li>
<li>
gRPC上下文创建CancellableContext创建之后支持超时取消如果gRPC客户端请求消息在Http Header中携带了“grpc-timeout”系统在创建CancellableContext的同时会启动一个延时定时任务延时周期为超时时间一旦该定时器成功执行就会调用CancellableContext.CancellationListener的cancel方法发送CancelServerStreamCommand指令
</li>
<li>
JumpToApplicationThreadServerStreamListener的创建它是ServerImpl的内部类从命名上基本可以看出它的用途即从ServerStream跳转到应用线程中进行服务调用gRPC服务端的接口调用主要通过JumpToApplicationThreadServerStreamListener的messageRead和halfClosed方法完成
</li>
<li>
将NettyServerStream的TransportState缓存到Netty的Http2Stream中当处理请求消息体时可以根据streamId获取到Http2Stream进而根据“streamKey”还原NettyServerStream的TransportState进行后续处理。
</li>
gRPC消息体的处理入口是NettyServerHandler的onDataRead(),处理流程如下所示:
<img src="https://static001.geekbang.org/resource/image/20/9a/20c7a0f2ca1c9b801a406777283cb99a.png" alt="" />
消息体处理比较简单,下面就关键技术点进行讲解:
<li>
因为Netty HTTP/2协议Http2FrameListener分别提供了onDataRead和onHeadersRead回调方法所以gRPC NettyServerHandler在处理完消息头之后需要缓存上下文以便后续处理消息体时使用
</li>
<li>
onDataRead和onHeadersRead方法都是由Netty的NIO线程负责调度但是在执行onDataRead的过程中发生了线程切换如下所示ServerTransportListenerImpl类
</li>
```
wrappedExecutor.execute(new ContextRunnable(context) {
@Override
public void runInContext() {
ServerStreamListener listener = NOOP_LISTENER;
try {
ServerMethodDefinition&lt;?, ?&gt; method = registry.lookupMethod(methodName);
if (method == null) {
method = fallbackRegistry.lookupMethod(methodName, stream.getAuthority());
}
```
因此,实际上它们是并行+交叉串行实行的,后续章节介绍线程模型时会介绍切换原则。
### 2.3.3 内部的服务路由和调用
内部的服务路由和调用,主要包括如下几个步骤:
1. 将请求消息体反序列为Java的POJO对象即IDL中定义的请求参数对象
1. 根据请求消息头中的方法名到注册中心查询到对应的服务定义信息;
1. 通过Java本地接口调用方式调用服务端启动时注册的IDL接口实现类。
具体流程如下所示:
<img src="https://static001.geekbang.org/resource/image/80/4d/808ed4d507d2f72624f80457b2d3ca4d.png" alt="" />
中间的交互流程比较复杂,涉及的类较多,但是关键步骤主要有三个:
<li>
**解码:**对HTTP/2 Body进行应用层解码转换成服务端接口的请求参数解码的关键就是调用requestMarshaller.parse(input)将PB码流转换成Java对象
</li>
<li>
**路由:**根据URL中的方法名从内部服务注册中心查询到对应的服务实例路由的关键是调用registry.lookupMethod(methodName)获取到ServerMethodDefinition对象
</li>
<li>
**调用:**调用服务端接口实现类的指定方法实现RPC调用与一些RPC框架不同的是此处调用是Java本地接口调用非反射调用性能更优它的实现关键是UnaryRequestMethod.invoke(request, responseObserver)方法。
</li>
### 2.3.4 响应消息发送
响应消息的发送由StreamObserver的onNext触发流程如下所示
<img src="https://static001.geekbang.org/resource/image/77/a6/77aedcd98c5910cad5f8d3e50cddc2a6.png" alt="" />
响应消息的发送原理如下:
<li>
分别发送gRPC HTTP/2响应消息头和消息体由NettyServerStream的Sink将响应消息封装成SendResponseHeadersCommand和SendGrpcFrameCommand加入到WriteQueue中
</li>
<li>
<p>WriteQueue通过Netty的NioEventLoop线程进行消息处理NioEventLoop将SendResponseHeadersCommand和SendGrpcFrameCommand写入到Netty的<br />
Channel中进而触发DefaultChannelPipeline的<br />
write(Object msg, ChannelPromise promise)操作;</p>
</li>
<li>
响应消息通过ChannelPipeline职责链进行调度触发NettyServerHandler的sendResponseHeaders和sendGrpcFrame方法调用Http2ConnectionEncoder的writeHeaders和writeData方法将响应消息通过Netty的HTTP/2协议栈发送给客户端。
</li>
需要指出的是请求消息的接收、服务调用以及响应消息发送多次发生NIO线程和应用线程之间的互相切换以及并行处理。因此上述流程中的一些步骤并不是严格按照图示中的顺序执行的后续线程模型章节会做分析和介绍。
# 3. 源码分析
## 3.1 主要类和功能交互流程
### 3.1.1 gRPC请求消息头处理
<img src="https://static001.geekbang.org/resource/image/32/57/325ca29ddb2cf12eb96fa10af2cfe757.png" alt="" />
gRPC请求消息头处理涉及的主要类库如下
1. **NettyServerHandler**gRPC Netty Server的ChannelHandler实现负责HTTP/2请求消息和响应消息的处理
1. **SerializingExecutor**应用调用线程池负责RPC请求消息的解码、响应消息编码以及服务接口的调用等
1. **MessageDeframer**负责请求Framer的解析主要用于处理HTTP/2 Header和Body的读取。
1. **ServerCallHandler**真正的服务接口处理类提供onMessage(ReqT request)和onHalfClose()方法,用于服务接口的调用。
### 3.1.2 gRPC请求消息体处理和服务调用
<img src="https://static001.geekbang.org/resource/image/37/8d/37a206a67f0f75990a01d64859df398d.png" alt="" />
### 3.1.3 gRPC响应消息处理
<img src="https://static001.geekbang.org/resource/image/df/d6/df4800c136c5097f6ffe68fb3306f3d6.png" alt="" />
需要说明的是响应消息的发送由调用服务端接口的应用线程执行在本示例中由SerializingExecutor进行调用。
当请求消息头被封装成SendResponseHeadersCommand并被插入到WriteQueue之后后续操作由Netty的NIO线程NioEventLoop负责处理。
应用线程继续发送响应消息体将其封装成SendGrpcFrameCommand并插入到WriteQueue队列中由Netty的NIO线程NioEventLoop处理。响应消息的发送严格按照顺序即先消息头后消息体。
## 3.2 源码分析
了解gRPC服务端消息接入和service调用流程之后针对主要的流程和类库进行源码分析以加深对gRPC服务端工作原理的了解。
### 3.2.1 Netty服务端创建
基于Netty的HTTP/2协议栈构建gRPC服务端Netty HTTP/2协议栈初始化代码如下所示创建NettyServerHandlerNettyServerHandler类
```
frameWriter = new WriteMonitoringFrameWriter(frameWriter, keepAliveEnforcer);
Http2ConnectionEncoder encoder = new DefaultHttp2ConnectionEncoder(connection, frameWriter);
Http2ConnectionDecoder decoder = new FixedHttp2ConnectionDecoder(connection, encoder,
frameReader);
Http2Settings settings = new Http2Settings();
settings.initialWindowSize(flowControlWindow);
settings.maxConcurrentStreams(maxStreams);
settings.maxHeaderListSize(maxHeaderListSize);
return new NettyServerHandler(
transportListener, streamTracerFactories, decoder, encoder, settings, maxMessageSize,
keepAliveTimeInNanos, keepAliveTimeoutInNanos,
maxConnectionAgeInNanos, maxConnectionAgeGraceInNanos,
keepAliveEnforcer);
```
创建gRPC FrameListener作为Http2FrameListener监听HTTP/2消息的读取回调到NettyServerHandler中NettyServerHandler类
```
decoder().frameListener(new FrameListener());
```
将NettyServerHandler添加到Netty的ChannelPipeline中接收和发送HTTP/2消息NettyServerTransport类
```
ChannelHandler negotiationHandler = protocolNegotiator.newHandler(grpcHandler);
channel.pipeline().addLast(negotiationHandler);
```
gRPC服务端请求和响应消息统一由NettyServerHandler拦截处理相关方法如下
<img src="https://static001.geekbang.org/resource/image/5b/ee/5b1c230a68d37379c544cbe7b59270ee.png" alt="" />
NettyServerHandler是gRPC应用侧和底层协议栈的桥接类负责将原生的HTTP/2消息调度到gRPC应用侧同时将应用侧的消息发送到协议栈。
### 3.2.2 服务实例创建和绑定
gRPC服务端启动时需要将调用的接口实现类实例注册到内部的服务注册中心用于后续的接口调用关键代码如下InternalHandlerRegistry类
```
Builder addService(ServerServiceDefinition service) {
services.put(service.getServiceDescriptor().getName(), service);
return this;
}
```
服务接口绑定时由Proto3工具生成代码重载bindService()方法GreeterImplBase类
```
@java.lang.Override public final io.grpc.ServerServiceDefinition bindService() {
return io.grpc.ServerServiceDefinition.builder(getServiceDescriptor())
.addMethod(
METHOD_SAY_HELLO,
asyncUnaryCall(
new MethodHandlers&lt;
io.grpc.examples.helloworld.HelloRequest,
io.grpc.examples.helloworld.HelloReply&gt;(
this, METHODID_SAY_HELLO)))
.build();
}
```
### 3.2.3 service调用
<li>
<p>**gRPC消息的接收**<br />
gRPC消息的接入由Netty HTTP/2协议栈回调gRPC的FrameListener进而调用NettyServerHandler的onHeadersRead(ChannelHandlerContext ctx, int streamId, Http2Headers headers)和onDataRead(int streamId, ByteBuf data, int padding, boolean endOfStream),代码如下所示:<br />
<img src="https://static001.geekbang.org/resource/image/90/8c/90c06120c24587569abd596cb889ac8c.png" alt="" /></p>
消息头和消息体的处理主要由MessageDeframer的deliver方法完成相关代码如下MessageDeframer类
</li>
```
if (inDelivery) {
return;
}
inDelivery = true;
try {
while (pendingDeliveries &gt; 0 &amp;&amp; readRequiredBytes()) {
switch (state) {
case HEADER:
processHeader();
break;
case BODY:
processBody();
pendingDeliveries--;
break;
default:
throw new AssertionError(&quot;Invalid state: &quot; + state);
```
gRPC请求消息PB的解码由PrototypeMarshaller负责代码如下(ProtoLiteUtils类)
```
public T parse(InputStream stream) {
if (stream instanceof ProtoInputStream) {
ProtoInputStream protoStream = (ProtoInputStream) stream;
if (protoStream.parser() == parser) {
try {
T message = (T) ((ProtoInputStream) stream).message();
...
```
<li>**gRPC响应消息发送**<br />
响应消息分为两部分发送响应消息头和消息体分别被封装成不同的WriteQueue.AbstractQueuedCommand插入到WriteQueue中。<br />
消息头封装代码NettyServerStream类</li>
```
public void writeHeaders(Metadata headers) {
writeQueue.enqueue(new SendResponseHeadersCommand(transportState(),
Utils.convertServerHeaders(headers), false),
true);
}
```
消息体封装代码NettyServerStream类
```
ByteBuf bytebuf = ((NettyWritableBuffer) frame).bytebuf();
final int numBytes = bytebuf.readableBytes();
onSendingBytes(numBytes);
writeQueue.enqueue(
new SendGrpcFrameCommand(transportState(), bytebuf, false),
channel.newPromise().addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) throws Exception {
transportState().onSentBytes(numBytes);
}
}), flush);
```
Netty的NioEventLoop将响应消息发送到ChannelPipeline最终被NettyServerHandler拦截并处理。<br />
响应消息头处理代码如下NettyServerHandler类
```
private void sendResponseHeaders(ChannelHandlerContext ctx, SendResponseHeadersCommand cmd,
ChannelPromise promise) throws Http2Exception {
int streamId = cmd.stream().id();
Http2Stream stream = connection().stream(streamId);
if (stream == null) {
resetStream(ctx, streamId, Http2Error.CANCEL.code(), promise);
return;
}
if (cmd.endOfStream()) {
closeStreamWhenDone(promise, streamId);
}
encoder().writeHeaders(ctx, streamId, cmd.headers(), 0, cmd.endOfStream(), promise);
}
```
响应消息体处理代码如下NettyServerHandler类
```
private void sendGrpcFrame(ChannelHandlerContext ctx, SendGrpcFrameCommand cmd,
ChannelPromise promise) throws Http2Exception {
if (cmd.endStream()) {
closeStreamWhenDone(promise, cmd.streamId());
}
encoder().writeData(ctx, cmd.streamId(), cmd.content(), 0, cmd.endStream(), promise);
}
```
<li>服务接口实例调用:<br />
经过一系列预处理最终由ServerCalls的ServerCallHandler调用服务接口实例代码如下ServerCalls类</li>
```
return new EmptyServerCallListener&lt;ReqT&gt;() {
ReqT request;
@Override
public void onMessage(ReqT request) {
this.request = request;
}
@Override
public void onHalfClose() {
if (request != null) {
method.invoke(request, responseObserver);
responseObserver.freeze();
if (call.isReady()) {
onReady();
}
```
最终的服务实现类调用如下GreeterGrpc类
```
public void invoke(Req request, io.grpc.stub.StreamObserver&lt;Resp&gt; responseObserver) {
switch (methodId) {
case METHODID_SAY_HELLO:
serviceImpl.sayHello((io.grpc.examples.helloworld.HelloRequest) request,
(io.grpc.stub.StreamObserver&lt;io.grpc.examples.helloworld.HelloReply&gt;) responseObserver);
break;
default:
throw new AssertionError();
}
```
## 3.3 服务端线程模型
gRPC的线程由Netty线程 + gRPC应用线程组成它们之间的交互和切换比较复杂下面做下详细介绍。
### 3.3.1 Netty Server线程模型
<img src="https://static001.geekbang.org/resource/image/7c/3c/7c205269a713a3b98033ac8760b3633c.png" alt="" />
它的工作流程总结如下:
<li>
从主线程池bossGroup中随机选择一个Reactor线程作为Acceptor线程用于绑定监听端口接收客户端连接
</li>
<li>
Acceptor线程接收客户端连接请求之后创建新的SocketChannel将其注册到主线程池bossGroup的其它Reactor线程上由其负责接入认证、握手等操作
</li>
<li>
步骤2完成之后应用层的链路正式建立将SocketChannel从主线程池的Reactor线程的多路复用器上摘除重新注册到Sub线程池workerGroup的线程上用于处理I/O的读写操作。
</li>
Netty Server使用的NIO线程实现是NioEventLoop它的职责如下
<li>
作为服务端Acceptor线程负责处理客户端的请求接入
</li>
<li>
作为客户端Connecor线程负责注册监听连接操作位用于判断异步连接结果
</li>
<li>
作为I/O线程监听网络读操作位负责从SocketChannel中读取报文
</li>
<li>
作为I/O线程负责向SocketChannel写入报文发送给对方如果发生写半包会自动注册监听写事件用于后续继续发送半包数据直到数据全部发送完成
</li>
<li>
作为定时任务线程,可以执行定时任务,例如链路空闲检测和发送心跳消息等;
</li>
<li>
作为线程执行器可以执行普通的任务TaskRunnable
</li>
### 3.3.2 gRPC service 线程模型
gRPC服务端调度线程为SerializingExecutor它实现了Executor和Runnable接口通过外部传入的Executor对象调度和处理Runnable同时内部又维护了一个任务队列ConcurrentLinkedQueue通过run方法循环处理队列中存放的Runnable对象代码示例如下
<img src="https://static001.geekbang.org/resource/image/44/be/44d1dde76116b42ff1f110697b2e39be.png" alt="" />
### 3.3.3 线程调度和切换策略
Netty Server I/O线程的职责
1. gRPC请求消息的读取、响应消息的发送
1. HTTP/2协议消息的编码和解码
1. NettyServerHandler的调度
gRPC service线程的职责
1. 将gRPC请求消息PB码流反序列化为接口的请求参数对象
1. 将接口响应对象序列化为PB码流
1. gRPC服务端接口实现类调用
gRPC的线程模型遵循Netty的线程分工原则协议层消息的接收和编解码由Netty的I/O(NioEventLoop)线程负责后续应用层的处理由应用线程负责防止由于应用处理耗时而阻塞Netty的I/O线程。
基于上述分工原则在gRPC请求消息的接入和响应发送过程中系统不断的在Netty I/O线程和gRPC应用线程之间进行切换。明白了分工原则也就能够理解为什么要做频繁的线程切换。
gRPC线程模型存在的一个缺点就是在一次RPC调用过程中做了多次I/O线程到应用线程之间的切换频繁切换会导致性能下降这也是为什么gRPC性能比一些基于私有协议构建的RPC框架性能低的一个原因。尽管gRPC的性能已经比较优异但是仍有一定的优化空间。
源代码下载地址:
链接:[https://github.com/geektime-geekbang/gRPC_LLF/tree/master](https://github.com/geektime-geekbang/gRPC_LLF/tree/master)

View File

@@ -0,0 +1,677 @@
# 1. gRPC客户端创建流程
## 1.1 背景
gRPC 是在 HTTP/2 之上实现的 RPC 框架HTTP/2 是第7层应用层协议它运行在TCP第4层 - 传输层协议之上相比于传统的REST/JSON机制有诸多的优点
1. 基于HTTP/2之上的二进制协议Protobuf序列化机制
1. 一个连接上可以多路复用,并发处理多个请求和响应;
1. 多种语言的类库实现;
1. 服务定义文件和自动代码生成(.proto文件和Protobuf编译工具
此外gRPC还提供了很多扩展点用于对框架进行功能定制和扩展例如通过开放负载均衡接口可以无缝的与第三方组件进行集成对接Zookeeper、域名解析服务、SLB服务等
一个完整的 RPC 调用流程示例如下:
<img src="https://static001.geekbang.org/resource/image/1d/17/1d4dcdb9272422124313171a30a01417.png" alt="" />
gRPC的RPC调用与上述流程相似下面我们一起学习下gRPC的客户端创建和服务调用流程。
## 1.2 业务代码示例
以gRPC入门级的helloworld Demo为例客户端发起RPC调用的代码主要包括如下几部分
1. 根据hostname和port创建ManagedChannelImpl
1. 根据helloworld.proto文件生成的GreeterGrpc创建客户端Stub用来发起RPC调用
1. 使用客户端StubGreeterBlockingStub发起RPC调用获取响应。
相关示例代码如下所示HelloWorldClient类
```
HelloWorldClient(ManagedChannelBuilder&lt;?&gt; channelBuilder) {
channel = channelBuilder.build();
blockingStub = GreeterGrpc.newBlockingStub(channel);
futureStub = GreeterGrpc.newFutureStub(channel);
stub = GreeterGrpc.newStub(channel);
}
public void blockingGreet(String name) {
logger.info(&quot;Will try to greet &quot; + name + &quot; ...&quot;);
HelloRequest request = HelloRequest.newBuilder().setName(name).build();
try {
HelloReply response = blockingStub
.sayHello(request);
...
```
## 1.3 RPC调用流程
gRPC的客户端调用主要包括基于Netty的HTTP/2客户端创建、客户端负载均衡、请求消息的发送和响应接收处理四个流程。
### 1.3.1 客户端调用总体流程
gRPC的客户端调用总体流程如下图所示
<img src="https://static001.geekbang.org/resource/image/ec/da/ece53387619fc837732ee603be3cc3da.png" alt="" />
gRPC的客户端调用流程如下
1. 客户端Stub(GreeterBlockingStub)调用sayHello(request)发起RPC调用
1. 通过DnsNameResolver进行域名解析获取服务端的地址信息列表随后使用默认的LoadBalancer策略选择一个具体的gRPC服务端实例
1. 如果与路由选中的服务端之间没有可用的连接则创建NettyClientTransport和NettyClientHandler发起HTTP/2连接
1. 对请求消息使用PBProtobuf做序列化通过HTTP/2 Stream发送给gRPC服务端
1. 接收到服务端响应之后使用PBProtobuf做反序列化
1. 回调GrpcFuture的set(Response)方法唤醒阻塞的客户端调用线程获取RPC响应。
需要指出的是客户端同步阻塞RPC调用阻塞的是调用方线程通常是业务线程底层Transport的I/O线程Netty的NioEventLoop仍然是非阻塞的。
### 1.3.2 ManagedChannel创建流程
ManagedChannel是对Transport层SocketChannel的抽象Transport层负责协议消息的序列化和反序列化以及协议消息的发送和读取。
ManagedChannel将处理后的请求和响应传递给与之相关联的ClientCall进行上层处理同时ManagedChannel提供了对Channel的生命周期管理链路创建、空闲、关闭等
ManagedChannel提供了接口式的切面ClientInterceptor它可以拦截RPC客户端调用注入扩展点以及功能定制方便框架的使用者对gRPC进行功能扩展。
ManagedChannel的主要实现类ManagedChannelImpl创建流程如下
<img src="https://static001.geekbang.org/resource/image/06/fa/063dc39295105718484fe8e1f9dc85fa.png" alt="" />
流程关键技术点解读:
1. 使用builder模式创建ManagedChannelBuilder实现类NettyChannelBuilderNettyChannelBuilder提供了buildTransportFactory工厂方法创建NettyTransportFactory最终用于创建NettyClientTransport
1. 初始化HTTP/2连接方式采用plaintext协商模式还是默认的TLS模式HTTP/2的连接有两种模式h2基于TLS之上构建的HTTP/2和h2c直接在TCP之上构建的HTTP/2
1. 创建NameResolver.Factory工厂类用于服务端URI的解析gRPC默认采用DNS域名解析方式。
ManagedChannel实例构造完成之后即可创建ClientCall发起RPC调用。
### 1.3.3 ClientCall创建流程
完成ManagedChannelImpl创建之后由ManagedChannelImpl发起创建一个新的ClientCall实例。ClientCall的用途是业务应用层的消息调度和处理它的典型用法如下
```
call = channel.newCall(unaryMethod, callOptions);
call.start(listener, headers);
call.sendMessage(message);
call.halfClose();
call.request(1);
// wait for listener.onMessage()
```
ClientCall实例的创建流程如下所示
<img src="https://static001.geekbang.org/resource/image/80/bf/80962363b8b3e347da50ad34432eb4bf.png" alt="" />
流程关键技术点解读:
<li>
ClientCallImpl的主要构造参数是MethodDescriptor和CallOptions其中MethodDescriptor存放了需要调用RPC服务的接口名、方法名、服务调用的方式例如UNARY类型以及请求和响应的序列化和反序列化实现类。
CallOptions则存放了RPC调用的其它附加信息例如超时时间、鉴权信息、消息长度限制和执行客户端调用的线程池等。
</li>
<li>
设置压缩和解压缩的注册类CompressorRegistry和DecompressorRegistry以便可以按照指定的压缩算法对HTTP/2消息做压缩和解压缩。
</li>
ClientCallImpl实例创建完成之后就可以调用ClientTransport创建HTTP/2 Client向gRPC服务端发起远程服务调用。
### 1.3.4 基于Netty的HTTP/2 Client创建流程
gRPC客户端底层基于Netty4.1的HTTP/2协议栈框架构建以便可以使用HTTP/2协议来承载RPC消息在满足标准化规范的前提下提升通信性能。
gRPC HTTP/2协议栈客户端的关键实现是NettyClientTransport和NettyClientHandler客户端初始化流程如下所示
<img src="https://static001.geekbang.org/resource/image/a4/70/a4c79938fd8877619370d349d0e8f170.png" alt="" />
流程关键技术点解读:
<li>
**NettyClientHandler的创建**级联创建Netty的Http2FrameReader、Http2FrameWriter和Http2Connection用于构建基于Netty的gRPC HTTP/2客户端协议栈。
</li>
<li>
**HTTP/2 Client启动**仍然基于Netty的Bootstrap来初始化并启动客户端但是有两个细节需要注意
<ul>
<li>
NettyClientHandler实际被包装成ProtocolNegotiator.Handler用于HTTP/2的握手协商创建之后不是由传统的ChannelInitializer在初始化Channel时将NettyClientHandler加入到pipeline中而是直接通过Bootstrap的handler方法直接加入到pipeline中以便可以立即接收发送任务。
</li>
<li>
客户端使用的work线程组并非通常意义的EventLoopGroup而是一个EventLoop即HTTP/2客户端使用的work线程并非一组线程默认线程数为CPU内核 * 2而是一个EventLoop线程。这个其实也很容易理解一个NioEventLoop线程可以同时处理多个HTTP/2客户端连接它是多路复用的对于单个HTTP/2客户端如果默认独占一个work线程组将造成极大的资源浪费同时也可能会导致句柄溢出并发启动大量HTTP/2客户端
</li>
</ul>
</li>
<li>
**WriteQueue创建**Netty的NioSocketChannel初始化并向Selector注册之后发起HTTP连接之前立即由NettyClientHandler创建WriteQueue用于接收并处理gRPC内部的各种Command例如链路关闭指令、发送Frame指令、发送Ping指令等。
</li>
HTTP/2 Client创建完成之后即可由客户端根据协商策略发起HTTP/2连接。如果连接创建成功后续即可复用该HTTP/2连接进行RPC调用。
### 1.3.5 HTTP/2连接创建流程
HTTP/2在TCP连接之初通过协商的方式进行通信只有协商成功才能进行后续的业务层数据发送和接收。
HTTP/2的版本标识分为两类
1. 基于TLS之上构架的HTTP/2, 即HTTPS使用h2表示ALPN0x68与0x32
1. 直接在TCP之上构建的HTTP/2,即HTTP使用h2c表示。
HTTP/2连接创建分为两种通过协商升级协议方式和直接连接方式。
假如不知道服务端是否支持HTTP/2可以先使用HTTP/1.1进行协商,客户端发送协商请求消息(只含消息头),报文示例如下:
```
GET / HTTP/1.1
Host: 127.0.0.1
Connection: Upgrade, HTTP2-Settings
Upgrade: h2c
HTTP2-Settings: &lt;base64url encoding of HTTP/2 SETTINGS payload&gt;
```
服务端接收到协商请求之后如果不支持HTTP/2则直接按照HTTP/1.1响应返回双方通过HTTP/1.1进行通信,报文示例如下:
```
HTTP/1.1 200 OK
Content-Length: 28
Content-Type: text/css
body...
```
如果服务端支持HTTP/2,则协商成功返回101结果码通知客户端一起升级到HTTP/2进行通信示例报文如下
```
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: h2c
[ HTTP/2 connection...
```
101响应之后服务需要发送SETTINGS帧作为连接序言客户端接收到101响应之后也必须发送一个序言作为回应示例如下
```
PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
SETTINGS帧
```
客户端序言发送完成之后可以不需要等待服务端的SETTINGS帧而直接发送业务请求Frame。
假如客户端和服务端已经约定使用HTTP/2,则可以免去101协商和切换流程直接发起HTTP/2连接具体流程如下所示
<img src="https://static001.geekbang.org/resource/image/29/00/292a3a065e39eb5af7baf44778ea4900.png" alt="" />
几个关键点:
1. 如果已经明确知道服务端支持HTTP/2则可免去通过HTTP/1.1 101协议切换方式进行升级TCP连接建立之后即可发送序言否则只能在接收到服务端101响应之后发送序言
1. 针对一个连接服务端第一个要发送的帧必须是SETTINGS帧连接序言所包含的SETTINGS帧可以为空
1. 客户端可以在发送完序言之后发送应用帧数据不用等待来自服务器端的序言SETTINGS帧。
gRPC支持三种Protocol Negotiator策略
1. **PlaintextNegotiator**明确服务端支持HTTP/2采用HTTP直接连接的方式与服务端建立HTTP/2连接省去101协议切换过程
1. **PlaintextUpgradeNegotiator**不清楚服务端是否支持HTTP/2采用HTTP/1.1协商模式切换升级到HTTP/2
1. **TlsNegotiator**在TLS之上构建HTTP/2协商采用ALPN扩展协议&quot;h2&quot;作为协议标识符。
下面我们以PlaintextNegotiator为例了解下基于Netty的HTTP/2连接创建流程
<img src="https://static001.geekbang.org/resource/image/aa/bd/aac3f5843e2af841d72185ed9cd3c0bd.png" alt="" />
### 1.3.6 负载均衡策略
总体上看RPC的负载均衡策略有两大类
1. 服务端负载均衡(例如代理模式、外部负载均衡服务)
1. 客户端负载均衡(内置负载均衡策略和算法,客户端实现)
外部负载均衡模式如下所示:
<img src="https://static001.geekbang.org/resource/image/01/bf/015d2a792327826570858a187113c2bf.png" alt="" />
以代理LB模式为例RPC客户端向负载均衡代理发送请求负载均衡代理按照指定的路由策略将请求消息转发到后端可用的服务实例上。负载均衡代理负责维护后端可用的服务列表如果发现某个服务不可用则将其剔除出路由表。
代理LB模式的优点是客户端不需要实现负载均衡策略算法也不需要维护后端的服务列表信息不直接跟后端的服务进行通信在做网络安全边界隔离时非常实用。例如通过Nginx做L7层负载均衡将互联网前端的流量安全的接入到后端服务中。
代理LB模式通常支持L4Transport和L7Application)层负载均衡两者各有优缺点可以根据RPC的协议特点灵活选择。L4/L7层负载均衡对应场景如下
- **L4层**对时延要求苛刻、资源损耗少、RPC本身采用私有TCP协议
- **L7层**有会话状态的连接、HTTP协议簇例如Restful
客户端负载均衡策略由客户端内置负载均衡能力通过静态配置、域名解析服务例如DNS服务、订阅发布例如Zookeeper服务注册中心等方式获取RPC服务端地址列表并将地址列表缓存到客户端内存中。
每次RPC调用时根据客户端配置的负载均衡策略由负载均衡算法从缓存的服务地址列表中选择一个服务实例发起RPC调用。
客户端负载均衡策略工作原理示例如下:
<img src="https://static001.geekbang.org/resource/image/d5/de/d58b4fba8211d76c17e484d2ec7506de.png" alt="" />
gRPC默认采用客户端负载均衡策略同时提供了扩展机制使用者通过自定义实现NameResolver和LoadBalancer即可覆盖gRPC默认的负载均衡策略实现自定义路由策略的扩展。
gRPC提供的负载均衡策略实现类如下所示
- **PickFirstBalancer**无负载均衡能力,即使有多个服务端地址可用,也只选择第一个地址;
- **RoundRobinLoadBalancer**&quot;RoundRobin&quot;负载均衡策略。
gRPC负载均衡流程如下所示
<img src="https://static001.geekbang.org/resource/image/70/b5/70f37499bc53a001b0cc1d179501f8b5.png" alt="" />
流程关键技术点解读:
<li>
负载均衡功能模块的输入是客户端指定的hostName、需要调用的接口名和方法名等参数输出是执行负载均衡算法后获得的NettyClientTransport通过NettyClientTransport可以创建基于Netty HTTP/2的gRPC客户端发起RPC调用
</li>
<li>
gRPC系统默认提供的是DnsNameResolver它通过InetAddress.getAllByName(host)获取指定host的IP地址列表本地DNS服务对于扩展者而言可以继承NameResolver实现自定义的地址解析服务例如使用Zookeeper替换DnsNameResolver把Zookeeper作为动态的服务地址配置中心它的伪代码示例如下
**第一步:**继承NameResolver实现start(Listener listener)方法:
</li>
```
void start(Listener listener)
{
//获取ZooKeeper地址并连接
//创建Watcher并实现process(WatchedEvent event),监听地址变更
//根据接口名和方法名调用getChildren方法获取发布该服务的地址列表
//将地址列表加到List中
// 调用NameResolver.Listener.onAddresses(),通知地址解析完成
```
**第二步:**创建ManagedChannelBuilder时指定Target的地址为Zookeeper服务端地址同时设置nameResolver为Zookeeper NameResolver,示例代码如下所示:
```
this(ManagedChannelBuilder.forTarget(zookeeperAddr)
.loadBalancerFactory(RoundRobinLoadBalancerFactory.getInstance())
.nameResolverFactory(new ZookeeperNameResolverProvider())
.usePlaintext(false));
```
1. LoadBalancer负责从nameResolver中解析获得的服务端URL中按照指定路由策略选择一个目标服务端地址并创建ClientTransport。同样可以通过覆盖handleResolvedAddressGroups实现自定义负载均衡策略。
通过LoadBalancer + NameResolver可以实现灵活的负载均衡策略扩展。例如基于Zookeeper、etcd的分布式配置服务中心方案。
### 1.3.7 RPC请求消息发送流程
gRPC默认基于Netty HTTP/2 + PB进行RPC调用请求消息发送流程如下所示
<img src="https://static001.geekbang.org/resource/image/83/33/83adc405df0a39746ffb657a63c18433.png" alt="" />
流程关键技术点解读:
1. ClientCallImpl的sendMessage调用主要完成了请求对象的序列化基于PB、HTTP/2 Frame的初始化
1. ClientCallImpl的halfClose调用将客户端准备就绪的请求Frame封装成自定义的SendGrpcFrameCommand写入到WriteQueue中
1. WriteQueue执行flush()将SendGrpcFrameCommand写入到Netty的Channel中调用Channel的write方法被NettyClientHandler拦截到由NettyClientHandler负责具体的发送操作
1. NettyClientHandler调用Http2ConnectionEncoder的writeData方法将Frame写入到HTTP/2 Stream中完成请求消息的发送。
### 1.3.8 RPC响应接收和处理流程
gRPC客户端响应消息的接收入口是NettyClientHandler它的处理流程如下所示
<img src="https://static001.geekbang.org/resource/image/63/3d/638dea0d3e70fda00dd8fecc798e9d3d.png" alt="" />
流程关键技术点解读:
1. NettyClientHandler的onHeadersRead(int streamId, Http2Headers headers, boolean endStream)方法会被调用两次根据endStream判断是否是Stream结尾
1. 请求和响应的关联根据streamId可以关联同一个HTTP/2 Stream将NettyClientStream缓存到Stream中客户端就可以在接收到响应消息头或消息体时还原出NettyClientStream进行后续处理
1. RPC客户端调用线程的阻塞和唤醒使用到了GrpcFuture的wait和notify机制来实现客户端调用线程的同步阻塞和唤醒
1. 客户端和服务端的HTTP/2 Header和Data Frame解析共用同一个方法即MessageDeframer的deliver()。
# 客户端源码分析
gRPC客户端调用原理并不复杂但是代码却相对比较繁杂。下面围绕关键的类库对主要功能点进行源码分析。
## NettyClientTransport功能和源码分析
NettyClientTransport的主要功能如下
- 通过start(Listener transportListener) 创建HTTP/2 Client并连接gRPC服务端
- 通过newStream(MethodDescriptor<?, ?> method, Metadata headers, CallOptions callOptions) 创建ClientStream
- 通过shutdown() 关闭底层的HTTP/2连接。
以启动HTTP/2客户端为例进行讲解NettyClientTransport类
```
EventLoop eventLoop = group.next();
if (keepAliveTimeNanos != KEEPALIVE_TIME_NANOS_DISABLED) {
keepAliveManager = new KeepAliveManager(
new ClientKeepAlivePinger(this), eventLoop, keepAliveTimeNanos, keepAliveTimeoutNanos,
keepAliveWithoutCalls);
}
handler = NettyClientHandler.newHandler(lifecycleManager, keepAliveManager, flowControlWindow,
maxHeaderListSize, Ticker.systemTicker(), tooManyPingsRunnable);
HandlerSettings.setAutoWindow(handler);
negotiationHandler = negotiator.newHandler(handler);
```
根据启动时配置的HTTP/2协商策略以NettyClientHandler为参数创建ProtocolNegotiator.Handler。
创建Bootstrap并设置EventLoopGroup需要指出的是此处并没有使用EventLoopGroup而是它的一种实现类EventLoop原因在前文中已经说明相关代码示例如下NettyClientTransport类
```
Bootstrap b = new Bootstrap();
b.group(eventLoop);
b.channel(channelType);
if (NioSocketChannel.class.isAssignableFrom(channelType)) {
b.option(SO_KEEPALIVE, true);
}
```
创建WriteQueue并设置到NettyClientHandler中用于接收内部的各种QueuedCommand初始化完成之后发起HTTP/2连接代码如下NettyClientTransport类
```
handler.startWriteQueue(channel);
channel.connect(address).addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) throws Exception {
if (!future.isSuccess()) {
ChannelHandlerContext ctx = future.channel().pipeline().context(handler);
if (ctx != null) {
ctx.fireExceptionCaught(future.cause());
}
future.channel().pipeline().fireExceptionCaught(future.cause());
}
```
## 2.2 NettyClientHandler功能和源码分析
NettyClientHandler继承自Netty的Http2ConnectionHandler是gRPC接收和发送HTTP/2消息的关键实现类也是gRPC和Netty的交互桥梁它的主要功能如下所示
- 发送各种协议消息给gRPC服务端
- 接收gRPC服务端返回的应答消息头、消息体和其它协议消息
- 处理HTTP/2协议相关的指令例如StreamError、ConnectionError等。
协议消息的发送无论是业务请求消息还是协议指令消息都统一封装成QueuedCommand由NettyClientHandler拦截并处理相关代码如下所示NettyClientHandler类
```
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise)
throws Exception {
if (msg instanceof CreateStreamCommand) {
createStream((CreateStreamCommand) msg, promise);
} else if (msg instanceof SendGrpcFrameCommand) {
sendGrpcFrame(ctx, (SendGrpcFrameCommand) msg, promise);
} else if (msg instanceof CancelClientStreamCommand) {
cancelStream(ctx, (CancelClientStreamCommand) msg, promise);
} else if (msg instanceof SendPingCommand) {
sendPingFrame(ctx, (SendPingCommand) msg, promise);
} else if (msg instanceof GracefulCloseCommand) {
gracefulClose(ctx, (GracefulCloseCommand) msg, promise);
} else if (msg instanceof ForcefulCloseCommand) {
forcefulClose(ctx, (ForcefulCloseCommand) msg, promise);
} else if (msg == NOOP_MESSAGE) {
ctx.write(Unpooled.EMPTY_BUFFER, promise);
} else {
throw new AssertionError(&quot;Write called for unexpected type: &quot; + msg.getClass().getName());
}
```
协议消息的接收NettyClientHandler通过向Http2ConnectionDecoder注册FrameListener来监听RPC响应消息和协议指令消息相关接口如下
<img src="https://static001.geekbang.org/resource/image/c6/ab/c6bdb9b58f87645f1d03df8fd84015ab.png" alt="" />
FrameListener回调NettyClientHandler的相关方法实现协议消息的接收和处理
<img src="https://static001.geekbang.org/resource/image/01/0f/01b7725010f84428ed7967c7e132d00f.png" alt="" />
需要指出的是NettyClientHandler 并没有实现所有的回调接口对于需要特殊处理的几个方法进行了重载例如onDataRead和onHeadersRead。
## 2.3 ProtocolNegotiator功能和源码分析
ProtocolNegotiator用于HTTP/2连接创建的协商gRPC支持三种策略并有三个实现子类
<img src="https://static001.geekbang.org/resource/image/2c/b8/2c92cb05f7215930a84f4db5143b18b8.png" alt="" />
gRPC的ProtocolNegotiator实现类完全遵循HTTP/2相关规范以PlaintextUpgradeNegotiator为例通过设置Http2ClientUpgradeCodec用于101协商和协议升级相关代码如下所示PlaintextUpgradeNegotiator类
```
public Handler newHandler(GrpcHttp2ConnectionHandler handler) {
Http2ClientUpgradeCodec upgradeCodec = new Http2ClientUpgradeCodec(handler);
HttpClientCodec httpClientCodec = new HttpClientCodec();
final HttpClientUpgradeHandler upgrader =
new HttpClientUpgradeHandler(httpClientCodec, upgradeCodec, 1000);
return new BufferingHttp2UpgradeHandler(upgrader);
}
```
## 2.4 LoadBalancer功能和源码分析
LoadBalancer负责客户端负载均衡它是个抽象类gRPC框架的使用者可以通过继承的方式进行扩展。
gRPC当前已经支持PickFirstBalancer和RoundRobinLoadBalancer两种负载均衡策略未来不排除会提供更多的策略。
以RoundRobinLoadBalancer为例它的工作原理如下根据PickSubchannelArgs来选择一个SubchannelRoundRobinLoadBalancerFactory类
```
public PickResult pickSubchannel(PickSubchannelArgs args) {
if (size &gt; 0) {
return PickResult.withSubchannel(nextSubchannel());
}
if (status != null) {
return PickResult.withError(status);
}
return PickResult.withNoResult();
}
```
再看下Subchannel的选择算法Picker类
```
private Subchannel nextSubchannel() {
if (size == 0) {
throw new NoSuchElementException();
}
synchronized (this) {
Subchannel val = list.get(index);
index++;
if (index &gt;= size) {
index = 0;
}
return val;
}
}
```
即通过顺序的方式从服务端列表中获取一个Subchannel。<br />
如果用户需要定制负载均衡策略则可以在RPC调用时使用如下代码HelloWorldClient类
```
this(ManagedChannelBuilder.forAddress(host, port).loadBalancerFactory(RoundRobinLoadBalancerFactory.getInstance()).nameResolverFactory(new ZkNameResolverProvider()) .usePlaintext(true));
```
## 2.5 ClientCalls功能和源码分析
ClientCalls提供了各种RPC调用方式包括同步、异步、Streaming和Unary方式等相关方法如下所示
<img src="https://static001.geekbang.org/resource/image/e9/a1/e90718ef05e8b55edebb5dea5211eea1.png" alt="" />
下面一起看下RPC请求消息的发送和应答接收相关代码。
### 2.5.1 RPC请求调用源码分析
请求调用主要有两步请求Frame构造和Frame发送请求Frame构造代码如下所示ClientCallImpl类
```
public void sendMessage(ReqT message) {
Preconditions.checkState(stream != null, &quot;Not started&quot;);
Preconditions.checkState(!cancelCalled, &quot;call was cancelled&quot;);
Preconditions.checkState(!halfCloseCalled, &quot;call was half-closed&quot;);
try {
InputStream messageIs = method.streamRequest(message);
stream.writeMessage(messageIs);
...
```
使用PB对请求消息做序列化生成InputStream构造请求Frame:
```
private int writeUncompressed(InputStream message, int messageLength) throws IOException {
if (messageLength != -1) {
statsTraceCtx.outboundWireSize(messageLength);
return writeKnownLengthUncompressed(message, messageLength);
}
BufferChainOutputStream bufferChain = new BufferChainOutputStream();
int written = writeToOutputStream(message, bufferChain);
if (maxOutboundMessageSize &gt;= 0 &amp;&amp; written &gt; maxOutboundMessageSize) {
throw Status.INTERNAL
.withDescription(
String.format(&quot;message too large %d &gt; %d&quot;, written , maxOutboundMessageSize))
.asRuntimeException();
}
writeBufferChain(bufferChain, false);
return written;
}
```
Frame发送代码如下所示
```
public void writeFrame(WritableBuffer frame, boolean endOfStream, boolean flush) {
ByteBuf bytebuf = frame == null ? EMPTY_BUFFER : ((NettyWritableBuffer) frame).bytebuf();
final int numBytes = bytebuf.readableBytes();
if (numBytes &gt; 0) {
onSendingBytes(numBytes);
writeQueue.enqueue(
new SendGrpcFrameCommand(transportState(), bytebuf, endOfStream),
channel.newPromise().addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) throws Exception {
if (future.isSuccess()) {
transportState().onSentBytes(numBytes);
}
}
}), flush);
```
NettyClientHandler接收到发送事件之后调用Http2ConnectionEncoder将Frame写入Netty HTTP/2协议栈NettyClientHandler类
```
private void sendGrpcFrame(ChannelHandlerContext ctx, SendGrpcFrameCommand cmd,
ChannelPromise promise) {
encoder().writeData(ctx, cmd.streamId(), cmd.content(), 0, cmd.endStream(), promise);
}
```
### 2.5.2 RPC响应接收和处理源码分析
响应消息的接收入口是NettyClientHandler包括HTTP/2 Header和HTTP/2 DATA Frame两部分代码如下NettyClientHandler类
```
private void onHeadersRead(int streamId, Http2Headers headers, boolean endStream) {
NettyClientStream.TransportState stream = clientStream(requireHttp2Stream(streamId));
stream.transportHeadersReceived(headers, endStream);
if (keepAliveManager != null) {
keepAliveManager.onDataReceived();
}
}
```
如果参数endStream为True说明Stream已经结束调用transportTrailersReceived通知Listener close代码如下所示AbstractClientStream2类
```
if (stopDelivery || isDeframerStalled()) {
deliveryStalledTask = null;
closeListener(status, trailers);
} else {
deliveryStalledTask = new Runnable() {
@Override
public void run() {
closeListener(status, trailers);
}
};
}
```
读取到HTTP/2 DATA Frame之后调用MessageDeframer的deliver对Frame进行解析代码如下MessageDeframer类
```
private void deliver() {
if (inDelivery) {
return;
}
inDelivery = true;
try {
while (pendingDeliveries &gt; 0 &amp;&amp; readRequiredBytes()) {
switch (state) {
case HEADER:
processHeader();
break;
case BODY:
processBody();
...
```
将Frame 转换成InputStream之后通知ClientStreamListenerImpl调用messageRead(final InputStream message)将InputStream反序列化为响应对象相关代码如下所示ClientStreamListenerImpl类
```
public void messageRead(final InputStream message) {
class MessageRead extends ContextRunnable {
MessageRead() {
super(context);
}
@Override
public final void runInContext() {
try {
if (closed) {
return;
}
try {
observer.onMessage(method.parseResponse(message));
} finally {
message.close();
}
```
当接收到endOfStream之后通知ClientStreamListenerImpl调用它的close方法如下所示ClientStreamListenerImpl类
```
private void close(Status status, Metadata trailers) {
closed = true;
cancelListenersShouldBeRemoved = true;
try {
closeObserver(observer, status, trailers);
} finally {
removeContextListenerAndCancelDeadlineFuture();
}
}
```
最终调用UnaryStreamToFuture的onClose方法set响应对象唤醒阻塞的调用方线程完成RPC调用代码如下UnaryStreamToFuture类
```
public void onClose(Status status, Metadata trailers) {
if (status.isOk()) {
if (value == null) {
responseFuture.setException(
Status.INTERNAL.withDescription(&quot;No value received for unary call&quot;)
.asRuntimeException(trailers));
}
responseFuture.set(value);
} else {
responseFuture.setException(status.asRuntimeException(trailers));
}
```
源代码下载地址:
链接: [https://github.com/geektime-geekbang/gRPC_LLF/tree/master](https://github.com/geektime-geekbang/gRPC_LLF/tree/master)

View File

@@ -0,0 +1,558 @@
# RPC线程模型
## 1.1 BIO线程模型
在JDK 1.4推出Java NIO之前基于Java的所有Socket通信都采用了同步阻塞模式BIO这种一请求一应答的通信模型简化了上层的应用开发但是在性能和可靠性方面却存在着巨大的瓶颈。
因此在很长一段时间里大型的应用服务器都采用C或者C++语言开发因为它们可以直接使用操作系统提供的异步I/O或者AIO能力。
当并发访问量增大、响应时间延迟增大之后采用Java BIO开发的服务端软件只有通过硬件的不断扩容来满足高并发和低时延。
它极大地增加了企业的成本,并且随着集群规模的不断膨胀,系统的可维护性也面临巨大的挑战,只能通过采购性能更高的硬件服务器来解决问题,这会导致恶性循环。
传统采用BIO的Java Web服务器如下所示典型的如Tomcat的BIO模式
<img src="https://static001.geekbang.org/resource/image/62/42/62e08cf3d345b1ccb91429f28d454542.png" alt="" />
采用该线程模型的服务器调度特点如下:
- 服务端监听线程Acceptor负责客户端连接的接入每当有新的客户端接入就会创建一个新的I/O线程负责处理Socket
- 客户端请求消息的读取和应答的发送都有I/O线程负责
- 除了I/O读写操作默认情况下业务的逻辑处理例如DB操作等也都在I/O线程处理
- I/O操作采用同步阻塞操作读写没有完成I/O线程会同步阻塞。
BIO线程模型主要存在如下三个问题
1. **性能问题:**一连接一线程模型导致服务端的并发接入数和系统吞吐量受到极大限制;
1. **可靠性问题:**由于I/O操作采用同步阻塞模式当网络拥塞或者通信对端处理缓慢会导致I/O线程被挂住阻塞时间无法预测
1. **可维护性问题:**I/O线程数无法有效控制、资源无法有效共享多线程并发问题系统可维护性差。
为了解决同步阻塞I/O面临的一个链路需要一个线程处理的问题通常会对它的线程模型进行优化后端通过一个线程池来处理多个客户端的请求接入形成客户端个数&quot;M&quot;与线程池最大线程数&quot;N&quot;的比例关系其中M可以远远大于N通过线程池可以灵活的调配线程资源设置线程的最大值防止由于海量并发接入导致线程耗尽它的工作原理如下所示
<img src="https://static001.geekbang.org/resource/image/6d/ca/6da1ab2c9d34a3660374a8a997ac03ca.png" alt="" />
优化之后的BIO模型采用了线程池实现因此避免了为每个请求都创建一个独立线程造成的线程资源耗尽问题。但是由于它底层的通信依然采用同步阻塞模型阻塞的时间取决于对方I/O线程的处理速度和网络I/O的传输速度。
本质上来讲无法保证生产环境的网络状况和对端的应用程序能足够快如果应用程序依赖对方的处理速度它的可靠性就非常差优化之后的BIO线程模型仍然无法从根本上解决性能线性扩展问题。
## 1.2 异步非阻塞线程模型
从JDK1.0到JDK1.3Java的I/O类库都非常原始很多UNIX网络编程中的概念或者接口在I/O类库中都没有体现例如Pipe、Channel、Buffer和Selector等。2002年发布JDK1.4时NIO以JSR-51的身份正式随JDK发布。它新增了个java.nio包提供了很多进行异步I/O开发的API和类库主要的类和接口如下
- 进行异步I/O操作的缓冲区ByteBuffer等
- 进行异步I/O操作的管道Pipe
- 进行各种I/O操作异步或者同步的Channel包括ServerSocketChannel和SocketChannel
- 多种字符集的编码能力和解码能力;
- 实现非阻塞I/O操作的多路复用器selector
- 基于流行的Perl实现的正则表达式类库
- 文件通道FileChannel。
新的NIO类库的提供极大地促进了基于Java的异步非阻塞编程的发展和应用,也诞生了很多优秀的Java NIO框架例如Apache的Mina、以及当前非常流行的Netty。
Java NIO类库的工作原理如下所示
<img src="https://static001.geekbang.org/resource/image/53/38/53437505107a710a603c6a8593f00638.png" alt="" />
在Java NIO类库中最重要的就是多路复用器Selector它是Java NIO编程的基础熟练地掌握Selector对于掌握NIO编程至关重要。多路复用器提供选择已经就绪的任务的能力。
简单来讲Selector会不断地轮询注册在其上的Channel如果某个Channel上面有新的TCP连接接入、读和写事件这个Channel就处于就绪状态会被Selector轮询出来然后通过SelectionKey可以获取就绪Channel的集合进行后续的I/O操作。
通常一个I/O线程会聚合一个Selector一个Selector可以同时注册N个Channel,这样单个I/O线程就可以同时并发处理多个客户端连接。另外由于I/O操作是非阻塞的因此也不会受限于网络速度和对方端点的处理时延可靠性和效率都得到了很大提升。
典型的NIO线程模型Reactor模式如下所示
<img src="https://static001.geekbang.org/resource/image/b5/5f/b5233ba74ab40a0ad9f4c7822164795f.png" alt="" />
## 1.3 RPC性能三原则
影响RPC框架性能的三个核心要素如下
1. **I/O模型**用什么样的通道将数据发送给对方BIO、NIO或者AIOIO模型在很大程度上决定了框架的性能
1. **协议:**采用什么样的通信协议Rest+ JSON或者基于TCP的私有二进制协议协议的选择不同性能模型也不同相比于公有协议内部私有二进制协议的性能通常可以被设计的更优
1. **线程:**数据报如何读取?读取之后的编解码在哪个线程进行,编解码后的消息如何派发,通信线程模型的不同,对性能的影响也非常大。
在以上三个要素中线程模型对性能的影响非常大。随着硬件性能的提升CPU的核数越来越越多很多服务器标配已经达到32或64核。
通过多线程并发编程可以充分利用多核CPU的处理能力提升系统的处理效率和并发性能。但是如果线程创建或者管理不当频繁发生线程上下文切换或者锁竞争反而会影响系统的性能。
线程模型的优劣直接影响了RPC框架的性能和并发能力它也是大家选型时比较关心的技术细节之一。下面我们一起来分析和学习下gRPC的线程模型。
# 2. gRPC线程模型分析
gRPC的线程模型主要包括服务端线程模型和客户端线程模型其中服务端线程模型主要包括
- 服务端监听和客户端接入线程HTTP /2 Acceptor
- 网络I/O读写线程
- 服务接口调用线程
客户端线程模型主要包括:
- 客户端连接线程HTTP/2 Connector
- 网络I/O读写线程
- 接口调用线程
- 响应回调通知线程
## 2.1 服务端线程模型
gRPC服务端线程模型整体上可以分为两大类
- 网络通信相关的线程模型基于Netty4.1的线程模型实现
- 服务接口调用线程模型基于JDK线程池实现
### 2.1.1 服务端线程模型概述
gRPC服务端线程模型和交互图如下所示
<img src="https://static001.geekbang.org/resource/image/7b/b3/7b75fb1c58e0bee27cddc8b3f3e843b3.png" alt="" />
其中HTTP/2服务端创建、HTTP/2请求消息的接入和响应发送都由Netty负责gRPC消息的序列化和反序列化、以及应用服务接口的调用由gRPC的SerializingExecutor线程池负责。
### 2.1.2 I/O通信线程模型
gRPC的做法是服务端监听线程和I/O线程分离的Reactor多线程模型它的代码如下所示NettyServer类
```
public void start(ServerListener serverListener) throws IOException {
listener = checkNotNull(serverListener, &quot;serverListener&quot;);
allocateSharedGroups();
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup);
b.channel(channelType);
if (NioServerSocketChannel.class.isAssignableFrom(channelType)) {
b.option(SO_BACKLOG, 128);
b.childOption(SO_KEEPALIVE, true);
```
它的工作原理如下:
<img src="https://static001.geekbang.org/resource/image/4a/1e/4a59ff78a4b550df92138c593e71771e.png" alt="" />
流程如下:
**步骤1**业务线程发起创建服务端操作在创建服务端的时候实例化了2个EventLoopGroup1个EventLoopGroup实际就是一个EventLoop线程组负责管理EventLoop的申请和释放。
EventLoopGroup管理的线程数可以通过构造函数设置如果没有设置默认取-Dio.netty.eventLoopThreads如果该系统参数也没有指定则为“可用的CPU内核 * 2”。
bossGroup线程组实际就是Acceptor线程池负责处理客户端的TCP连接请求如果系统只有一个服务端端口需要监听则建议bossGroup线程组线程数设置为1。workerGroup是真正负责I/O读写操作的线程组通过ServerBootstrap的group方法进行设置用于后续的Channel绑定。
**步骤2**服务端Selector轮询监听客户端连接代码示例如下NioEventLoop类
```
int selectedKeys = selector.select(timeoutMillis);
selectCnt ++;
```
**步骤3**如果监听到客户端连接则创建客户端SocketChannel连接从workerGroup中随机选择一个NioEventLoop线程将SocketChannel注册到该线程持有的Selector代码示例如下NioServerSocketChannel类
```
protected int doReadMessages(List&lt;Object&gt; buf) throws Exception {
SocketChannel ch = SocketUtils.accept(javaChannel());
try {
if (ch != null) {
buf.add(new NioSocketChannel(this, ch));
return 1;
}
```
**步骤4**通过调用EventLoopGroup的next()获取一个EventLoopNioEventLoop用于处理网络I/O事件。
Netty线程模型的核心是NioEventLoop它的职责如下
1. 作为服务端Acceptor线程负责处理客户端的请求接入
1. 作为I/O线程监听网络读操作位负责从SocketChannel中读取报文
1. 作为I/O线程负责向SocketChannel写入报文发送给对方如果发生写半包会自动注册监听写事件用于后续继续发送半包数据直到数据全部发送完成
1. 作为定时任务线程,可以执行定时任务,例如链路空闲检测和发送心跳消息等
1. 作为线程执行器可以执行普通的任务线程RunnableNioEventLoop处理网络I/O操作的相关代码如下
```
try {
int readyOps = k.readyOps();
if ((readyOps &amp; SelectionKey.OP_CONNECT) != 0) {
int ops = k.interestOps();
ops &amp;= ~SelectionKey.OP_CONNECT;
k.interestOps(ops);
unsafe.finishConnect();
}
if ((readyOps &amp; SelectionKey.OP_WRITE) != 0) {
ch.unsafe().forceFlush();
}
if ((readyOps &amp; (SelectionKey.OP_READ | SelectionKey.OP_ACCEPT)) != 0 || readyOps == 0) {
unsafe.read();
}
```
除了处理I/O操作NioEventLoop也可以执行Runnable和定时任务。NioEventLoop继承SingleThreadEventExecutor这就意味着它实际上是一个线程个数为1的线程池类继承关系如下所示
<img src="https://static001.geekbang.org/resource/image/f3/14/f3f92ac29c50a6e31743919450057914.png" alt="" />
SingleThreadEventExecutor聚合了JDK的java.util.concurrent.Executor和消息队列Queue自定义提供线程池功能相关代码如下SingleThreadEventExecutor类
```
private final Queue&lt;Runnable&gt; taskQueue;
private volatile Thread thread;
@SuppressWarnings(&quot;unused&quot;)
private volatile ThreadProperties threadProperties;
private final Executor executor;
private volatile boolean interrupted;
```
直接调用NioEventLoop的execute(Runnable task)方法即可执行自定义的Task代码示例如下SingleThreadEventExecutor类
```
public void execute(Runnable task) {
if (task == null) {
throw new NullPointerException(&quot;task&quot;);
}
boolean inEventLoop = inEventLoop();
if (inEventLoop) {
addTask(task);
} else {
startThread();
addTask(task);
if (isShutdown() &amp;&amp; removeTask(task)) {
reject();
}
```
除了SingleThreadEventExecutorNioEventLoop同时实现了ScheduledExecutorService接口这意味着它也可以执行定时任务相关接口定义如下
<img src="https://static001.geekbang.org/resource/image/07/17/0725a5e639ba33e0bd153fac3ed34a17.png" alt="" />
为了防止大量Runnable和定时任务执行影响网络I/O的处理效率Netty提供了一个配置项ioRatio用于设置I/O操作和其它任务执行的时间比例默认为50%相关代码示例如下NioEventLoop类
```
final long ioTime = System.nanoTime() - ioStartTime;
runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
```
NioEventLoop同时支持I/O操作和Runnable执行的原因如下避免锁竞争例如心跳检测往往需要周期性的执行如果NioEventLoop不支持定时任务执行则用户需要自己创建一个类似ScheduledExecutorService的定时任务线程池或者定时任务线程周期性的发送心跳发送心跳需要网络操作就要跟I/O线程所持有的资源进行交互例如Handler、ByteBuf、NioSocketChannel等这样就会产生锁竞争需要考虑并发安全问题。原理如下
<img src="https://static001.geekbang.org/resource/image/eb/13/eb0b9ec4947a7a499f3a8f783c755413.png" alt="" />
### 2.1.3. 服务调度线程模型
gRPC服务调度线程主要职责如下
- 请求消息的反序列化主要包括HTTP/2 Header的反序列化以及将PB(Body)反序列化为请求对象;
- 服务接口的调用method.invoke(非反射机制)
<li>将响应消息封装成WriteQueue.QueuedCommand写入到Netty Channel中同时对响应Header和Body对象做序列化<br />
服务端调度的核心是SerializingExecutor它同时实现了JDK的Executor和Runnable接口既是一个线程池同时也是一个Task。</li>
SerializingExecutor聚合了JDK的Executor由Executor负责Runnable的执行代码示例如下SerializingExecutor类
```
public final class SerializingExecutor implements Executor, Runnable {
private static final Logger log =
Logger.getLogger(SerializingExecutor.class.getName());
private final Executor executor;
private final Queue&lt;Runnable&gt; runQueue = new ConcurrentLinkedQueue&lt;Runnable&gt;();
```
其中Executor默认使用的是JDK的CachedThreadPool在构建ServerImpl的时候进行初始化代码如下
<img src="https://static001.geekbang.org/resource/image/ab/62/ab64e112210d75e7b9201a06f9dec662.png" alt="" />
当服务端接收到客户端HTTP/2请求消息时由Netty的NioEventLoop线程切换到gRPC的SerializingExecutor进行消息的反序列化、以及服务接口的调用代码示例如下ServerTransportListenerImpl类
```
final Context.CancellableContext context = createContext(stream, headers, statsTraceCtx);
final Executor wrappedExecutor;
if (executor == directExecutor()) {
wrappedExecutor = new SerializeReentrantCallsDirectExecutor();
} else {
wrappedExecutor = new SerializingExecutor(executor);
}
final JumpToApplicationThreadServerStreamListener jumpListener
= new JumpToApplicationThreadServerStreamListener(wrappedExecutor, stream, context);
stream.setListener(jumpListener);
wrappedExecutor.execute(new ContextRunnable(context) {
@Override
public void runInContext() {
ServerStreamListener listener = NOOP_LISTENER;
try {
ServerMethodDefinition&lt;?, ?&gt; method = registry.lookupMethod(methodName);
...
```
相关的调用堆栈,示例如下:
<img src="https://static001.geekbang.org/resource/image/3d/d3/3d3f45e12b87885967901f823e1eddd3.png" alt="" />
响应消息的发送由SerializingExecutor发起将响应消息头和消息体序列化然后分别封装成SendResponseHeadersCommand和SendGrpcFrameCommand调用Netty NioSocketChannle的write方法发送到Netty的ChannelPipeline中由gRPC的NettyServerHandler拦截之后真正写入到SocketChannel中代码如下所示NettyServerHandler类
```
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise)
throws Exception {
if (msg instanceof SendGrpcFrameCommand) {
sendGrpcFrame(ctx, (SendGrpcFrameCommand) msg, promise);
} else if (msg instanceof SendResponseHeadersCommand) {
sendResponseHeaders(ctx, (SendResponseHeadersCommand) msg, promise);
} else if (msg instanceof CancelServerStreamCommand) {
cancelStream(ctx, (CancelServerStreamCommand) msg, promise);
} else if (msg instanceof ForcefulCloseCommand) {
forcefulClose(ctx, (ForcefulCloseCommand) msg, promise);
} else {
AssertionError e =
new AssertionError(&quot;Write called for unexpected type: &quot; + msg.getClass().getName());
ReferenceCountUtil.release(msg);
```
响应消息体的发送堆栈如下所示:
<img src="https://static001.geekbang.org/resource/image/33/2e/336739fac897e18e932aa98ff874982e.png" alt="" />
Netty I/O线程和服务调度线程的运行分工界面以及切换点如下所示
<img src="https://static001.geekbang.org/resource/image/b4/e9/b4ba273d77641bc440466ad7d37d70e9.png" alt="" />
事实上在实际服务接口调用过程中NIO线程和服务调用线程切换次数远远超过4次频繁的线程切换对gRPC的性能带来了一定的损耗。
## 2.2 客户端线程模型
gRPC客户端的线程主要分为三类
1. 业务调用线程
1. 客户端连接和I/O读写线程
1. 请求消息业务处理和响应回调线程
### 2.2.1 客户端线程模型概述
gRPC客户端线程模型工作原理如下图所示同步阻塞调用为例<br />
<img src="https://static001.geekbang.org/resource/image/77/90/77ffa44324ea0e318caa3693021ae490.png" alt="" />
客户端调用主要涉及的线程包括:
- 应用线程负责调用gRPC服务端并获取响应其中请求消息的序列化由该线程负责
- 客户端负载均衡以及Netty Client创建由grpc-default-executor线程池负责
- HTTP/2客户端链路创建、网络I/O数据的读写由Netty NioEventLoop线程负责
- 响应消息的反序列化由SerializingExecutor负责与服务端不同的是客户端使用的是ThreadlessExecutor并非JDK线程池
- SerializingExecutor通过调用responseFuture的set(value)唤醒阻塞的应用线程完成一次RPC调用。
### 2.2.2 I/O通信线程模型
相比于服务端,客户端的线程模型简单一些,它的工作原理如下:<br />
<img src="https://static001.geekbang.org/resource/image/f0/fa/f0b5c823ca2ee2a7ccc285ca8d081ffa.png" alt="" />
第1步由grpc-default-executor发起客户端连接示例代码如下NettyClientTransport类
```
Bootstrap b = new Bootstrap();
b.group(eventLoop);
b.channel(channelType);
if (NioSocketChannel.class.isAssignableFrom(channelType)) {
b.option(SO_KEEPALIVE, true);
}
for (Map.Entry&lt;ChannelOption&lt;?&gt;, ?&gt; entry : channelOptions.entrySet()) {
b.option((ChannelOption&lt;Object&gt;) entry.getKey(), entry.getValue());
}
```
相比于服务端客户端只需要创建一个NioEventLoop因为它不需要独立的线程去监听客户端连接也没必要通过一个单独的客户端线程去连接服务端。
Netty是异步事件驱动的NIO框架它的连接和所有I/O操作都是非阻塞的因此不需要创建单独的连接线程。
另外客户端使用的work线程组并非通常意义的EventLoopGroup而是一个EventLoop即HTTP/2客户端使用的work线程并非一组线程默认线程数为CPU内核 * 2而是一个EventLoop线程。
这个其实也很容易理解一个NioEventLoop线程可以同时处理多个HTTP/2客户端连接它是多路复用的对于单个HTTP/2客户端如果默认独占一个work线程组将造成极大的资源浪费同时也可能会导致句柄溢出并发启动大量HTTP/2客户端
第2步发起连接操作判断连接结果判断连接结果如果没有连接成功则监听连接网络操作位SelectionKey.OP_CONNECT。如果连接成功则调用pipeline().fireChannelActive()将监听位修改为READ。代码如下NioSocketChannel类
```
protected boolean doConnect(SocketAddress remoteAddress, SocketAddress localAddress) throws Exception {
if (localAddress != null) {
doBind0(localAddress);
}
boolean success = false;
try {
boolean connected = SocketUtils.connect(javaChannel(), remoteAddress);
if (!connected) {
selectionKey().interestOps(SelectionKey.OP_CONNECT);
}
success = true;
return connected;
```
第3步由NioEventLoop的多路复用器轮询连接操作结果判断连接结果如果或连接成功重新设置监听位为READAbstractNioChannel类
```
protected void doBeginRead() throws Exception {
final SelectionKey selectionKey = this.selectionKey;
if (!selectionKey.isValid()) {
return;
}
readPending = true;
final int interestOps = selectionKey.interestOps();
if ((interestOps &amp; readInterestOp) == 0) {
selectionKey.interestOps(interestOps | readInterestOp);
}
```
第4步由NioEventLoop线程负责I/O读写同服务端。
### 2.2.3 客户端调用线程模型
客户端调用线程交互流程如下所示:
<img src="https://static001.geekbang.org/resource/image/2b/d1/2b4ae73b676745847f2799720fc90bd1.png" alt="" />
请求消息的发送由用户线程发起相关代码示例如下GreeterBlockingStub类
```
public io.grpc.examples.helloworld.HelloReply sayHello(io.grpc.examples.helloworld.HelloRequest request) {
return blockingUnaryCall(
getChannel(), METHOD_SAY_HELLO, getCallOptions(), request);
}
```
HTTP/2 Header的创建、以及请求参数反序列化为Protobuf均由用户线程负责完成相关代码示例如下ClientCallImpl类
```
public void sendMessage(ReqT message) {
Preconditions.checkState(stream != null, &quot;Not started&quot;);
Preconditions.checkState(!cancelCalled, &quot;call was cancelled&quot;);
Preconditions.checkState(!halfCloseCalled, &quot;call was half-closed&quot;);
try {
InputStream messageIs = method.streamRequest(message);
stream.writeMessage(messageIs);
...
```
用户线程将请求消息封装成CreateStreamCommand和SendGrpcFrameCommand发送到Netty的ChannelPipeline中然后返回完成线程切换。后续操作由Netty NIO线程负责相关代码示例如下NettyClientHandler类
```
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise)
throws Exception {
if (msg instanceof CreateStreamCommand) {
createStream((CreateStreamCommand) msg, promise);
} else if (msg instanceof SendGrpcFrameCommand) {
sendGrpcFrame(ctx, (SendGrpcFrameCommand) msg, promise);
} else if (msg instanceof CancelClientStreamCommand) {
cancelStream(ctx, (CancelClientStreamCommand) msg, promise);
} else if (msg instanceof SendPingCommand) {
sendPingFrame(ctx, (SendPingCommand) msg, promise);
} else if (msg instanceof GracefulCloseCommand) {
gracefulClose(ctx, (GracefulCloseCommand) msg, promise);
} else if (msg instanceof ForcefulCloseCommand) {
forcefulClose(ctx, (ForcefulCloseCommand) msg, promise);
} else if (msg == NOOP_MESSAGE) {
ctx.write(Unpooled.EMPTY_BUFFER, promise);
...
```
客户端响应消息的接收由gRPC的NettyClientHandler负责相关代码如下所示
<img src="https://static001.geekbang.org/resource/image/69/79/6941dbae4f7b8470f3c5fd653dc33179.png" alt="" />
接收到HTTP/2响应之后Netty将消息投递到SerializingExecutor由SerializingExecutor的ThreadlessExecutor负责响应的反序列化以及responseFuture的设值相关代码示例如下UnaryStreamToFuture类
```
public void onClose(Status status, Metadata trailers) {
if (status.isOk()) {
if (value == null) {
// No value received so mark the future as an error
responseFuture.setException(
Status.INTERNAL.withDescription(&quot;No value received for unary call&quot;)
.asRuntimeException(trailers));
}
responseFuture.set(value);
} else {
responseFuture.setException(status.asRuntimeException(trailers));
}
```
# 线程模型总结
## 3.1 优点
### 3.1.1 Netty线程模型
Netty4之后对线程模型进行了优化通过串行化的设计避免线程竞争当系统在运行过程中如果频繁的进行线程上下文切换会带来额外的性能损耗。
多线程并发执行某个业务流程,业务开发者还需要时刻对线程安全保持警惕,哪些数据可能会被并发修改,如何保护?这不仅降低了开发效率,也会带来额外的性能损耗。
为了解决上述问题Netty采用了串行化设计理念从消息的读取、编码以及后续Handler的执行始终都由I/O线程NioEventLoop负责这就意外着整个流程不会进行线程上下文的切换数据也不会面临被并发修改的风险对于用户而言甚至不需要了解Netty的线程细节这确实是个非常好的设计理念它的工作原理图如下
<img src="https://static001.geekbang.org/resource/image/71/4a/71399ff626d246d67a4fc21b2835fe4a.png" alt="" />
一个NioEventLoop聚合了一个多路复用器Selector因此可以处理成百上千的客户端连接Netty的处理策略是每当有一个新的客户端接入则从NioEventLoop线程组中顺序获取一个可用的NioEventLoop当到达数组上限之后重新返回到0通过这种方式可以基本保证各个NioEventLoop的负载均衡。一个客户端连接只注册到一个NioEventLoop上这样就避免了多个I/O线程去并发操作它。
Netty通过串行化设计理念降低了用户的开发难度提升了处理性能。利用线程组实现了多个串行化线程水平并行执行线程之间并没有交集这样既可以充分利用多核提升并行处理能力同时避免了线程上下文的切换和并发保护带来的额外性能损耗。
Netty 3的I/O事件处理流程如下
<img src="https://static001.geekbang.org/resource/image/52/3e/52a661e44e12274c41484e98f20c153e.png" alt="" />
Netty 4的I/O消息处理流程如下所示
<img src="https://static001.geekbang.org/resource/image/be/86/be77fe6bbcfaecd4ca96d16b05aa3786.png" alt="" />
Netty 4修改了Netty 3的线程模型在Netty 3的时候upstream是在I/O线程里执行的而downstream是在业务线程里执行。
当Netty从网络读取一个数据报投递给业务handler的时候handler是在I/O线程里执行而当我们在业务线程中调用write和writeAndFlush向网络发送消息的时候handler是在业务线程里执行直到最后一个Header handler将消息写入到发送队列中业务线程才返回。
Netty4修改了这一模型在Netty 4里inbound(对应Netty 3的upstream)和outbound(对应Netty 3的downstream)都是在NioEventLoop(I/O线程)中执行。
当我们在业务线程里通过ChannelHandlerContext.write发送消息的时候Netty 4在将消息发送事件调度到ChannelPipeline的时候首先将待发送的消息封装成一个Task然后放到NioEventLoop的任务队列中由NioEventLoop线程异步执行。
后续所有handler的调度和执行包括消息的发送、I/O事件的通知都由NioEventLoop线程负责处理。
### 3.1.2 gRPC线程模型
消息的序列化和反序列化均由gRPC线程负责而没有在Netty的Handler中做CodeC原因如下Netty4优化了线程模型所有业务Handler都由Netty的I/O线程负责通过串行化的方式消除锁竞争原理如下所示
<img src="https://static001.geekbang.org/resource/image/90/ab/9011eef3e09d565dd844358134a571ab.png" alt="" />
如果大量的Handler都在Netty I/O线程中执行一旦某些Handler执行比较耗时则可能会反向影响I/O操作的执行像序列化和反序列化操作都是CPU密集型操作更适合在业务应用线程池中执行提升并发处理能力。因此gRPC并没有在I/O线程中做消息的序列化和反序列化。
## 3.2 改进点思考
### 3.2.1 时间可控的接口调用直接在I/O线程上处理
gRPC采用的是网络I/O线程和业务调用线程分离的策略大部分场景下该策略是最优的。但是对于那些接口逻辑非常简单执行时间很短不需要与外部网元交互、访问数据库和磁盘也不需要等待其它资源的则建议接口调用直接在Netty /O线程中执行不需要再投递到后端的服务线程池。避免线程上下文切换同时也消除了线程并发问题。
例如提供配置项或者接口系统默认将消息投递到后端服务调度线程但是也支持短路策略直接在Netty的NioEventLoop中执行消息的序列化和反序列化、以及服务接口调用。
### 3.2.2 减少锁竞争
当前gRPC的线程切换策略如下
<img src="https://static001.geekbang.org/resource/image/bc/28/bc60a016b98fd462e8b969312a4a5428.png" alt="" />
优化之后的gRPC线程切换策略
<img src="https://static001.geekbang.org/resource/image/82/c9/8205614f248c915610d102b52e95bbc9.png" alt="" />
通过线程绑定技术例如采用一致性hash做映射,将Netty的I/O线程与后端的服务调度线程做绑定1个I/O线程绑定一个或者多个服务调用线程降低锁竞争提升性能。
### 3.2.3 关键点总结
RPC调用涉及的主要队列如下
- Netty的消息发送队列客户端和服务端都涉及
- gRPC SerializingExecutor的消息队列JDK的BlockingQueue
- gRPC消息发送任务队列WriteQueue。
源代码下载地址:
链接: [https://github.com/geektime-geekbang/gRPC_LLF/tree/master](https://github.com/geektime-geekbang/gRPC_LLF/tree/master)

View File

@@ -0,0 +1,706 @@
# 1. 常用的服务调用方式
无论是RPC框架还是当前比较流行的微服务框架通常都会提供多种服务调用方式以满足不同业务场景的需求比较常用的服务调用方式如下
1. **同步服务调用:**最常用的服务调用方式,开发比较简单,比较符合编程人员的习惯,代码相对容易维护些;
1. **并行服务调用:**对于无上下文依赖的多个服务,可以一次并行发起多个调用,这样可以有效降低服务调用的时延;
1. **异步服务调用:**客户端发起服务调用之后,不同步等待响应,而是注册监听器或者回调函数,待接收到响应之后发起异步回调,驱动业务流程继续执行,比较常用的有 Reactive响应式编程和JDK的Future-Listener回调机制。
下面我们分别对上述几种服务调用方式进行讲解。
## 1.1 同步服务调用
同步服务调用是最常用的一种服务调用方式它的工作原理和使用都非常简单RPC/微服务框架默认都支持该调用形式。
同步服务调用的工作原理如下客户端发起RPC调用将请求消息路由到I/O线程无论I/O线程是同步还是异步发送消息发起调用的业务线程都会同步阻塞等待服务端的应答由I/O线程唤醒同步等待的业务线程获取应答然后业务流程继续执行。它的工作原理图如下所示
<img src="https://static001.geekbang.org/resource/image/47/21/47c58251d2f8bd98bfbcd72bcd14e421.png" alt="" />
第1步消费者调用服务端发布的接口接口调用由服务框架包装成动态代理发起远程服务调用。
第2步通信框架的I/O线程通过网络将请求消息发送给服务端。
第3步消费者业务线程调用通信框架的消息发送接口之后直接或者间接调用wait()方法同步阻塞等待应答。备注与步骤2无严格的顺序先后关系不同框架实现策略不同
第4步服务端返回应答消息给消费者由通信框架负责应答消息的反序列化。
第5步I/O线程获取到应答消息之后根据消息上下文找到之前同步阻塞的业务线程notify()阻塞的业务线程,返回应答给消费者,完成服务调用。
同步服务调用会阻塞调用方的业务线程,为了防止服务端长时间不返回应答消息导致客户端用户线程被挂死,业务线程等待的时候需要设置超时时间,超时时间的取值需要综合考虑业务端到端的时延控制目标,以及自身的可靠性,超时时间不宜设置过大或者过小,通常在几百毫秒到几秒之间。
## 1.2 并行服务调用
在大多数业务应用中服务总是被串行的调用和执行例如A业务调用B服务B服务又调用C服务最后形成一个串行的服务调用链A业务 — B服务 — C服务…
串行服务调用代码比较简单,便于开发和维护,但在一些时延敏感型的业务场景中,需要采用并行服务调用来降低时延,比较典型的场景如下:
1. 多个服务之间逻辑上不存在上下文依赖关系,执行先后顺序没有严格的要求,逻辑上可以被并行执行;
1. 长流程业务,调用多个服务,对时延比较敏感,其中有部分服务逻辑上无上下文关联,可以被并行调用。
并行服务调用的主要目标有两个:
1. 降低业务E2E时延。
1. 提升整个系统的吞吐量。
以游戏业务中购买道具流程为例,对并行服务调用的价值进行说明:
<img src="https://static001.geekbang.org/resource/image/97/7e/97618a016f16a63217b53f9d3ff3c07e.png" alt="" />
在购买道具时三个鉴权流程实际可以并行执行最终执行结果做个Join即可。如果采用传统的串行服务调用耗时将是三个鉴权服务时延之和显然是没有必要的。
计费之后的通知类服务亦如此注意通知服务也可以使用MQ做订阅/发布),单个服务的串行调用会导致购买道具时延比较长,影响游戏玩家的体验。
要解决串行调用效率低的问题,有两个解决对策:
1. 并行服务调用一次I/O操作可以发起批量调用然后同步等待响应
1. 异步服务调用,在同一个业务线程中异步执行多个服务调用,不阻塞业务线程。
采用并行服务调用的伪代码示例:
```
ParallelFuture future = ParallelService.invoke(serviceName [], methodName[], args []);
List&lt;Object&gt; results = future.get(timeout);//同步阻塞式获取批量服务调用的响应列表
```
采用并行服务调用之后,它的总耗时为并行服务调用中耗时最大的服务的执行时间,即 T = Max(T(服务A)T(服务B)T(服务C))如果采用同步串行服务调用则总耗时为并行调用的各个服务耗时之和T = T(服务A) + T(服务B) + T(服务C。服务调用越多并行服务调用的优势越明显。
并行服务调用的一种实现策略如下所示:
<img src="https://static001.geekbang.org/resource/image/c1/bb/c1585171726fbb13dfbf488b52ba34bb.png" alt="" />
第1步服务框架提供批量服务调用接口供消费者使用它的定义样例如下ParallelService.invoke(serviceName [], methodName[], args [])
第2步平台的并行服务调用器创建并行Future缓存批量服务调用上下文信息
第3步并行服务调用器循环调用普通的Invoker通过循环的方式执行单个服务调用获取到单个服务的Future之后设置到Parallel Future中
第4步返回Parallel Future给消费者
第5步普通Invoker调用通信框架的消息发送接口发起远程服务调用
第6步服务端返回应答通信框架对报文做反序列化转换成业务对象更新Parallel Future的结果列表
第7步消费者调用Parallel Future的get(timeout)方法, 同步阻塞,等待所有结果全部返回;
第8步Parallel Future通过对结果集进行判断看所有服务调用是否都已经完成包括成功、失败和异常
第9步所有批量服务调用结果都已经返回notify消费者线程消费者获取到结果列表完成批量服务调用流程继续执行。
通过批量服务调用+ Future机制可以实现并行服务调用由于在调用过程中没有创建新的线程用户就不需要担心依赖线程上下文的功能发生异常。
## 1.3 异步服务调用
JDK原生的Future主要用于异步操作它代表了异步操作的执行结果用户可以通过调用它的get方法获取结果。如果当前操作没有执行完get操作将阻塞调用线程:
<img src="https://static001.geekbang.org/resource/image/ce/76/cefed1d339b00c88b8f9548b45bb5d76.png" alt="" />
在实际项目中往往会扩展JDK的Future提供Future-Listener机制它支持主动获取和被动异步回调通知两种模式适用于不同的业务场景。
异步服务调用的工作原理如下:
<img src="https://static001.geekbang.org/resource/image/fc/69/fc8debf2fe872fe0398a9fa2f50bcd69.png" alt="" />
异步服务调用的工作流程如下:
第1步消费者调用服务端发布的接口接口调用由服务框架包装成动态代理发起远程服务调用
第2步通信框架异步发送请求消息如果没有发生I/O异常返回
第3步请求消息发送成功后I/O线程构造Future对象设置到RPC上下文中
第4步业务线程通过RPC上下文获取Future对象
第5步构造Listener对象将其添加到Future中用于服务端应答异步回调通知
第6步业务线程返回不阻塞等待应答
第7步服务端返回应答消息通信框架负责反序列化等
第8步I/O线程将应答设置到Future对象的操作结果中
第9步Future对象扫描注册的监听器列表循环调用监听器的operationComplete方法将结果通知给监听器监听器获取到结果之后继续后续业务逻辑的执行异步服务调用结束。
异步服务调用相比于同步服务调用有两个优点:
1. 化串行为并行,提升服务调用效率,减少业务线程阻塞时间。
1. 化同步为异步,避免业务线程阻塞。
基于Future-Listener的纯异步服务调用代码示例如下
```
xxxService1.xxxMethod(Req);
Future f1 = RpcContext.getContext().getFuture();
Listener l = new xxxListener();
f1.addListener(l);
class xxxListener{
public void operationComplete(F future)
{ //判断是否执行成功,执行后续业务流程}
}
```
# 服务调用的一些误区和典型问题
对于服务调用方式的理解容易出现各种误区例如把I/O异步与服务调用的异步混淆起来认为异步服务调用一定性能更高等。
另外由于Restful风格API的盛行很多RPC/微服务框架开始支持Restful API而且通常都是基于HTTP/1.0/1.1协议实现的。对于内部的RPC调用使用HTTP/1.0/1.1协议代替TCP私有协议可能会带来一些潜在的性能风险需要在开放性、标准性以及性能成本上综合考虑谨慎选择。
## 2.1 理解误区
### 2.1.1 I/O异步服务就是异步
实际上通信框架基于NIO实现并不意味着服务框架就支持异步服务调用了两者本质上不是同一个层面的事情。在RPC/微服务框架中引入NIO带来的好处是显而易见的
- 所有的I/O操作都是非阻塞的避免有限的I/O线程因为网络、对方处理慢等原因被阻塞
- 多路复用的Reactor线程模型基于Linux的epoll和Selector一个I/O线程可以并行处理成百上千条链路解决了传统同步I/O通信线程膨胀的问题。
NIO只解决了通信层面的异步问题跟服务调用的异步没有必然关系也就是说即便采用传统的BIO通信依然可以实现异步服务调用只不过通信效率和可靠性比较差而已。
对异步服务调用和通信框架的关系进行说明:<br />
<img src="https://static001.geekbang.org/resource/image/a6/b8/a69619ff7aad67e7a714925c69642eb8.png" alt="" />
用户发起远程服务调用之后经历层层业务逻辑处理、消息编码最终序列化后的消息会被放入到通信框架的消息队列中。业务线程可以选择同步等待、也可以选择直接返回通过消息队列的方式实现业务层和通信层的分离是比较成熟、典型的做法目前主流的RPC框架或者Web服务器很少直接使用业务线程进行网络读写。
通过上图可以看出采用NIO还是BIO对上层的业务是不可见的双方的汇聚点就是消息队列在Java实现中它通常就是个Queue。业务线程将消息放入到发送队列中可以选择主动等待或者立即返回跟通信框架是否是NIO没有任何关系。因此不能认为I/O异步就代表服务调用也是异步的。
### 2.1.2 服务调用天生就是同步的
RPC/微服务框架的一个目标就是让用户像调用本地方法一样调用远程服务,而不需要关心服务提供者部署在哪里,以及部署形态(透明化调用)。由于本地方法通常都是同步调用,所以服务调用也应该是同步的。
从服务调用形式上看主要包含3种
1. **one way方式**只有请求,没有应答。例如通知消息。
1. **请求-响应方式:**一请求一应答,这种方式比较常用。
1. **请求-响应-异步通知方式:**客户端发送请求之后服务端接收到就立即返回应答类似TCP的ACK。业务接口层面的响应通过异步通知的方式告知请求方。例如电商类的支付接口、充值缴费接口等。
OneWay方式的调用示意图如下
请求-应答模式最常用例如HTTP协议就是典型的请求-应答方式:
请求-响应-异步通知方式流程:通过流程设计,将执行时间可能较长的服务接口从流程上设计成异步。通常在服务调用时请求方携带回调的通知地址,服务端接收到请求之后立即返回应答,表示该请求已经被接收处理。当服务调用真正完成之后,再通过回调地址反向调用服务消费端,将响应通知异步返回。通过接口层的异步,来实现整个服务调用流程的异步,降低了异步调用的开发难度。
One way方式的服务调用由于不需要返回应答因此也很容易被设计成异步的消费者发起远程服务调用之后立即返回不需要同步阻塞等待应答。
对于请求-响应方式,一般的观点都认为消费者必需要等待服务端响应,拿到结果之后才能返回,否则结果从哪里取?即便业务线程不阻塞,没有获取到结果流程还是无法继续执行下去。
从逻辑上看上述观点没有问题。但实际上同步阻塞等待应答并非是唯一的技术选择我们也可以利用Java的Future-Listener机制来实现异步服务调用。从业务角度看它的效果与同步等待等价但是从技术层面看却是个很大的进步它可以保证业务线程在不同步阻塞的情况下实现同步等待的效果服务执行效率更高。
即接口层面请求-响应式定义与具体的技术实现无关,选择同步还是异步服务调用,取决于技术实现。当然,异步通知类接口,从技术实现上做异步更容易些。
### 2.1.3 异步服务调用性能更高
对于I/O密集型资源不是瓶颈大部分时间都在同步等应答的场景异步服务调用会带来巨大的吞吐量提升资源使用率也可以提高更加充分的利用硬件资源提升性能。
另外,对于时延不稳定的接口,例如依赖第三方服务的响应速度、数据库操作类等,通常异步服务调用也会带来性能提升。
但是如果接口调用时延本身都非常小例如毫秒级内存计算型不依赖第三方服务内部也没有I/O操作则异步服务调用并不会提升性能。能否提升性能主要取决于业务的应用场景。
## 2.2 Restful API的潜在性能风险
使用Restful API可以带来很多收益
- API接口更加规范和标准可以通过Swagger API规范来描述服务接口并生成客户端和服务端代码
- Restful API可读性更好也更容易维护
- 服务提供者和消费者基于API契约双方可以解耦不需要在客户端引入SDK和类库的直接依赖未来的独立升级也更方便
- 内外可以使用同一套API非常容易开放给外部或者合作伙伴使用而不是对内和对外维护两套不同协议的API。
通常对外开放的API使用Restful是通用的做法但是在系统内部例如商品中心和订单中心RPC调用使用 Restful风格的API作为微服务的API却可能存在性能风险。
### 2.2.1 HTTP1.X的性能问题
如果HTTP服务器采用同步阻塞I/O例如Tomcat5.5之前的BIO模型如下图所示
<img src="https://static001.geekbang.org/resource/image/08/b0/08deb9535575670243d3793f3dc2f1b0.png" alt="" />
就会存在如下几个问题:
1. **性能问题:**一连接一线程模型导致服务端的并发接入数和系统吞吐量受到极大限制;
1. **可靠性问题:**由于I/O操作采用同步阻塞模式当网络拥塞或者通信对端处理缓慢会导致I/O线程被挂住阻塞时间无法预测
1. **可维护性问题:**I/O线程数无法有效控制、资源无法有效共享多线程并发问题系统可维护性差。
显然如果采用的Restful API 底层使用的HTTP协议栈是同步阻塞I/O则服务端的处理性能将大打折扣。
### 2.2.2 异步非阻塞I/O的HTTP协议栈
如果HTTP协议栈采用了异步非阻塞I/O模型例如Netty、Servlet3.X版本则可以解决同步阻塞I/O的问题带来如下收益
- 同一个I/O线程可以并行处理多个客户端链接有效降低了I/O线程数量提升了资源调度利用率
- 读写操作都是非阻塞的不会因为对端处理慢、网络时延大等导致的I/O线程被阻塞。
相比于TCP类协议例如Thrift, 采用了非阻塞I/O的HTTP/1.X协议仍然存在性能问题原因如下所示
<img src="https://static001.geekbang.org/resource/image/df/71/df88e6d9be74aff50bc9461e5bb69971.png" alt="" />
由于HTTP协议是无状态的客户端发送请求之后必须等待接收到服务端响应之后才能继续发送请求非websocket、pipeline等模式
在某一个时刻链路上只存在单向的消息流实际上把TCP的双工变成了单工模式。如果服务端响应耗时较大则单个HTTP链路的通信性能严重下降只能通过不断的新建连接来提升I/O性能。
但这也会带来很多副作用例如句柄数的增加、I/O线程的负载加重等。显而易见修8条单向车道的成本远远高于修一条双向8车道的成本。
除了无状态导致的链路传输性能差之外HTTP/1.X还存在如下几个影响性能的问题
<li>
HTTP客户端超时之后由于协议是无状态的客户端无法对请求和响应进行关联只能关闭链路重连反复的建链会增加成本开销和时延如果客户端选择不关闭链路继续发送新的请求服务端可能会把上一条客户端认为超时的响应返回回去也可能按照HTTP协议规范直接关闭链路无路哪种处理都会导致链路被关闭。如果采用传统的RPC私有协议请求和响应可以通过消息ID或者会话ID做关联某条消息的超时并不需要关闭链路只需要丢弃该消息重发即可。
</li>
<li>
HTTP本身包含文本类型的协议消息头占用一些字节另外采用JSON类文本的序列化方式报文相比于传统的私有RPC协议也大很多降低了传输性能。
</li>
<li>
服务端无法主动推送响应。
</li>
如果业务对性能和资源成本要求非常苛刻在选择使用基于HTTP/1.X的Restful API 代替私有RPC API通常是基于TCP的二进制私有协议时就要三思。反之如果业务对性能要求较低或者在硬件成本和开放性、规范性上更看重后者则使用Restful API也无妨。
### 2.2.3 推荐解决方案
如果选择Restful API作为内部RPC或者微服务的接口协议则建议使用HTTP/2.0协议来承载它的优点如下支持双向流、消息头压缩、单TCP的多路复用、服务端推送等特性。可以有效解决传统HTTP/1.X协议遇到的问题效果与RPC的TCP私有协议接近。
# 3. gRPC服务调用
gRPC的通信协议基于标准的HTTP/2设计主要提供了两种RPC调用方式
1. 普通RPC调用方式即请求-响应模式。
1. 基于HTTP/2.0的streaming调用方式。
## 3.1 普通RPC调用
普通的RPC调用提供了三种实现方式
1. 同步阻塞式服务调用通常实现类是xxxBlockingStub基于proto定义生成
1. 异步非阻塞调用基于Future-Listener机制通常实现类是xxxFutureStub。
1. 异步非阻塞调用基于Reactive的响应式编程模式通常实现类是xxxStub。
### 3.1.1 同步阻塞式RPC调用
同步阻塞式服务调用代码示例如下HelloWorldClient类
```
blockingStub = GreeterGrpc.newBlockingStub(channel);
...
HelloRequest request
= HelloRequest.newBuilder().setName(name).build();
HelloReply response;
try {
response = blockingStub.sayHello(request);
...
```
创建GreeterBlockingStub然后调用它的sayHello此时会阻塞调用方线程例如main函数直到收到服务端响应之后业务代码继续执行打印响应日志。
实际上同步服务调用是由gRPC框架的ClientCalls在框架层做了封装异步发起服务调用之后同步阻塞调用方线程直到收到响应再唤醒被阻塞的业务线程源码如下ClientCalls类
```
try {
ListenableFuture&lt;RespT&gt; responseFuture = futureUnaryCall(call, param);
while (!responseFuture.isDone()) {
try {
executor.waitAndDrain();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw Status.CANCELLED.withCause(e).asRuntimeException();
}
```
判断当前操作是否完成如果没完成则在ThreadlessExecutor中阻塞阻塞调用方线程ThreadlessExecutor不是真正的线程池代码如下ThreadlessExecutor类
```
Runnable runnable = queue.take();
while (runnable != null) {
try {
runnable.run();
} catch (Throwable t) {
log.log(Level.WARNING, &quot;Runnable threw exception&quot;, t);
}
runnable = queue.poll();
}
```
调用queue的take方法会阻塞直到队列中有消息(响应)才会继续执行BlockingQueue类
```
/**
* Retrieves and removes the head of this queue, waiting if necessary
* until an element becomes available.
*
* @return the head of this queue
* @throws InterruptedException if interrupted while waiting
*/
E take() throws InterruptedException;
```
### 3.1.2 基于Future的异步RPC调用
业务调用代码示例如下HelloWorldClient类
```
HelloRequest request
= HelloRequest.newBuilder().setName(name).build();
try {
com.google.common.util.concurrent.ListenableFuture&lt;io.grpc.examples.helloworld.HelloReply&gt;
listenableFuture = futureStub.sayHello(request);
Futures.addCallback(listenableFuture, new FutureCallback&lt;HelloReply&gt;() {
@Override
public void onSuccess(@Nullable HelloReply result) {
logger.info(&quot;Greeting: &quot; + result.getMessage());
}
```
调用GreeterFutureStub的sayHello方法返回的不是应答而是ListenableFuture它继承自JDK的Future接口定义如下
```
@GwtCompatible
public interface ListenableFuture&lt;V&gt; extends Future&lt;V&gt;
```
将ListenableFuture加入到gRPC的Future列表中创建一个新的FutureCallback对象当ListenableFuture获取到响应之后gRPC的DirectExecutor线程池会调用新创建的FutureCallback执行onSuccess或者onFailure实现异步回调通知。
接着我们分析下ListenableFuture的实现原理ListenableFuture的具体实现类是GrpcFuture代码如下ClientCalls类
```
public static &lt;ReqT, RespT&gt; ListenableFuture&lt;RespT&gt; futureUnaryCall(
ClientCall&lt;ReqT, RespT&gt; call,
ReqT param) {
GrpcFuture&lt;RespT&gt; responseFuture = new GrpcFuture&lt;RespT&gt;(call);
asyncUnaryRequestCall(call, param, new UnaryStreamToFuture&lt;RespT&gt;(responseFuture), false);
return responseFuture;
}
```
获取到响应之后调用complete方法AbstractFuture类
```
private void complete() {
for (Waiter currentWaiter = clearWaiters();
currentWaiter != null;
currentWaiter = currentWaiter.next) {
currentWaiter.unpark();
}
```
将ListenableFuture加入到Future列表中之后同步获取响应在gRPC线程池中阻塞非业务调用方线程Futures类
```
public static &lt;V&gt; V getUninterruptibly(Future&lt;V&gt; future)
throws ExecutionException {
boolean interrupted = false;
try {
while (true) {
try {
return future.get();
} catch (InterruptedException e) {
interrupted = true;
}
}
```
获取到响应之后回调callback的onSuccess代码如下Futures类
```
value = getUninterruptibly(future);
} catch (ExecutionException e) {
callback.onFailure(e.getCause());
return;
} catch (RuntimeException e) {
callback.onFailure(e);
return;
} catch (Error e) {
callback.onFailure(e);
return;
}
callback.onSuccess(value);
```
除了将ListenableFuture加入到Futures中由gRPC的线程池执行异步回调也可以自定义线程池执行异步回调代码示例如下HelloWorldClient类
```
listenableFuture.addListener(new Runnable() {
@Override
public void run() {
try {
HelloReply response = listenableFuture.get();
logger.info(&quot;Greeting: &quot; + response.getMessage());
}
catch(Exception e)
{
e.printStackTrace();
}
}
}, Executors.newFixedThreadPool(1));
```
### 3.1.3 Reactive风格异步RPC调用
业务调用代码示例如下HelloWorldClient类
```
HelloRequest request = HelloRequest.newBuilder().setName(name).build();
io.grpc.stub.StreamObserver&lt;io.grpc.examples.helloworld.HelloReply&gt; responseObserver =
new io.grpc.stub.StreamObserver&lt;io.grpc.examples.helloworld.HelloReply&gt;()
{
public void onNext(HelloReply value)
{
logger.info(&quot;Greeting: &quot; + value.getMessage());
}
public void onError(Throwable t){
logger.warning(t.getMessage());
}
public void onCompleted(){}
};
stub.sayHello(request,responseObserver);
```
构造响应StreamObserver通过响应式编程处理正常和异常回调接口定义如下
<img src="https://static001.geekbang.org/resource/image/45/1d/45b9e8b4dea5ff3498533097808f2e1d.png" alt="" />
将响应StreamObserver作为入参传递到异步服务调用中该方法返回空程序继续向下执行不阻塞当前业务线程代码如下所示GreeterGrpc.GreeterStub
```
public void sayHello(io.grpc.examples.helloworld.HelloRequest request,
io.grpc.stub.StreamObserver&lt;io.grpc.examples.helloworld.HelloReply&gt; responseObserver) {
asyncUnaryCall(
getChannel().newCall(METHOD_SAY_HELLO, getCallOptions()), request, responseObserver);
}
```
下面分析下基于Reactive方式异步调用的代码实现把响应StreamObserver对象作为入参传递到异步调用中代码如下(ClientCalls类)
```
private static &lt;ReqT, RespT&gt; void asyncUnaryRequestCall(
ClientCall&lt;ReqT, RespT&gt; call, ReqT param, StreamObserver&lt;RespT&gt; responseObserver,
boolean streamingResponse) {
asyncUnaryRequestCall(call, param,
new StreamObserverToCallListenerAdapter&lt;ReqT, RespT&gt;(call, responseObserver,
new CallToStreamObserverAdapter&lt;ReqT&gt;(call),
streamingResponse),
streamingResponse);
}
```
当收到响应消息时调用StreamObserver的onNext方法代码如下StreamObserverToCallListenerAdapter类
```
public void onMessage(RespT message) {
if (firstResponseReceived &amp;&amp; !streamingResponse) {
throw Status.INTERNAL
.withDescription(&quot;More than one responses received for unary or client-streaming call&quot;)
.asRuntimeException();
}
firstResponseReceived = true;
observer.onNext(message);
```
当Streaming关闭时调用onCompleted方法如下所示StreamObserverToCallListenerAdapter类
```
public void onClose(Status status, Metadata trailers) {
if (status.isOk()) {
observer.onCompleted();
} else {
observer.onError(status.asRuntimeException(trailers));
}
}
```
通过源码分析可以发现Reactive风格的异步调用相比于Future模式没有任何同步阻塞点无论是业务线程还是gRPC框架的线程都不会同步等待相比于Future异步模式Reactive风格的调用异步化更彻底一些。
## 3.2 Streaming模式服务调用
基于HTTP/2.0,gRPC提供了三种streaming模式
1. 服务端streaming
1. 客户端streaming
1. 服务端和客户端双向streaming
### 3.2.1 服务端streaming
服务端streaming模式指客户端1个请求服务端返回N个响应每个响应可以单独的返回它的原理如下所示
<img src="https://static001.geekbang.org/resource/image/f4/26/f4989ade79af24dcaa46d05ac62cae26.png" alt="" />
适用的场景主要是客户端发送单个请求但是服务端可能返回的是一个响应列表服务端不想等到所有的响应列表都组装完成才返回应答给客户端而是处理完成一个就返回一个响应直到服务端关闭stream通知客户端响应全部发送完成。
在实际业务中应用场景还是比较多的最典型的如SP短信群发功能如果不使用streaming模式则原群发流程如下所示
<img src="https://static001.geekbang.org/resource/image/27/e6/2742f5dc95f8032a082bd483504594e6.png" alt="" />
采用gRPC服务端streaming模式之后流程优化如下
<img src="https://static001.geekbang.org/resource/image/46/c2/46efe67ea4301bd4cfce2e824e86ccc2.png" alt="" />
实际上不同用户之间的短信下发和通知是独立的不需要互相等待采用streaming模式之后单个用户的体验会更好。
服务端streaming模式的本质就是如果响应是个列表列表中的单个响应比较独立有些耗时长有些耗时短为了防止快的等慢的可以处理完一个就返回一个不需要等所有的都处理完才统一返回响应。可以有效避免客户端要么在等待要么需要批量处理响应资源使用不均的问题也可以压缩单个响应的时延端到端提升用户的体验时延敏感型业务
像请求-响应-异步通知类业务也比较适合使用服务端streaming模式。它的proto文件定义如下所示
```
rpc ListFeatures(Rectangle) returns (stream Feature) {}
```
下面一起看下业务示例代码:
<img src="https://static001.geekbang.org/resource/image/7f/0f/7faa1b53676125cfaa32b1dae7c9ec0f.png" alt="" />
服务端Sreaming模式也支持同步阻塞和Reactive异步两种调用方式以Reactive异步为例它的代码实现如下RouteGuideImplBase类
```
public void listFeatures(io.grpc.examples.routeguide.Rectangle request,
io.grpc.stub.StreamObserver&lt;io.grpc.examples.routeguide.Feature&gt; responseObserver) {
asyncUnimplementedUnaryCall(METHOD_LIST_FEATURES, responseObserver);
}
```
构造io.grpc.stub.StreamObserver&lt;io.grpc.examples.routeguide.Feature&gt; responseObserver实现它的三个回调接口注意由于是服务端streaming模式所以它的onNext(Feature value)将会被回调多次每次都代表一个响应如果所有的响应都返回则会调用onCompleted()方法。
### 3.2.2 客户端streaming
与服务端streaming类似客户端发送多个请求服务端返回一个响应多用于汇聚和汇总计算场景proto文件定义如下
```
rpc RecordRoute(stream Point) returns (RouteSummary) {}
```
业务调用代码示例如下RouteGuideClient类
```
StreamObserver&lt;Point&gt; requestObserver = asyncStub.recordRoute(responseObserver);
try {
// Send numPoints points randomly selected from the features list.
for (int i = 0; i &lt; numPoints; ++i) {
int index = random.nextInt(features.size());
Point point = features.get(index).getLocation();
info(&quot;Visiting point {0}, {1}&quot;, RouteGuideUtil.getLatitude(point),
RouteGuideUtil.getLongitude(point));
requestObserver.onNext(point);
...
```
异步服务调用获取请求StreamObserver对象循环调用requestObserver.onNext(point)异步发送请求消息到服务端发送完成之后调用requestObserver.onCompleted(),通知服务端所有请求已经发送完成,可以接收服务端的响应了。
响应接收的代码如下所示由于响应只有一个所以onNext只会被调用一次RouteGuideClient类
```
StreamObserver&lt;RouteSummary&gt; responseObserver = new StreamObserver&lt;RouteSummary&gt;() {
@Override
public void onNext(RouteSummary summary) {
info(&quot;Finished trip with {0} points. Passed {1} features. &quot;
+ &quot;Travelled {2} meters. It took {3} seconds.&quot;, summary.getPointCount(),
summary.getFeatureCount(), summary.getDistance(), summary.getElapsedTime());
if (testHelper != null) {
testHelper.onMessage(summary);
}
```
异步服务调用时将响应StreamObserver实例作为参数传入代码如下
```
StreamObserver&lt;Point&gt; requestObserver = asyncStub.recordRoute(responseObserver);
```
### 3.2.3 双向streaming
客户端发送N个请求服务端返回N个或者M个响应利用该特性可以充分利用HTTP/2.0的多路复用功能在某个时刻HTTP/2.0链路上可以既有请求也有响应,实现了全双工通信(对比单行道和双向车道),示例如下:
proto文件定义如下
```
rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}
```
业务代码示例如下RouteGuideClient类
```
StreamObserver&lt;RouteNote&gt; requestObserver =
asyncStub.routeChat(new StreamObserver&lt;RouteNote&gt;() {
@Override
public void onNext(RouteNote note) {
info(&quot;Got message \&quot;{0}\&quot; at {1}, {2}&quot;, note.getMessage(), note.getLocation()
.getLatitude(), note.getLocation().getLongitude());
if (testHelper != null) {
testHelper.onMessage(note);
}
}
```
构造Streaming响应对象StreamObserver<RouteNote>并实现onNext等接口由于服务端也是Streaming模式因此响应是多个的也就是说onNext会被调用多次。<br />
通过在循环中调用requestObserver的onNext方法发送请求消息代码如下所示RouteGuideClient类
```
for (RouteNote request : requests) {
info(&quot;Sending message \&quot;{0}\&quot; at {1}, {2}&quot;, request.getMessage(), request.getLocation()
.getLatitude(), request.getLocation().getLongitude());
requestObserver.onNext(request);
}
} catch (RuntimeException e) {
// Cancel RPC
requestObserver.onError(e);
throw e;
}
// Mark the end of requests
requestObserver.onCompleted();
```
requestObserver的onNext方法实际调用了ClientCall的消息发送方法代码如下CallToStreamObserverAdapter类
```
private static class CallToStreamObserverAdapter&lt;T&gt; extends ClientCallStreamObserver&lt;T&gt; {
private boolean frozen;
private final ClientCall&lt;T, ?&gt; call;
private Runnable onReadyHandler;
private boolean autoFlowControlEnabled = true;
public CallToStreamObserverAdapter(ClientCall&lt;T, ?&gt; call) {
this.call = call;
}
private void freeze() {
this.frozen = true;
}
@Override
public void onNext(T value) {
call.sendMessage(value);
}
```
对于双向Streaming模式只支持异步调用方式。
## 3.3 总结
gRPC服务调用支持同步和异步方式同时也支持普通的RPC和streaming模式可以最大程度满足业务的需求。<br />
对于streaming模式可以充分利用HTTP/2.0协议的多路复用功能实现在一条HTTP链路上并行双向传输数据有效的解决了HTTP/1.X的数据单向传输问题在大幅减少HTTP连接的情况下充分利用单条链路的性能可以媲美传统的RPC私有长连接协议更少的链路、更高的性能
<img src="https://static001.geekbang.org/resource/image/af/b2/af32d0e627414f83e63137bfedfdc7b2.png" alt="" />
gRPC的网络I/O通信基于Netty构建服务调用底层统一使用异步方式同步调用是在异步的基础上做了上层封装。因此gRPC的异步化是比较彻底的对于提升I/O密集型业务的吞吐量和可靠性有很大的帮助。
源代码下载地址:
链接: [https://github.com/geektime-geekbang/gRPC_LLF/tree/master](https://github.com/geektime-geekbang/gRPC_LLF/tree/master)

View File

@@ -0,0 +1,691 @@
# 1. RPC调用安全策略
## 1.1 严峻的安全形势
近年来,个人信息泄漏和各种信息安全事件层出不穷,个人信息安全以及隐私数据保护面临严峻的挑战。
很多国家已经通过立法的方式保护个人信息和数据安全例如我国2016年11月7日出台、2017年6月1日正式实施的《网络安全法》以及2016年4月14日欧盟通过的《一般数据保护法案》GDP R该法案将于2018年5月25日正式生效。
GDPR的通过意味着欧盟对个人信息保护及其监管达到了前所未有的高度堪称史上最严格的数据保护法案。
作为企业内部各系统、模块之间调用的通信框架即便是内网通信RPC调用也需要考虑安全性RPC调用安全主要涉及如下三点
1. **个人/企业敏感数据加密:**例如针对个人的账号、密码、手机号等敏感信息进行加密传输,打印接口日志时需要做数据模糊化处理等,不能明文打印;
1. **对调用方的身份认证:**调用来源是否合法,是否有访问某个资源的权限,防止越权访问;
1. **数据防篡改和完整性:**通过对请求参数、消息头和消息体做签名,防止请求消息在传输过程中被非法篡改。
## 1.2 敏感数据加密传输
### 1.2.1 基于SSL/TLS的通道加密
当存在跨网络边界的RPC调用时往往需要通过TLS/SSL对传输通道进行加密以防止请求和响应消息中的敏感数据泄漏。跨网络边界调用场景主要有三种
1. 后端微服务直接开放给端侧例如手机App、TV、多屏等没有统一的API Gateway/SLB做安全接入和认证
1. 后端微服务直接开放给DMZ部署的管理或者运维类Portal
1. 后端微服务直接开放给第三方合作伙伴/渠道。
除了跨网络之外,对于一些安全等级要求比较高的业务场景,即便是内网通信,只要跨主机/VM/容器通信都强制要求对传输通道进行加密。在该场景下即便只存在内网各模块的RPC调用仍然需要做SSL/TLS。
使用SSL/TLS的典型场景如下所示
<img src="https://static001.geekbang.org/resource/image/1a/48/1a69fa3b2939b510dc9af185e66e3d48.png" alt="" />
目前使用最广的SSL/TLS工具/类库就是OpenSSL它是为网络通信提供安全及数据完整性的一种安全协议囊括了主要的密码算法、常用的密钥和证书封装管理功能以及SSL协议。
多数SSL加密网站是用名为OpenSSL的开源软件包由于这也是互联网应用最广泛的安全传输方法被网银、在线支付、电商网站、门户网站、电子邮件等重要网站广泛使用。
### 1.2.2 针对敏感数据的单独加密
有些RPC调用并不涉及敏感数据的传输或者敏感字段占比较低为了最大程度的提升吞吐量降低调用时延通常会采用HTTP/TCP + 敏感字段单独加密的方式既保障了敏感信息的传输安全同时也降低了采用SSL/TLS加密通道带来的性能损耗对于JDK原生的SSL类库这种性能提升尤其明显。
它的工作原理如下所示:
<img src="https://static001.geekbang.org/resource/image/65/dd/6507b91b6cae0bbcaea4ec535d5944dd.png" alt="" />
通常使用Handler拦截机制对请求和响应消息进行统一拦截根据注解或者加解密标识对敏感字段进行加解密这样可以避免侵入业务。
采用该方案的缺点主要有两个:
1. 对敏感信息的识别可能存在偏差,容易遗漏或者过度保护,需要解读数据和隐私保护方面的法律法规,而且不同国家对敏感数据的定义也不同,这会为识别带来很多困难;
1. 接口升级时容易遗漏,例如开发新增字段,忘记识别是否为敏感数据。
## 1.3 认证和鉴权
RPC的认证和鉴权机制主要包含两点
1. **认证:**对调用方身份进行识别,防止非法调用;
1. **鉴权:**对调用方的权限进行校验,防止越权调用。
事实上并非所有的RPC调用都必须要做认证和鉴权例如通过API Gateway网关接入的流量已经在网关侧做了鉴权和身份认证对来自网关的流量RPC服务端就不需要重复鉴权。
另外,一些对安全性要求不太高的场景,可以只做认证而不做细粒度的鉴权。
### 1.3.1 身份认证
内部RPC调用的身份认证场景主要有如下两大类
1. 防止对方知道服务提供者的地址之后,绕过注册中心/服务路由策略直接访问RPC服务提供端
1. RPC服务只想供内部模块调用不想开放给其它业务系统使用双方网络是互通的
身份认证的方式比较多例如HTTP Basic Authentication、OAuth2等比较简单使用的是令牌认证Token机制它的工作原理如下所示
<img src="https://static001.geekbang.org/resource/image/e0/b6/e0a4b9ecb2788ca842f2d41029a6adb6.png" alt="" />
工作原理如下:
1. RPC客户端和服务端通过HTTPS与注册中心连接做双向认证以保证客户端和服务端与注册中心之间的安全
1. 服务端生成Token并注册到注册中心由注册中心下发给订阅者。通过订阅/发布机制向RPC客户端做Token授权
1. 服务端开启身份认证对RPC调用进行Token校验认证通过之后才允许调用后端服务接口。
### 1.3.2 权限管控
身份认证可以防止非法调用如果需要对调用方进行更细粒度的权限管控则需要做对RPC调用做鉴权。例如管理员可以查看、修改和删除某个后台资源而普通用户只能查看资源不能对资源做管理操作。
在RPC调用领域比较流行的是基于OAuth2.0的权限认证机制,它的工作原理如下:
<img src="https://static001.geekbang.org/resource/image/eb/af/ebb67f6d52854e4e6771ebac4a9041af.png" alt="" />
OAuth2.0的认证流程如下:
1. 客户端向资源拥有者申请授权(例如携带用户名+密码等证明身份信息的凭证);
1. 资源拥有者对客户端身份进行校验,通过之后同意授权;
1. 客户端使用步骤2的授权凭证向认证服务器申请资源访问令牌access token
1. 认证服务器对授权凭证进行合法性校验通过之后颁发access token
1. 客户端携带access token通常在HTTP Header中访问后端资源例如发起RPC调用
1. 服务端对access token合法性进行校验是否合法、是否过期等同时对token进行解析获取客户端的身份信息以及对应的资源访问权限列表实现对资源访问权限的细粒度管控
1. access token校验通过返回资源信息给客户端。
步骤2的用户授权有四种方式
1. 授权码模式authorization code
1. 简化模式implicit
1. 密码模式resource owner password credentials
1. 客户端模式client credentials
需要指出的是OAuth 2.0是一个规范不同厂商即便遵循该规范实现也可能会存在细微的差异。大部分厂商在采用OAuth 2.0的基础之上往往会衍生出自己特有的OAuth 2.0实现。
对于access token为了提升性能RPC服务端往往会缓存不需要每次调用都与AS服务器做交互。同时access token是有过期时间的根据业务的差异过期时间也会不同。客户端在token过期之前需要刷新Token或者申请一个新的Token。
考虑到access token的安全通常选择SSL/TLS加密传输或者对access token单独做加密防止access token泄漏。
## 1.4 数据完整性和一致性
RPC调用除了数据的机密性和有效性之外还有数据的完整性和一致性需要保证即如何保证接收方收到的数据与发送方发出的数据是完全相同的。
利用消息摘要可以保障数据的完整性和一致性,它的特点如下:
- 单向Hash算法从明文到密文的不可逆过程即只能加密而不能解密
- 无论消息大小,经过消息摘要算法加密之后得到的密文长度都是固定的;
- 输入相同,则输出一定相同。
目前常用的消息摘要算法是SHA-1、MD5和MACMD5可产生一个128位的散列值。 SHA-1则是以MD5为原型设计的安全散列算法可产生一个160位的散列值安全性更高一些。MAC除了能够保证消息的完整性还能够保证来源的真实性。
由于MD5已被发现有许多漏洞在实际应用中更多使用SHA和MAC而且往往会把数字签名和消息摘要混合起来使用。
# gRPC安全机制
谷歌提供了可扩展的安全认证机制,以满足不同业务场景需求,它提供的授权机制主要有四类:
1. **通道凭证:**默认提供了基于HTTP/2的TLS对客户端和服务端交换的所有数据进行加密传输
1. **调用凭证:**被附加在每次RPC调用上通过Credentials将认证信息附加到消息头中由服务端做授权认证
1. **组合凭证:**将一个频道凭证和一个调用凭证关联起来创建一个新的频道凭证在这个频道上的每次调用会发送组合的调用凭证来作为授权数据最典型的场景就是使用HTTP S来传输Access Token
1. **Google的OAuth 2.0**gRPC内置的谷歌的OAuth 2.0认证机制通过gRPC访问Google API 时使用Service Accounts密钥作为凭证获取授权令牌。
## 2.1 SSL/TLS认证
gRPC基于HTTP/2协议默认会开启SSL/TLS考虑到兼容性和适用范围gRPC提供了三种协商机制
- **PlaintextNegotiator**非SSL/TLS加密传输的HTTP/2通道不支持客户端通过HTTP/1.1的Upgrade升级到HTTP/2,代码示例如下PlaintextNegotiator类
```
static final class PlaintextNegotiator implements ProtocolNegotiator {
@Override
public Handler newHandler(GrpcHttp2ConnectionHandler handler) {
return new BufferUntilChannelActiveHandler(handler);
}
}
```
- **PlaintextUpgradeNegotiator**非SSL/TLS加密传输的HTTP/2通道支持客户端通过HTTP/1.1的Upgrade升级到HTTP/2代码示例如下PlaintextUpgradeNegotiator类
```
static final class PlaintextUpgradeNegotiator implements ProtocolNegotiator {
@Override
public Handler newHandler(GrpcHttp2ConnectionHandler handler) {
Http2ClientUpgradeCodec upgradeCodec = new Http2ClientUpgradeCodec(handler);
HttpClientCodec httpClientCodec = new HttpClientCodec();
final HttpClientUpgradeHandler upgrader =
new HttpClientUpgradeHandler(httpClientCodec, upgradeCodec, 1000);
return new BufferingHttp2UpgradeHandler(upgrader);
}
}
```
- **TlsNegotiator**基于SSL/TLS加密传输的HTTP/2通道代码示例如下TlsNegotiator类
```
static final class TlsNegotiator implements ProtocolNegotiator {
private final SslContext sslContext;
private final String host;
private final int port;
TlsNegotiator(SslContext sslContext, String host, int port) {
this.sslContext = checkNotNull(sslContext, &quot;sslContext&quot;);
this.host = checkNotNull(host, &quot;host&quot;);
this.port = port;
}
```
下面对gRPC的SSL/TLS工作原理进行详解。
### 2.1.1 SSL/TLS工作原理
SSL/TLS分为单向认证和双向认证在实际业务中单向认证使用较多即客户端认证服务端服务端不认证客户端。
SSL单向认证的过程原理如下
1. SL客户端向服务端传送客户端SSL协议的版本号、支持的加密算法种类、产生的随机数以及其它可选信息
1. 服务端返回握手应答向客户端传送确认SSL协议的版本号、加密算法的种类、随机数以及其它相关信息
1. 服务端向客户端发送自己的公钥;
1. 客户端对服务端的证书进行认证服务端的合法性校验包括证书是否过期、发行服务器证书的CA是否可靠、发行者证书的公钥能否正确解开服务器证书的“发行者的数字签名”、服务器证书上的域名是否和服务器的实际域名相匹配等
1. 客户端随机产生一个用于后面通讯的“对称密码”,然后用服务端的公钥对其加密,将加密后的“预主密码”传给服务端;
1. 服务端将用自己的私钥解开加密的“预主密码”,然后执行一系列步骤来产生主密码;
1. 客户端向服务端发出信息,指明后面的数据通讯将使用主密码为对称密钥,同时通知服务器客户端的握手过程结束;
1. 服务端向客户端发出信息,指明后面的数据通讯将使用主密码为对称密钥,同时通知客户端服务器端的握手过程结束;
1. SSL的握手部分结束SSL安全通道建立客户端和服务端开始使用相同的对称密钥对数据进行加密然后通过Socket进行传输。
SSL单向认证的流程图如下所示
<img src="https://static001.geekbang.org/resource/image/67/ee/672ce5cc60be10d880553ff883c953ee.png" alt="" />
SSL双向认证相比单向认证多了一步服务端发送认证请求消息给客户端客户端发送自签名证书给服务端进行安全认证的过程。
客户端接收到服务端要求客户端认证的请求消息之后,发送自己的证书信息给服务端,信息如下:
<img src="https://static001.geekbang.org/resource/image/b2/bf/b20f662117c0211a7b963fb80f9ac9bf.png" alt="" />
服务端对客户端的自签名证书进行认证,信息如下:
<img src="https://static001.geekbang.org/resource/image/8f/c1/8f00556a8c2309add279f53a60207dc1.png" alt="" />
### 2.1.2 HTTP/2的ALPN
对于一些新的web协议例如HTTP/2客户端和浏览器需要知道服务端是否支持HTTP/2,对于HTTP/2 Over HTTP可以使用HTTP/1.1的Upgrade机制进行协商对于HTTP/2 Over TLS则需要使用到NPN或ALPN扩展来完成协商。
ALPN作为HTTP/2 Over TLS的协商机制已经被定义到 RFC7301中从2016年开始它已经取代NPN成为HTTP/2Over TLS的标准协商机制。目前所有支持HTTP/2的浏览器都已经支持ALPN。
Jetty为 OpenJDK 7和OpenJDK 8提供了扩展的ALPN实现JDK默认不支持ALPN类库与Jetty容器本身并不强绑定无论是否使用Jetty作为Web容器都可以集成Jetty提供的ALPN类库以实现基于TLS的HTTP/2协议。
如果要开启ALPN需要增加如下JVM启动参数
```
java -Xbootclasspath/p:&lt;path_to_alpn_boot_jar&gt; ...
```
客户端代码示例如下:
```
SSLContext sslContext = ...;
final SSLSocket sslSocket = (SSLSocket)context.getSocketFactory().createSocket(&quot;localhost&quot;, server.getLocalPort());
ALPN.put(sslSocket, new ALPN.ClientProvider()
{
public boolean supports()
{
return true;
}
public List&lt;String&gt; protocols()
{
return Arrays.asList(&quot;h2&quot;, &quot;http/1.1&quot;);
}
public void unsupported()
{
ALPN.remove(sslSocket);
}
public void selected(String protocol)
{
ALPN.remove(sslSocket);
}
});
```
服务端代码示例如下:
```
final SSLSocket sslSocket = ...;
ALPN.put(sslSocket, new ALPN.ServerProvider()
{
public void unsupported()
{
ALPN.remove(sslSocket);
}
public String select(List&lt;String&gt; protocols);
{
ALPN.remove(sslSocket);
return protocols.get(0);
}
});
```
以上代码示例来源:[http://www.eclipse.org/jetty/documentation/9.3.x/alpn-chapter.html](http://www.eclipse.org/jetty/documentation/9.3.x/alpn-chapter.html)
需要指出的是Jetty ALPN类库版本与JDK版本是配套使用的配套关系如下所示
可以通过如下网站查询双方的配套关系:[http://www.eclipse.org/jetty/documentation/9.3.x/alpn-chapter.html](http://www.eclipse.org/jetty/documentation/9.3.x/alpn-chapter.html)
如果大家需要了解更多的Jetty ALPN相关信息可以下载jetty的ALPN源码和文档学习。
### 2.1.3 gRPC 的TLS策略
gRPC的TLS实现有两种策略
1. 基于OpenSSL的TLS
1. 基于Jetty ALPN/NPN的TLS
对于非安卓的后端Java应用gRPC强烈推荐使用OpenSSL原因如下
1. 性能更高基于OpenSSL的gRPC调用比使用JDK GCM的性能高10倍以上
1. 密码算法更丰富OpenSSL支持的密码算法比JDK SSL提供的更丰富特别是HTTP/2协议使用的加密算法
1. OpenSSL支持ALPN回退到NPN
1. 不需要根据JDK的版本升级配套升级ALPN类库Jetty的ALPN版本与JDK特定版本配套使用
gRPC的HTTP/2和TLS基于Netty框架实现如果使用OpenSSL则需要依赖Netty的netty-tcnative。
Netty的OpenSSL有两种实现机制Dynamic linked和Statically Linked。在开发和测试环境中建议使用Statically Linked的方式netty-tcnative-boringssl-static它提供了对ALPN的支持以及HTTP/2需要的密码算法不需要额外再集成Jetty的ALPN类库。从1.1.33.Fork16版本开始支持所有的操作系统可以实现跨平台运行。
对于生产环境则建议使用Dynamic linked的方式原因如下
1. 很多场景下需要升级OpenSSL的版本或者打安全补丁如果使用动态链接方式例如apt-ge则应用软件不需要级联升级
1. 对于一些紧急的OpenSSL安全补丁如果采用Statically Linked的方式需要等待Netty社区提供新的静态编译补丁版本可能会存在一定的滞后性。
netty-tcnative-boringssl-static的Maven配置如下
```
&lt;project&gt;
&lt;dependencies&gt;
&lt;dependency&gt;
&lt;groupId&gt;io.netty&lt;/groupId&gt;
&lt;artifactId&gt;netty-tcnative-boringssl-static&lt;/artifactId&gt;
&lt;version&gt;2.0.6.Final&lt;/version&gt;
&lt;/dependency&gt;
&lt;/dependencies&gt;
&lt;/project&gt;
```
使用Dynamically Linked (netty-tcnative)的相关约束如下:
1.
```
OpenSSL version &gt;= 1.0.2 for ALPN
```
或者
```
version &gt;= 1.0.1 for NPN
```
1. 类路径中包含
```
netty-tcnative version &gt;= 1.1.33.Fork7
```
尽管gRPC强烈不建议使用基于JDK的TLS但是它还是提供了对Jetty ALPN/NPN的支持。
通过Xbootclasspath参数开启ALPN示例如下
```
java -Xbootclasspath/p:/path/to/jetty/alpn/extension.jar
```
由于ALPN类库与JDK版本号有强对应关系如果匹配错误则会导致SSL握手失败因此可以通过 Jetty-ALPN-Agent来自动为JDK版本选择合适的ALPN版本启动参数如下所示
```
java -javaagent:/path/to/jetty-alpn-agent.jar
```
### 2.1.4 基于TLS的gRPC代码示例
以基于JDKJetty-ALPN的TLS为例给出gRPC SSL安全认证的代码示例。
TLS服务端创建
```
int port = 18443;
SelfSignedCertificate ssc = new SelfSignedCertificate();
server = ServerBuilder.forPort(port).useTransportSecurity(ssc.certificate(),
ssc.privateKey())
.addService(new GreeterImpl())
.build()
.start();
```
其中SelfSignedCertificate是Netty提供的用于测试的临时自签名证书类在实际项目中需要加载生成环境的CA和密钥。<br />
在启动参数中增加SSL握手日志打印以及Jetty的ALPN Agent类库示例如下
<img src="https://static001.geekbang.org/resource/image/0c/91/0cd945f90415049a211ddde507a54191.png" alt="" />
启动服务端显示SSL证书已经成功加载
<img src="https://static001.geekbang.org/resource/image/4e/20/4ea36b4f882197e496a2ffea7ecddf20.png" alt="" />
TLS客户端代码创建
```
this(NettyChannelBuilder.forAddress(host, port).sslContext(
GrpcSslContexts.forClient().
ciphers(Http2SecurityUtil.CIPHERS,
SupportedCipherSuiteFilter.INSTANCE).
trustManager(InsecureTrustManagerFactory.INSTANCE).build()));
```
NettyChannel创建时使用gRPC的GrpcSslContexts指定客户端模式设置HTTP/2的密钥同时加载CA证书工厂完成TLS客户端的初始化。
与服务端类似,需要通过-javaagent指定ALPN Agent类库路径同时开启SSL握手调试日志打印启动客户端运行结果如下所示
<img src="https://static001.geekbang.org/resource/image/77/7e/7789c382a2e32b77c9a8f201f0d0057e.png" alt="" />
### 2.1.5 gRPC TLS源码分析
gRPC在Netty SSL类库基础上做了二次封装以简化业务的使用以服务端代码为例进行说明服务端开启TLS代码如下NettyServerBuilder类
```
public NettyServerBuilder useTransportSecurity(File certChain, File privateKey) {
try {
sslContext = GrpcSslContexts.forServer(certChain, privateKey).build();
```
实际调用GrpcSslContexts创建了Netty SslContext下面一起分析下GrpcSslContexts的实现它调用了Netty SslContextBuilder加载X.509 certificate chain file和PKCS#8 private key filePEM格式代码如下SslContextBuilder类
```
public static SslContextBuilder forServer(File keyCertChainFile, File keyFile) {
return new SslContextBuilder(true).keyManager(keyCertChainFile, keyFile);
}
```
Netty的SslContext加载keyCertChainFile和private key fileSslContextBuilder类
```
X509Certificate[] keyCertChain;
PrivateKey key;
try {
keyCertChain = SslContext.toX509Certificates(keyCertChainFile);
} catch (Exception e) {
throw new IllegalArgumentException(&quot;File does not contain valid certificates: &quot; + keyCertChainFile, e);
}
try {
key = SslContext.toPrivateKey(keyFile, keyPassword);
```
加载完成之后通过SslContextBuilder创建SslContext完成SSL上下文的创建。
服务端开启SSL之后gRPC会根据初始化完成的SslContext创建SSLEngine然后实例化Netty的SslHandler将其加入到ChannelPipeline中代码示例如下ServerTlsHandler类
```
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
super.handlerAdded(ctx);
SSLEngine sslEngine = sslContext.newEngine(ctx.alloc());
ctx.pipeline().addFirst(new SslHandler(sslEngine, false));
}
```
下面一起分析下Netty SSL服务端的源码SSL服务端接收客户端握手请求消息的入口方法是decode方法首先获取接收缓冲区的读写索引并对读取的偏移量指针进行备份SslHandler类
```
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List&lt;Object&gt; out) throws SSLException {
final int startOffset = in.readerIndex();
final int endOffset = in.writerIndex();
int offset = startOffset;
int totalLength = 0;
...
```
对半包标识进行判断如果上一个消息是半包消息则判断当前可读的字节数是否小于整包消息的长度如果小于整包长度则说明本次读取操作仍然没有把SSL整包消息读取完整需要返回I/O线程继续读取代码如下
```
if (packetLength &gt; 0) {
if (endOffset - startOffset &lt; packetLength) {
return;
...
```
如果消息读取完整,则修改偏移量:同时置位半包长度标识:
```
} else {
offset += packetLength;
totalLength = packetLength;
packetLength = 0;
}
```
下面在for循环中读取SSL消息一个ByteBuf可能包含多条完整的SSL消息。首先判断可读的字节数是否小于协议消息头长度如果是则退出循环继续由I/O线程接收后续的报文
```
if (readableBytes &lt; SslUtils.SSL_RECORD_HEADER_LENGTH) {
break;
}
```
获取SSL消息包的报文长度具体算法不再介绍可以参考SSL的规范文档进行解读代码如下SslUtils类
```
if (tls) {
// SSLv3 or TLS - Check ProtocolVersion
int majorVersion = buffer.getUnsignedByte(offset + 1);
if (majorVersion == 3) {
// SSLv3 or TLS
packetLength = buffer.getUnsignedShort(offset + 3) + SSL_RECORD_HEADER_LENGTH;
...
```
对长度进行判断如果SSL报文长度大于可读的字节数说明是个半包消息将半包标识长度置位返回I/O线程继续读取后续的数据报代码如下SslHandler类
```
if (packetLength &gt; readableBytes) {
// wait until the whole packet can be read
this.packetLength = packetLength;
break;
}
```
对消息进行解码将SSL加密的消息解码为加密前的原始数据unwrap方法如下
```
private boolean unwrap(
ChannelHandlerContext ctx, ByteBuf packet, int offset, int length) throws SSLException {
boolean decoded = false;
boolean wrapLater = false;
boolean notifyClosure = false;
ByteBuf decodeOut = allocate(ctx, length);
try {
while (!ctx.isRemoved()) {
final SSLEngineResult result = engineType.unwrap(this, packet, offset, length, decodeOut);
final Status status = result.getStatus();
...
```
调用SSLEngine的unwrap方法对SSL原始消息进行解码对解码结果进行判断如果越界说明out缓冲区不够需要进行动态扩展。如果是首次越界为了尽量节约内存使用SSL最大缓冲区长度和SSL原始缓冲区可读的字节数中较小的。如果再次发生缓冲区越界说明扩张后的缓冲区仍然不够用直接使用SSL缓冲区的最大长度保证下次解码成功。
解码成功之后对SSL引擎的操作结果进行判断如果需要继续接收数据则继续执行解码操作如果需要发送握手消息则调用wrapNonAppData发送握手消息如果需要异步执行SSL代理任务则调用立即执行线程池执行代理任务如果是握手成功则设置SSL操作结果发送SSL握手成功事件如果是应用层的业务数据则继续执行解码操作其它操作结果抛出操作类型异常SslHandler类
```
switch (handshakeStatus) {
case NEED_UNWRAP:
break;
case NEED_WRAP:
wrapNonAppData(ctx, true);
break;
case NEED_TASK:
runDelegatedTasks();
break;
case FINISHED:
setHandshakeSuccess();
wrapLater = true;
...
```
需要指出的是SSL客户端和服务端接收对方SSL握手消息的代码是相同的那为什么SSL服务端和客户端发送的握手消息不同呢这些是SSL引擎负责区分和处理的我们在创建SSL引擎的时候设置了客户端模式SSL引擎就是根据这个来进行区分的。
SSL的消息读取实际就是ByteToMessageDecoder将接收到的SSL加密后的报文解码为原始报文然后将整包消息投递给后续的消息解码器对消息做二次解码。基于SSL的消息解码模型如下
<img src="https://static001.geekbang.org/resource/image/c9/4e/c9267e5c82a9e08f7df3cb39e286844e.png" alt="" />
SSL消息读取的入口都是decode因为是非握手消息它的处理非常简单就是循环调用引擎的unwrap方法将SSL报文解码为原始的报文代码如下SslHandler类
```
switch (status) {
case BUFFER_OVERFLOW:
int readableBytes = decodeOut.readableBytes();
int bufferSize = engine.getSession().getApplicationBufferSize() - readableBytes;
if (readableBytes &gt; 0) {
decoded = true;
ctx.fireChannelRead(decodeOut);
...
```
握手成功之后的所有消息都是应用数据因此它的操作结果为NOT_HANDSHAKING遇到此标识之后继续读取消息直到没有可读的字节退出循环。
如果读取到了可用的字节则将读取到的缓冲区加到输出结果列表中有后续的Handler进行处理例如对HTTPS的请求报文做反序列化。
SSL消息发送时由SslHandler对消息进行编码编码后的消息实际就是SSL加密后的消息。从待加密的消息队列中弹出消息调用SSL引擎的wrap方法进行编码代码如下SslHandler类
```
while (!ctx.isRemoved()) {
Object msg = pendingUnencryptedWrites.current();
if (msg == null) {
break;
}
ByteBuf buf = (ByteBuf) msg;
if (out == null) {
out = allocateOutNetBuf(ctx, buf.readableBytes());
}
SSLEngineResult result = wrap(alloc, engine, buf, out);
```
wrap方法很简单就是调用SSL引擎的编码方法然后对写索引进行修改如果缓冲区越界则动态扩展缓冲区
```
for (;;) {
ByteBuffer out0 = out.nioBuffer(out.writerIndex(), out.writableBytes());
SSLEngineResult result = engine.wrap(in0, out0);
in.skipBytes(result.bytesConsumed());
out.writerIndex(out.writerIndex() + result.bytesProduced());
...
```
对SSL操作结果进行判断因为已经握手成功因此返回的结果是NOT_HANDSHAKING执行finishWrap方法调用ChannelHandlerContext的write方法将消息写入发送缓冲区中如果待发送的消息为空则构造空的ByteBuf写入SslHandler类
```
private void finishWrap(ChannelHandlerContext ctx, ByteBuf out, ChannelPromise promise, boolean inUnwrap,
boolean needUnwrap) {
if (out == null) {
out = Unpooled.EMPTY_BUFFER;
} else if (!out.isReadable()) {
out.release();
out = Unpooled.EMPTY_BUFFER;
}
if (promise != null) {
ctx.write(out, promise);
} else {
ctx.write(out);
}
```
编码后调用ChannelHandlerContext的flush方法消息发送给对方完成消息的加密发送。
## 2.2 Google OAuth 2.0
### 2.2.1 工作原理
gRPC默认提供了多种OAuth 2.0认证机制假如gRPC应用运行在GCE里可以通过服务账号的密钥生成Token用于RPC调用的鉴权密钥可以从环境变量 GOOGLE_APPLICATION_CREDENTIALS 对应的文件里加载。如果使用GCE可以在虚拟机设置的时候为其配置一个默认的服务账号运行是可以与认证系统交互并为Channel生成RPC调用时的access Token。
### 2.2.2. 代码示例
以OAuth2认证为例客户端代码如下所示创建OAuth2Credentials并实现Token刷新接口
<img src="https://static001.geekbang.org/resource/image/d7/a3/d7b020fecb7c9a28bb4f3696984753a3.png" alt="" />
创建Stub时指定CallCredentials代码示例如下基于gRPC1.3版本,不同版本接口可能发生变化):
```
GoogleAuthLibraryCallCredentials callCredentials =
new GoogleAuthLibraryCallCredentials(credentials);
blockingStub = GreeterGrpc.newBlockingStub(channel)
.withCallCredentials(callCredentials);
```
下面的代码示例用于在GCE环境中使用Google的OAuth2
```
ManagedChannel channel = ManagedChannelBuilder.forTarget(&quot;pubsub.googleapis.com&quot;)
.build();
GoogleCredentials creds = GoogleCredentials.getApplicationDefault();
creds = creds.createScoped(Arrays.asList(&quot;https://www.googleapis.com/auth/pubsub&quot;));
CallCredentials callCreds = MoreCallCredentials.from(creds);
PublisherGrpc.PublisherBlockingStub publisherStub =
PublisherGrpc.newBlockingStub(channel).withCallCredentials(callCreds);
publisherStub.publish(someMessage);
```
2.3. 自定义安全认证策略
参考Google内置的Credentials实现类实现自定义的Credentials可以扩展gRPC的鉴权策略Credentials的实现类如下所示
<img src="https://static001.geekbang.org/resource/image/bf/3c/bf248fabe09b968f020a7502748c093c.png" alt="" />
以OAuth2Credentials为例实现getRequestMetadata(URI uri)方法获取access token将其放入Metadata中通过CallCredentials将其添加到请求Header中发送到服务端代码示例如下GoogleAuthLibraryCallCredentials类
```
Map&lt;String, List&lt;String&gt;&gt; metadata = creds.getRequestMetadata(uri);
Metadata headers;
synchronized (GoogleAuthLibraryCallCredentials.this) {
if (lastMetadata == null || lastMetadata != metadata) {
lastMetadata = metadata;
lastHeaders = toHeaders(metadata);
}
headers = lastHeaders;
}
applier.apply(headers);
```
对于扩展方需要自定义Credentials实现getRequestMetadata(URI uri)方法由gRPC的CallCredentials将鉴权信息加入到HTTP Header中发送到服务端。
源代码下载地址:
链接: [https://github.com/geektime-geekbang/gRPC_LLF/tree/master](https://github.com/geektime-geekbang/gRPC_LLF/tree/master)

View File

@@ -0,0 +1,776 @@
# 1. 常用的序列化框架
当进行远程跨进程服务调用时,需要把被传输的数据结构/对象序列化为字节数组或者ByteBuffer。而当远程服务读取到ByteBuffer对象或者字节数组时需要将其反序列化为原始的数据结构/对象。利用序列化框架可以实现上述转换工作。
## 1.1 Java默认的序列化机制
Java序列化从JDK 1.1版本就已经提供它不需要添加额外的类库只需实现java.io.Serializable并生成序列ID即可因此它从诞生之初就得到了广泛的应用。
但是在远程服务调用RPC很少直接使用Java序列化进行消息的编解码和传输这又是什么原因呢下面通过分析Java序列化的缺点来找出答案
**缺点1**无法跨语言是Java序列化最致命的问题。对于跨进程的服务调用服务提供者可能会使用C++或者其他语言开发当我们需要和异构语言进程交互时Java序列化就难以胜任。
由于Java序列化技术是Java语言内部的私有协议其它语言并不支持对于用户来说它完全是黑盒。对于Java序列化后的字节数组别的语言无法进行反序列化这就严重阻碍了它的应用。
事实上目前几乎所有流行的Java RPC通信框架都没有使用Java序列化作为编解码框架原因就在于它无法跨语言而这些RPC框架往往需要支持跨语言调用。
**缺点2**相比于业界的一些序列化框架Java默认的序列化效能较低主要体现在序列化之后的字节数组体积较大性能较低。
在同等情况下编码后的字节数组越大存储的时候就越占空间存储的硬件成本就越高并且在网络传输时更占带宽导致系统的吞吐量降低。Java序列化后的码流偏大也一直被业界所诟病导致它的应用范围受到了很大限制。
## 1.2 Thrift序列化框架
Thrift源于Facebook在2007年Facebook将Thrift作为一个开源项目提交给Apache基金会。
对于当时的Facebook来说创造Thrift是为了解决Facebook各系统间大数据量的传输通信以及系统之间语言环境不同需要跨平台的特性因此Thrift可以支持多种程序语言如C++、Cocoa、Erlang、Haskell、Java、Ocami、Perl、PHP、Python、Ruby和Smalltalk。
在多种不同的语言之间通信Thrift可以作为高性能的通信中间件使用它支持数据对象序列化和多种类型的RPC服务。
Thrift适用于静态的数据交换需要先确定好它的数据结构当数据结构发生变化时必须重新编辑IDL文件生成代码和编译这一点跟其他IDL工具相比可以视为是Thrift的弱项。
Thrift适用于搭建大型数据交换及存储的通用工具对于大型系统中的内部数据传输相对于JSON和XML在性能和传输大小上都有明显的优势。
Thrift主要由5部分组成:
1. **语言系统以及IDL编译器**负责由用户给定的IDL文件生成相应语言的接口代码
1. **TProtocol**RPC的协议层可以选择多种不同的对象序列化方式如JSON和Binary
1. **TTransport**RPC的传输层同样可以选择不同的传输层实现如socket、NIO、MemoryBuffer等
1. **TProcessor**作为协议层和用户提供的服务实现之间的纽带,负责调用服务实现的接口;
1. **TServer**聚合TProtocol、TTransport和TProcessor等对象。
我们重点关注的是编解码框架与之对应的就是TProtocol。由于Thrift的RPC服务调用和编解码框架绑定在一起所以通常我们使用Thrift的时候会采取RPC框架的方式。
但是它的TProtocol编解码框架还是可以以类库的方式独立使用的。
与Protocol Buffers比较类似的是Thrift通过IDL描述接口和数据结构定义它支持8种Java基本类型、Map、Set和List支持可选和必选定义功能非常强大。因为可以定义数据结构中字段的顺序所以它也可以支持协议的前向兼容。
Thrift支持三种比较典型的编解码方式。
1. 通用的二进制编解码;
1. 压缩二进制编解码;
1. 优化的可选字段压缩编解码。
由于支持二进制压缩编解码Thrift的编解码性能表现也相当优异远远超过Java序列化和RMI等。
## 1.3 MessagePack序列化框架
MessagePack是一个高效的二进制序列化框架它像JSON一样支持不同语言间的数据交换但是它的性能更快序列化之后的码流也更小。
MessagePack提供了对多语言的支持官方支持的语言如下Java、Python、Ruby、Haskell、C#、OCaml、Lua、Go、C、C++等。
MessagePack的Java API非常简单如果使用MessagePack进行开发只需要导入MessagePack maven依赖
```
&lt;dependency&gt;
&lt;groupId&gt;org.msgpack&lt;/groupId&gt;
&lt;artifactId&gt;msgpack&lt;/artifactId&gt;
&lt;version&gt;${msgpack.version}&lt;/version&gt;
&lt;/dependency&gt;
```
它的API使用示例如下
```
List&lt;String&gt; src = new ArrayList&lt;String&gt;();
src.add(&quot;msgpack&quot;);
src.add(&quot;kumofs&quot;);
src.add(&quot;viver&quot;);
MessagePack msgpack = new MessagePack();
byte[] raw = msgpack.write(src);
List&lt;String&gt; dst1 =
msgpack.read(raw, Templates.tList(Templates.TString));
```
## 1.4 Protocol Buffers序列化框架
Google的Protocol Buffers在业界非常流行很多商业项目选择Protocol Buffers作为编解码框架当前最新的为Protocol Buffers v3版本它具有如下特点
- 在谷歌内部长期使用,产品成熟度高;
- 跨语言、支持多种语言包括C++、Java和Python
- 编码后的消息更小,更加有利于存储和传输;
- 编解码的性能非常高;
- 支持不同协议版本的前向兼容;
- 支持定义可选和必选字段。
Protocol Buffers是一个灵活、高效、结构化的数据序列化框架相比于XML等传统的序列化工具它更小、更快、更简单。
Protocol Buffers支持数据结构化一次可以到处使用甚至跨语言使用通过代码生成工具可以自动生成不同语言版本的源代码甚至可以在使用不同版本的数据结构进程间进行数据传递实现数据结构的前向兼容。
# 2. Protocol Buffers介绍
区别于ThriftProtocol Buffers是一个可独立使用的序列化框架它并不与gRPC框架绑定任何需要支持多语言的RPC框架都可以选择使用Protocol Buffers作为序列化框架。
Protocol Buffers的使用主要包括
- IDL文件定义*.proto, 包含数据结构定义以及可选的服务接口定义gRPC
- 各种语言的代码生成(含数据结构定义、以及序列化和反序列化接口);
- 使用Protocol Buffers的API进行序列化和反序列化。
## 2.1 支持的数据结构
Protocol Buffers提供了对主流语言的常用数据结构的支持考虑到跨语言特性因此对于特定语言的特定数据结构并不提供支持比较典型的如Java的Exception对象。
### 2.1.1 标量值类型(基本数据类型)
Protocol Buffers支持的标量值类型如下
<img src="https://static001.geekbang.org/resource/image/f8/bc/f8c6483084cce768f1deffc245c011bc.png" alt="" />
### 2.1.2 复杂数据类型
通过repeated关键字标识该字段可以重复任意次数等价于数组。Protocol Buffers支持枚举类型定义示例如下
```
message QueryInfo{
string queryID = 1;
enum Types{
USER = 0;
GROUP=1;
OTHERS=2;
}
Types type = 2;
}
```
proto3支持map类型的数据结构它的定义示例如下
```
map&lt;key_type, value_type&gt; map_field = N;
message ValueType{...}
map&lt;string, ValueType&gt; typeMap = 0;
```
对于map数据类型的约束如下
- 键、值类型可以是内置的基本类型也可以是自定义message类型
- 不要依赖键值的迭代顺序;
- 不支持repeated关键字
- 如果在解析序列化文件的时候出现多个Key的情况那么将会使用最后一个如果在解析文本文件的时候出现多个key那么将会报错。
如果类型不确定类似Java中的泛型可以使用proto3中的Any来表示任何类型的数据它的定义如下
```
message PramMap{
map&lt;String, google.protobuf.Any&gt; extentionTypes = 1;
}
```
通过pack()可以将任何message打包成Any类型代码如下Any类
```
public static &lt;T extends com.google.protobuf.Message&gt; Any pack(
T message) {
return Any.newBuilder()
.setTypeUrl(getTypeUrl(&quot;type.googleapis.com&quot;,
message.getDescriptorForType()))
.setValue(message.toByteString())
.build();
}
```
通过unpack()方法可以将message从Any类型中取出代码如下
```
public &lt;T extends com.google.protobuf.Message&gt; T unpack(
java.lang.Class&lt;T&gt; clazz)
throws com.google.protobuf.InvalidProtocolBufferException {
if (!is(clazz)) {
throw new com.google.protobuf.InvalidProtocolBufferException(
&quot;Type of the Any message does not match the given class.&quot;);
}
if (cachedUnpackValue != null) {
return (T) cachedUnpackValue;
}
T defaultInstance =
com.google.protobuf.Internal.getDefaultInstance(clazz);
T result = (T) defaultInstance.getParserForType()
.parseFrom(getValue());
cachedUnpackValue = result;
return result;
}
```
## 2.2 IDL文件定义
按照Protocol Buffers的语法在proto文件中定义RPC请求和响应的数据结构示例如下
```
syntax = &quot;proto3&quot;;
option java_package = &quot;io.grpc.examples.helloworld&quot;;
package helloworld;
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
```
其中syntax proto3表示使用v3版本的Protocol Buffersv3和v2版本语法上有较多的变更,使用的时候需要特别注意。java_package表示生成代码的存放路径包路径。通过message关键字来定义数据结构数据结构的语法为
数据类型 字段名称 = Tag(field的唯一标识符在一个message层次中是unique的。嵌套message可以重新开始。用来标识这些fields的二进制编码方式序列化以及解析的时候会用到)。
message是支持嵌套的即A message引用B message作为自己的field它表示的就是对象聚合关系即A对象聚合引用了B对象。
对于一些公共的数据结构例如公共Header可以通过单独定义公共数据结构proto文件然后导入的方式使用示例如下
```
import “/other_protofile.proto”
```
导入也支持级联引用即a.proto导入了b.proto,b.proto导入了c.proto则a.proto可以直接使用c.proto中定义的message。<br />
在实际项目开发时可以使用Protocol Buffers的IDEA/Eclipse插件对.proto文件的合法性进行校验
<img src="https://static001.geekbang.org/resource/image/78/40/7838e715e5135cdfc4da2abc0b406140.png" alt="" />
## 2.3 代码生成
基于.proto文件生成代码有两种方式
1. 单独下载protoc工具通过命令行生成代码
1. 通过Maven等构建工具配置protoc命令在打包/构建时生成代码。
通过protoc工具生成代码流程如下
第一步下载Protocol Buffers的Windows版本网址如下<br />
[http://code.google.com/p/protobuf/downloads/detail?name=protoc-2.5.0-win32.zip&amp;can=2&amp;q=](http://code.google.com/p/protobuf/downloads/detail?name=protoc-2.5.0-win32.zip&amp;can=2&amp;q=)
对下载的protoc-2.5.0-win32.zip进行解压如下所示
<img src="https://static001.geekbang.org/resource/image/ad/8b/ad08078632c4797cfd5add573474018b.png" alt="" />
第二步编写proto文件通过执行protoc命令生成代码
<img src="https://static001.geekbang.org/resource/image/d1/23/d161a27fe87aa4006018c1cf03fe0323.png" alt="" />
如果使用maven构建生成代码则需要在pom.xml中做如下配置
```
&lt;plugin&gt;
&lt;groupId&gt;org.xolstice.maven.plugins&lt;/groupId&gt;
&lt;artifactId&gt;protobuf-maven-plugin&lt;/artifactId&gt;
&lt;version&gt;0.5.0&lt;/version&gt;
&lt;configuration&gt;
&lt;protocArtifact&gt;com.google.protobuf:protoc:3.2.0:exe:${os.detected.classifier}&lt;/protocArtifact&gt;
&lt;pluginId&gt;grpc-java&lt;/pluginId&gt; &lt;pluginArtifact&gt;io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}&lt;/pluginArtifact&gt;
&lt;/configuration&gt;
&lt;executions&gt;
&lt;execution&gt;
&lt;goals&gt;
&lt;goal&gt;compile&lt;/goal&gt;
&lt;goal&gt;compile-custom&lt;/goal&gt;
&lt;/goals&gt;
&lt;/execution&gt;
&lt;/executions&gt;
&lt;/plugin&gt;
```
## 2.4 序列化和反序列化接口调用
### 2.4.1 原生用法
Protocol Buffers使用经典的Build模式来构建对象代码示例如下
```
HelloRequest request
= HelloRequest.newBuilder().setName(name).build();
```
完成对象设值之后,可以通过多种方式将对象转换成字节数组或者输出流,代码如下:
<img src="https://static001.geekbang.org/resource/image/6b/50/6bf713fb9c4e3e8cec3e99beed47f350.png" alt="" />
例如可以通过toByteArray将对象直接序列化为字节数组。<br />
反序列化时Protocol Buffers提供了多种接口用于将字节数组/输入流转换成原始对象,相关接口如下所示:
<img src="https://static001.geekbang.org/resource/image/1d/c3/1d6e70df86298716dc52fd5ca2c344c3.png" alt="" />
### 2.4.2 Netty中使用
Netty提供了对Protocol Buffers的支持在服务端和客户端创建时只需要将Protocol Buffers相关的CodeC Handler加入到ChannelPipeline中即可。
支持Protocol Buffers的Netty服务端创建示例如下
```
public void initChannel(SocketChannel ch) {
ch.pipeline().addLast(
new ProtobufVarint32FrameDecoder());
ch.pipeline().addLast(
new ProtobufDecoder(
SubscribeReqProto.SubscribeReq
.getDefaultInstance()));
ch.pipeline().addLast(
new ProtobufVarint32LengthFieldPrepender());
ch.pipeline().addLast(new ProtobufEncoder());
ch.pipeline().addLast(new SubReqServerHandler());
}
```
支持Protocol Buffers的Netty客户端创建示例如下
```
public void initChannel(SocketChannel ch)
throws Exception {
ch.pipeline().addLast(
new ProtobufVarint32FrameDecoder());
ch.pipeline().addLast(
new ProtobufDecoder(
SubscribeRespProto.SubscribeResp
.getDefaultInstance()));
ch.pipeline().addLast(
new ProtobufVarint32LengthFieldPrepender());
ch.pipeline().addLast(new ProtobufEncoder());
ch.pipeline().addLast(new SubReqClientHandler());
}
```
# 3. gRPC序列化原理分析
gRPC默认使用Protocol Buffers作为RPC序列化框架通过Protocol Buffers对消息进行序列化和反序列化然后通过Netty的HTTP/2以Stream的方式进行数据传输。
由于存在一些特殊的处理gRPC并没有直接使用Netty提供的Protocol Buffers Handler,而是自己集成Protocol Buffers工具类进行序列化和反序列化下面一起分析它的设计和实现原理。
## 3.1 客户端请求消息序列化
客户端通过Build模式构造请求消息然后通过同步/异步方式发起RPC调用gRPC框架负责客户端请求消息的序列化以及HTTP/2 Header和Body的构造然后通过Netty提供的HTTP/2协议栈将HTTP/2请求消息发送给服务端。
### 3.1.1. 数据流图
客户端请求消息的发送流程如下所示:
<img src="https://static001.geekbang.org/resource/image/79/04/79497e27b54667022f918ca1c05df004.png" alt="" />
请求消息的序列化主要包含5个步骤
1. **请求消息的构建:**使用Protocol Buffers生成的代码通过build模式对请求消息设值完成请求消息的初始化
1. **请求消息的序列化:**使用Protocol Buffers的Marshaller工具类对于生成的请求对象继承自com.google.protobuf.GeneratedMessageV3进行序列化生成ProtoInputStream
1. **请求消息的首次封装:**主要用于创建NettyClientStream、构造gRPC的HTTP/2消息头等
1. **请求消息的二次封装:**将序列化之后的请求消息封装成SendGrpcFrameCommand通过异步的方式由Netty的NIO线程执行消息的发送
1. gRPC的NettyClientHandler拦截到write的请求消息之后根据Command类型判断是业务消息发送调用Netty的Http2ConnectionEncoder由Netty的HTTP/2协议栈创建HTTP/2 Stream并最终发送给服务端。
### 3.1.2 工作原理与源码分析
调用ClientCallImpl的sendMessage发送请求消息ClientCallImpl类
```
public void sendMessage(ReqT message) {
Preconditions.checkState(stream != null, &quot;Not started&quot;);
Preconditions.checkState(!cancelCalled, &quot;call was cancelled&quot;);
Preconditions.checkState(!halfCloseCalled, &quot;call was half-closed&quot;);
try {
InputStream messageIs = method.streamRequest(message);
stream.writeMessage(messageIs);
...
```
实际上并未真正发送消息而是使用Protocol Buffers对消息做序列化ProtoLiteUtils类
```
return new PrototypeMarshaller&lt;T&gt;() {
@Override
public Class&lt;T&gt; getMessageClass() {
return (Class&lt;T&gt;) defaultInstance.getClass();
}
@Override
public T getMessagePrototype() {
return defaultInstance;
}
@Override
public InputStream stream(T value) {
return new ProtoInputStream(value, parser);
}
```
序列化完成之后调用ClientStream的writeMessage对请求消息进行封装DelayedStream类
```
public void writeMessage(final InputStream message) {
checkNotNull(message, &quot;message&quot;);
if (passThrough) {
realStream.writeMessage(message);
} else {
delayOrExecute(new Runnable() {
@Override
public void run() {
realStream.writeMessage(message);
}
});
}
}
```
根据序列化之后的消息长度更新HTTP/2 Header的content-lengthMessageFramer类
```
ByteBuffer header = ByteBuffer.wrap(headerScratch);
header.put(UNCOMPRESSED);
header.putInt(messageLength);
```
完成发送前的准备工作之后调用halfClose方法开始向HTTP/2协议栈发送消息DelayedStream类
```
public void halfClose() {
delayOrExecute(new Runnable() {
@Override
public void run() {
realStream.halfClose();
}
});
}
```
序列化之后的请求消息通过NettyClientStream的Sink被包装成SendGrpcFrameCommand加入到WriteQueue中异步发送NettyClientStream类
```
public void writeFrame(WritableBuffer frame, boolean endOfStream, boolean flush) {
ByteBuf bytebuf = frame == null ? EMPTY_BUFFER : ((NettyWritableBuffer) frame).bytebuf();
final int numBytes = bytebuf.readableBytes();
if (numBytes &gt; 0) {
onSendingBytes(numBytes);
writeQueue.enqueue(
new SendGrpcFrameCommand(transportState(), bytebuf, endOfStream),
channel.newPromise().addListener(new ChannelFutureListener() {
...
```
通过flush()将发送队列排队的SendGrpcFrameCommand写入到channel中(WriteQueue类)
```
private void flush() {
try {
QueuedCommand cmd;
int i = 0;
boolean flushedOnce = false;
while ((cmd = queue.poll()) != null) {
channel.write(cmd, cmd.promise());
if (++i == DEQUE_CHUNK_SIZE) {
i = 0;
channel.flush();
...
```
gRPC的NettyClientHandler拦截到发送消息对消息类型做判断NettyClientHandler类
```
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise)
throws Exception {
if (msg instanceof CreateStreamCommand) {
createStream((CreateStreamCommand) msg, promise);
} else if (msg instanceof SendGrpcFrameCommand) {
sendGrpcFrame(ctx, (SendGrpcFrameCommand) msg, promise);
...
```
调用sendGrpcFrame通过Netty提供的Http2ConnectionEncoder完成HTTP/2消息的发送
```
private void sendGrpcFrame(ChannelHandlerContext ctx, SendGrpcFrameCommand cmd,
ChannelPromise promise) {
encoder().writeData(ctx, cmd.streamId(), cmd.content(), 0, cmd.endStream(), promise);
}
```
### 3.1.3 线程模型
请求消息构建、请求消息序列化、请求消息封装都由客户端用户线程执行,示例如下:
<img src="https://static001.geekbang.org/resource/image/2b/72/2bf8139e6e36b254404c3b8b1afc9672.png" alt="" />
请求消息的发送由Netty的NIO线程执行示例如下Netty的EventLoopGroup
<img src="https://static001.geekbang.org/resource/image/b9/c6/b9c1567f89b58d49916a199cc5e0d5c6.png" alt="" />
## 3.2 服务端请求消息反序列化
服务端接收到客户端的HTTP/2请求消息之后由Netty HTTP/2协议栈的FrameListener.onDataRead方法调用gRPC的NettyServerHandler对请求消息进行解析和处理。
### 3.2.1 数据流图
服务端读取客户端请求消息并进行序列化的流程如下所示HTTP/2 Header的处理步骤省略
<img src="https://static001.geekbang.org/resource/image/13/51/136c6ceb74178b4205c305373f376951.png" alt="" />
### 3.2.2 工作原理与源码分析
Netty的HTTP/2监听器回调gRPC的NettyServerHandler通知gRPC处理HTTP/2消息NettyServerHandler类
```
private void onDataRead(int streamId, ByteBuf data, int padding, boolean endOfStream)
throws Http2Exception {
flowControlPing().onDataRead(data.readableBytes(), padding);
try {
NettyServerStream.TransportState stream = serverStream(requireHttp2Stream(streamId));
stream.inboundDataReceived(data, endOfStream);
...
```
如果流控校验通过则调用MessageDeframer处理请求消息AbstractStream2类
```
protected final void deframe(ReadableBuffer frame, boolean endOfStream) {
if (deframer.isClosed()) {
frame.close();
return;
}
try {
deframer.deframe(frame, endOfStream);
...
```
异步处理由gRPC的SerializingExecutor负责body的解析JumpToApplicationThreadServerStreamListener类
```
public void messageRead(final InputStream message) {
callExecutor.execute(new ContextRunnable(context) {
@Override
public void runInContext() {
try {
getListener().messageRead(message);
...
```
调用ProtoLiteUtils的Marshaller通过parse(InputStream stream)方法将NettyReadableBuffer反序列化为原始的请求消息代码如下ProtoLiteUtils类
```
public T parse(InputStream stream) {
if (stream instanceof ProtoInputStream) {
ProtoInputStream protoStream = (ProtoInputStream) stream;
if (protoStream.parser() == parser) {
try {
@SuppressWarnings(&quot;unchecked&quot;)
T message = (T) ((ProtoInputStream) stream).message();
return message;
...
```
### 3.2.3 线程模型
Netty HTTP/2消息的读取和校验等由Netty NIO线程负责示例如下
<img src="https://static001.geekbang.org/resource/image/cd/2a/cdc0c52c9bad874fbf9e7202f5dd5b2a.png" alt="" />
后续HTTP Body的反序列化则由gRPC的SerializingExecutor线程池完成
<img src="https://static001.geekbang.org/resource/image/a4/07/a40081a15ad9059e912c36466e28b807.png" alt="" />
## 3.3 服务端响应消息序列化
服务端接口调用完成之后需要将响应消息序列化然后通过HTTP/2 Stream与请求相同的Stream ID发送给客户端。
### 3.3.1 数据流图
服务端响应的发送流程如下所示:
<img src="https://static001.geekbang.org/resource/image/ce/0e/ce7b17e8b52d93e3923c8007e0703b0e.png" alt="" />
响应消息发送的主要步骤说明如下:
1. 服务端的接口实现类中调用responseObserver.onNext(reply),触发响应消息的发送流程;
1. 响应消息的序列化使用Protocol Buffers的Marshaller工具类对于生成的响应对象继承自com.google.protobuf.GeneratedMessageV3进行序列化生成ProtoInputStream
1. 对HTTP响应Header进行处理包括设置响应消息的content-length字段根据是否压缩标识对响应消息进行gzip压缩等
1. 对响应消息进行二次封装将序列化之后的响应消息封装成SendGrpcFrameCommand通过异步的方式由Netty的NIO线程执行消息的发送
1. gRPC的NettyServerHandler拦截到write的请求消息之后根据Command类型判断是业务消息发送调用Netty的Http2ConnectionEncoder由Netty的HTTP/2协议栈创建HTTP/2 Stream并最终发送给客户端。
### 3.3.2 工作原理与源码分析
调用onNext方法发送HTTP Header和响应ServerCallStreamObserverImpl类
```
public void onNext(RespT response) {
if (cancelled) {
throw Status.CANCELLED.asRuntimeException();
}
if (!sentHeaders) {
call.sendHeaders(new Metadata());
sentHeaders = true;
}
call.sendMessage(response);
}
```
在sendMessage中调用Marshaller<RespT>的streamResponse将响应消息通过Protocol Buffers进行序列化ServerCallImpl类
```
public void sendMessage(RespT message) {
checkState(sendHeadersCalled, &quot;sendHeaders has not been called&quot;);
checkState(!closeCalled, &quot;call is closed&quot;);
try {
InputStream resp = method.streamResponse(message);
stream.writeMessage(resp);
stream.flush();
...
```
完成序列化之后调用flush发送响应消息AbstractServerStream类
```
public final void deliverFrame(WritableBuffer frame, boolean endOfStream, boolean flush) {
abstractServerStreamSink().writeFrame(frame, endOfStream ? false : flush);
}
```
将封装之后的SendGrpcFrameCommand加入到writeQueue中异步发送NettyServerStream类
```
public void writeFrame(WritableBuffer frame, boolean flush) {
if (frame == null) {
writeQueue.scheduleFlush();
return;
}
ByteBuf bytebuf = ((NettyWritableBuffer) frame).bytebuf();
final int numBytes = bytebuf.readableBytes();
onSendingBytes(numBytes);
writeQueue.enqueue(
new SendGrpcFrameCommand(transportState(), bytebuf, false),...
```
NettyServerHandler拦截到响应消息之后根据Command进行判断调用sendGrpcFrame由Netty的Http2ConnectionEncoder负责将HTTP/2消息发送给客户端NettyServerHandler
```
private void sendGrpcFrame(ChannelHandlerContext ctx, SendGrpcFrameCommand cmd,
ChannelPromise promise) throws Http2Exception {
if (cmd.endStream()) {
closeStreamWhenDone(promise, cmd.streamId());
}
encoder().writeData(ctx, cmd.streamId(), cmd.content(), 0, cmd.endStream(), promise);
}
```
### 3.3.3 线程模型
响应消息的序列化、以及HTTP Header的初始化等操作由gRPC的SerializingExecutor线程池负责示例如下
<img src="https://static001.geekbang.org/resource/image/4a/8d/4ac00a380475a80367a119db4437858d.png" alt="" />
HTTP/2消息的编码以及后续发送由Netty的NIO线程池负责
<img src="https://static001.geekbang.org/resource/image/c1/31/c11b2ca71164e9fe96a25ae8a59fee31.png" alt="" />
## 3.4 客户端响应消息反序列化
客户端接收到服务端响应之后将HTTP/2 Body反序列化为原始的响应消息然后回调到客户端监听器驱动业务获取响应并继续执行。
### 3.4.1 数据流图
客户端接收响应的流程图如下所示:
<img src="https://static001.geekbang.org/resource/image/58/0b/587956281615406747c0d4ac959b450b.png" alt="" />
主要的处理流程分析如下:
1. 与服务端接收请求类似都是通过Netty HTTP/2协议栈的FrameListener监听并回调gRPC Handler此处是NettyClientHandler读取消息
1. 根据streamId可以获取Http2Stream通过Http2Stream的getProperty方法获取NettyClientStream
1. 调用MessageDeframer的deframe方法对响应消息体进行解析。客户端和服务端实现机制不同通过不同的Listener重载messageRead方法
1. 调用ClientStreamListenerImpl的messageRead进行线程切换将反序列化操作切换到gRPC工作线程或者客户端业务线程中同步阻塞调用
1. 调用Protocol Buffers的 Marshaller<RespT>对响应消息进行反序列化还原成原始的message对象。
### 3.4.2 工作原理与源码分析
客户端接收到响应消息之后根据streamId关联获取到NettyClientStreamNettyClientHandler类
```
private void onDataRead(int streamId, ByteBuf data, int padding, boolean endOfStream) {
flowControlPing().onDataRead(data.readableBytes(), padding);
NettyClientStream.TransportState stream = clientStream(requireHttp2Stream(streamId));
stream.transportDataReceived(data, endOfStream);
...
```
调用NettyClientStream的transportDataReceived将响应消息拷贝到NettyReadableBuffer进行后续处理NettyClientStream类
```
void transportDataReceived(ByteBuf frame, boolean endOfStream) {
transportDataReceived(new NettyReadableBuffer(frame.retain()), endOfStream);
}
```
通过MessageDeframer的processBody处理响应消息体MessageDeframer类
```
private void processBody() {
InputStream stream = compressedFlag ? getCompressedBody() : getUncompressedBody();
nextFrame = null;
listener.messageRead(stream);
state = State.HEADER;
requiredLength = HEADER_LENGTH;
}
```
对于客户端调用的是ClientStreamListenerImpl的messageRead方法代码如下ClientStreamListenerImpl类
```
public void messageRead(final InputStream message) {
class MessageRead extends ContextRunnable {
MessageRead() {
super(context);
}
@Override
public final void runInContext() {
try {
if (closed) {
return;
}
try {
observer.onMessage(method.parseResponse(message));
```
在此处完成了Netty NIO线程到gRPC工作线程被阻塞的业务线程的切换由切换之后的业务线程负责响应的反序列化代码如下ClientStreamListenerImpl类
```
try {
observer.onMessage(method.parseResponse(message));
} finally {
message.close();
}
```
调用MethodDescriptor的parseResponse
```
public RespT parseResponse(InputStream input) {
return responseMarshaller.parse(input);
}
```
源代码下载地址:
链接: [https://github.com/geektime-geekbang/gRPC_LLF/tree/master](https://github.com/geektime-geekbang/gRPC_LLF/tree/master)