mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 14:43:42 +08:00
del
This commit is contained in:
573
极客时间专栏/geek/深入浅出gRPC/01 | gRPC 入门及服务端创建和调用原理.md
Normal file
573
极客时间专栏/geek/深入浅出gRPC/01 | gRPC 入门及服务端创建和调用原理.md
Normal 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、Apache(Facebook)的Thrift;
|
||||
1. 只支持特定语言的RPC框架,例如新浪微博的Motan;
|
||||
1. 支持服务治理等服务化特性的分布式服务框架,其底层内核仍然是RPC框架,例如阿里的Dubbo。
|
||||
|
||||
随着微服务的发展,基于语言中立性原则构建微服务,逐渐成为一种主流模式,例如对于后端并发处理要求高的微服务,比较适合采用Go语言构建,而对于前端的Web界面,则更适合Java和JavaScript。
|
||||
|
||||
因此,基于多语言的RPC框架来构建微服务,是一种比较好的技术选择。例如Netflix,API服务编排层和后端的微服务之间就采用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.0,Git地址如下:
|
||||
|
||||
[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. 序列化支持PB(Protocol Buffer)和JSON,PB是一种语言无关的高性能序列化框架,基于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<HelloReply> responseObserver) {
|
||||
HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + 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的线程池、内部的服务注册类(InternalHandlerRegistry)等,ServerImpl初始化完成之后,就可以调用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校验,此处必须是"application/grpc";
|
||||
</li>
|
||||
<li>
|
||||
从HTTP Header的URL中提取接口和方法名,以HelloWorldServer为例,它的method为:“helloworld.Greeter/SayHello”;
|
||||
</li>
|
||||
<li>
|
||||
<p>将Netty的HTTP Header转换成gRPC内部的Metadata,Metadata内部维护了一个键值对的二维数组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<?, ?> 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协议栈初始化代码如下所示(创建NettyServerHandler,NettyServerHandler类):
|
||||
|
||||
```
|
||||
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<
|
||||
io.grpc.examples.helloworld.HelloRequest,
|
||||
io.grpc.examples.helloworld.HelloReply>(
|
||||
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 > 0 && readRequiredBytes()) {
|
||||
switch (state) {
|
||||
case HEADER:
|
||||
processHeader();
|
||||
break;
|
||||
case BODY:
|
||||
processBody();
|
||||
pendingDeliveries--;
|
||||
break;
|
||||
default:
|
||||
throw new AssertionError("Invalid state: " + 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<ReqT>() {
|
||||
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<Resp> responseObserver) {
|
||||
switch (methodId) {
|
||||
case METHODID_SAY_HELLO:
|
||||
serviceImpl.sayHello((io.grpc.examples.helloworld.HelloRequest) request,
|
||||
(io.grpc.stub.StreamObserver<io.grpc.examples.helloworld.HelloReply>) 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>
|
||||
作为线程执行器可以执行普通的任务Task(Runnable)。
|
||||
</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)
|
||||
677
极客时间专栏/geek/深入浅出gRPC/02 | 客户端创建和调用原理.md
Normal file
677
极客时间专栏/geek/深入浅出gRPC/02 | 客户端创建和调用原理.md
Normal 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. 使用客户端Stub(GreeterBlockingStub)发起RPC调用,获取响应。
|
||||
|
||||
相关示例代码如下所示(HelloWorldClient类):
|
||||
|
||||
```
|
||||
HelloWorldClient(ManagedChannelBuilder<?> channelBuilder) {
|
||||
channel = channelBuilder.build();
|
||||
blockingStub = GreeterGrpc.newBlockingStub(channel);
|
||||
futureStub = GreeterGrpc.newFutureStub(channel);
|
||||
stub = GreeterGrpc.newStub(channel);
|
||||
}
|
||||
public void blockingGreet(String name) {
|
||||
logger.info("Will try to greet " + name + " ...");
|
||||
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. 对请求消息使用PB(Protobuf)做序列化,通过HTTP/2 Stream发送给gRPC服务端;
|
||||
1. 接收到服务端响应之后,使用PB(Protobuf)做反序列化;
|
||||
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实现类NettyChannelBuilder,NettyChannelBuilder提供了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表示(ALPN):0x68与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: <base64url encoding of HTTP/2 SETTINGS payload>
|
||||
|
||||
```
|
||||
|
||||
服务端接收到协商请求之后,如果不支持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扩展协议,以"h2"作为协议标识符。
|
||||
|
||||
下面我们以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模式通常支持L4(Transport)和L7(Application)层负载均衡,两者各有优缺点,可以根据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:**"RoundRobin"负载均衡策略。
|
||||
|
||||
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("Write called for unexpected type: " + 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来选择一个Subchannel(RoundRobinLoadBalancerFactory类):
|
||||
|
||||
```
|
||||
public PickResult pickSubchannel(PickSubchannelArgs args) {
|
||||
if (size > 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 >= 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, "Not started");
|
||||
Preconditions.checkState(!cancelCalled, "call was cancelled");
|
||||
Preconditions.checkState(!halfCloseCalled, "call was half-closed");
|
||||
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 >= 0 && written > maxOutboundMessageSize) {
|
||||
throw Status.INTERNAL
|
||||
.withDescription(
|
||||
String.format("message too large %d > %d", 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 > 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 > 0 && 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("No value received for unary call")
|
||||
.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)
|
||||
558
极客时间专栏/geek/深入浅出gRPC/03 | gRPC 线程模型分析.md
Normal file
558
极客时间专栏/geek/深入浅出gRPC/03 | gRPC 线程模型分析.md
Normal 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面临的一个链路需要一个线程处理的问题,通常会对它的线程模型进行优化,后端通过一个线程池来处理多个客户端的请求接入,形成客户端个数"M"与线程池最大线程数"N"的比例关系,其中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.3,Java的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或者AIO,IO模型在很大程度上决定了框架的性能;
|
||||
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, "serverListener");
|
||||
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个EventLoopGroup,1个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<Object> buf) throws Exception {
|
||||
SocketChannel ch = SocketUtils.accept(javaChannel());
|
||||
try {
|
||||
if (ch != null) {
|
||||
buf.add(new NioSocketChannel(this, ch));
|
||||
return 1;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**步骤4:**通过调用EventLoopGroup的next()获取一个EventLoop(NioEventLoop),用于处理网络I/O事件。
|
||||
|
||||
Netty线程模型的核心是NioEventLoop,它的职责如下:
|
||||
|
||||
1. 作为服务端Acceptor线程,负责处理客户端的请求接入
|
||||
1. 作为I/O线程,监听网络读操作位,负责从SocketChannel中读取报文
|
||||
1. 作为I/O线程,负责向SocketChannel写入报文发送给对方,如果发生写半包,会自动注册监听写事件,用于后续继续发送半包数据,直到数据全部发送完成
|
||||
1. 作为定时任务线程,可以执行定时任务,例如链路空闲检测和发送心跳消息等
|
||||
1. 作为线程执行器可以执行普通的任务线程(Runnable)NioEventLoop处理网络I/O操作的相关代码如下:
|
||||
|
||||
```
|
||||
try {
|
||||
int readyOps = k.readyOps();
|
||||
if ((readyOps & SelectionKey.OP_CONNECT) != 0) {
|
||||
int ops = k.interestOps();
|
||||
ops &= ~SelectionKey.OP_CONNECT;
|
||||
k.interestOps(ops);
|
||||
|
||||
unsafe.finishConnect();
|
||||
}
|
||||
if ((readyOps & SelectionKey.OP_WRITE) != 0) {
|
||||
ch.unsafe().forceFlush();
|
||||
}
|
||||
if ((readyOps & (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<Runnable> taskQueue;
|
||||
private volatile Thread thread;
|
||||
@SuppressWarnings("unused")
|
||||
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("task");
|
||||
}
|
||||
boolean inEventLoop = inEventLoop();
|
||||
if (inEventLoop) {
|
||||
addTask(task);
|
||||
} else {
|
||||
startThread();
|
||||
addTask(task);
|
||||
if (isShutdown() && removeTask(task)) {
|
||||
reject();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
除了SingleThreadEventExecutor,NioEventLoop同时实现了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<Runnable> runQueue = new ConcurrentLinkedQueue<Runnable>();
|
||||
|
||||
```
|
||||
|
||||
其中,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<?, ?> 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("Write called for unexpected type: " + 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<ChannelOption<?>, ?> entry : channelOptions.entrySet()) {
|
||||
b.option((ChannelOption<Object>) 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的多路复用器轮询连接操作结果,判断连接结果,如果或连接成功,重新设置监听位为READ(AbstractNioChannel类):
|
||||
|
||||
```
|
||||
protected void doBeginRead() throws Exception {
|
||||
final SelectionKey selectionKey = this.selectionKey;
|
||||
if (!selectionKey.isValid()) {
|
||||
return;
|
||||
}
|
||||
readPending = true;
|
||||
final int interestOps = selectionKey.interestOps();
|
||||
if ((interestOps & 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, "Not started");
|
||||
Preconditions.checkState(!cancelCalled, "call was cancelled");
|
||||
Preconditions.checkState(!halfCloseCalled, "call was half-closed");
|
||||
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("No value received for unary call")
|
||||
.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)
|
||||
706
极客时间专栏/geek/深入浅出gRPC/04 | gRPC 服务调用原理.md
Normal file
706
极客时间专栏/geek/深入浅出gRPC/04 | gRPC 服务调用原理.md
Normal 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<Object> 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<RespT> 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, "Runnable threw exception", 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<io.grpc.examples.helloworld.HelloReply>
|
||||
listenableFuture = futureStub.sayHello(request);
|
||||
Futures.addCallback(listenableFuture, new FutureCallback<HelloReply>() {
|
||||
@Override
|
||||
public void onSuccess(@Nullable HelloReply result) {
|
||||
logger.info("Greeting: " + result.getMessage());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
调用GreeterFutureStub的sayHello方法返回的不是应答,而是ListenableFuture,它继承自JDK的Future,接口定义如下:
|
||||
|
||||
```
|
||||
@GwtCompatible
|
||||
public interface ListenableFuture<V> extends Future<V>
|
||||
|
||||
```
|
||||
|
||||
将ListenableFuture加入到gRPC的Future列表中,创建一个新的FutureCallback对象,当ListenableFuture获取到响应之后,gRPC的DirectExecutor线程池会调用新创建的FutureCallback,执行onSuccess或者onFailure,实现异步回调通知。
|
||||
|
||||
接着我们分析下ListenableFuture的实现原理,ListenableFuture的具体实现类是GrpcFuture,代码如下(ClientCalls类):
|
||||
|
||||
```
|
||||
public static <ReqT, RespT> ListenableFuture<RespT> futureUnaryCall(
|
||||
ClientCall<ReqT, RespT> call,
|
||||
ReqT param) {
|
||||
GrpcFuture<RespT> responseFuture = new GrpcFuture<RespT>(call);
|
||||
asyncUnaryRequestCall(call, param, new UnaryStreamToFuture<RespT>(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 <V> V getUninterruptibly(Future<V> 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("Greeting: " + 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<io.grpc.examples.helloworld.HelloReply> responseObserver =
|
||||
new io.grpc.stub.StreamObserver<io.grpc.examples.helloworld.HelloReply>()
|
||||
{
|
||||
public void onNext(HelloReply value)
|
||||
{
|
||||
logger.info("Greeting: " + 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<io.grpc.examples.helloworld.HelloReply> responseObserver) {
|
||||
asyncUnaryCall(
|
||||
getChannel().newCall(METHOD_SAY_HELLO, getCallOptions()), request, responseObserver);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
下面分析下基于Reactive方式异步调用的代码实现,把响应StreamObserver对象作为入参传递到异步调用中,代码如下(ClientCalls类):
|
||||
|
||||
```
|
||||
private static <ReqT, RespT> void asyncUnaryRequestCall(
|
||||
ClientCall<ReqT, RespT> call, ReqT param, StreamObserver<RespT> responseObserver,
|
||||
boolean streamingResponse) {
|
||||
asyncUnaryRequestCall(call, param,
|
||||
new StreamObserverToCallListenerAdapter<ReqT, RespT>(call, responseObserver,
|
||||
new CallToStreamObserverAdapter<ReqT>(call),
|
||||
streamingResponse),
|
||||
streamingResponse);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当收到响应消息时,调用StreamObserver的onNext方法,代码如下(StreamObserverToCallListenerAdapter类):
|
||||
|
||||
```
|
||||
public void onMessage(RespT message) {
|
||||
if (firstResponseReceived && !streamingResponse) {
|
||||
throw Status.INTERNAL
|
||||
.withDescription("More than one responses received for unary or client-streaming call")
|
||||
.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<io.grpc.examples.routeguide.Feature> responseObserver) {
|
||||
asyncUnimplementedUnaryCall(METHOD_LIST_FEATURES, responseObserver);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
构造io.grpc.stub.StreamObserver<io.grpc.examples.routeguide.Feature> responseObserver,实现它的三个回调接口,注意由于是服务端streaming模式,所以它的onNext(Feature value)将会被回调多次,每次都代表一个响应,如果所有的响应都返回,则会调用onCompleted()方法。
|
||||
|
||||
### 3.2.2 客户端streaming
|
||||
|
||||
与服务端streaming类似,客户端发送多个请求,服务端返回一个响应,多用于汇聚和汇总计算场景,proto文件定义如下:
|
||||
|
||||
```
|
||||
rpc RecordRoute(stream Point) returns (RouteSummary) {}
|
||||
|
||||
```
|
||||
|
||||
业务调用代码示例如下(RouteGuideClient类):
|
||||
|
||||
```
|
||||
StreamObserver<Point> requestObserver = asyncStub.recordRoute(responseObserver);
|
||||
try {
|
||||
// Send numPoints points randomly selected from the features list.
|
||||
for (int i = 0; i < numPoints; ++i) {
|
||||
int index = random.nextInt(features.size());
|
||||
Point point = features.get(index).getLocation();
|
||||
info("Visiting point {0}, {1}", RouteGuideUtil.getLatitude(point),
|
||||
RouteGuideUtil.getLongitude(point));
|
||||
requestObserver.onNext(point);
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
异步服务调用获取请求StreamObserver对象,循环调用requestObserver.onNext(point),异步发送请求消息到服务端,发送完成之后,调用requestObserver.onCompleted(),通知服务端所有请求已经发送完成,可以接收服务端的响应了。
|
||||
|
||||
响应接收的代码如下所示:由于响应只有一个,所以onNext只会被调用一次(RouteGuideClient类):
|
||||
|
||||
```
|
||||
StreamObserver<RouteSummary> responseObserver = new StreamObserver<RouteSummary>() {
|
||||
@Override
|
||||
public void onNext(RouteSummary summary) {
|
||||
info("Finished trip with {0} points. Passed {1} features. "
|
||||
+ "Travelled {2} meters. It took {3} seconds.", summary.getPointCount(),
|
||||
summary.getFeatureCount(), summary.getDistance(), summary.getElapsedTime());
|
||||
if (testHelper != null) {
|
||||
testHelper.onMessage(summary);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
异步服务调用时,将响应StreamObserver实例作为参数传入,代码如下:
|
||||
|
||||
```
|
||||
StreamObserver<Point> 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<RouteNote> requestObserver =
|
||||
asyncStub.routeChat(new StreamObserver<RouteNote>() {
|
||||
@Override
|
||||
public void onNext(RouteNote note) {
|
||||
info("Got message \"{0}\" at {1}, {2}", 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("Sending message \"{0}\" at {1}, {2}", 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<T> extends ClientCallStreamObserver<T> {
|
||||
private boolean frozen;
|
||||
private final ClientCall<T, ?> call;
|
||||
private Runnable onReadyHandler;
|
||||
private boolean autoFlowControlEnabled = true;
|
||||
public CallToStreamObserverAdapter(ClientCall<T, ?> 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)
|
||||
691
极客时间专栏/geek/深入浅出gRPC/05 | gRPC 安全性设计.md
Normal file
691
极客时间专栏/geek/深入浅出gRPC/05 | gRPC 安全性设计.md
Normal 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和MAC,MD5可产生一个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, "sslContext");
|
||||
this.host = checkNotNull(host, "host");
|
||||
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:<path_to_alpn_boot_jar> ...
|
||||
|
||||
```
|
||||
|
||||
客户端代码示例如下:
|
||||
|
||||
```
|
||||
SSLContext sslContext = ...;
|
||||
final SSLSocket sslSocket = (SSLSocket)context.getSocketFactory().createSocket("localhost", server.getLocalPort());
|
||||
ALPN.put(sslSocket, new ALPN.ClientProvider()
|
||||
{
|
||||
public boolean supports()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
public List<String> protocols()
|
||||
{
|
||||
return Arrays.asList("h2", "http/1.1");
|
||||
}
|
||||
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<String> 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配置如下:
|
||||
|
||||
```
|
||||
<project>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-tcnative-boringssl-static</artifactId>
|
||||
<version>2.0.6.Final</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
```
|
||||
|
||||
使用Dynamically Linked (netty-tcnative)的相关约束如下:
|
||||
|
||||
1.
|
||||
|
||||
```
|
||||
OpenSSL version >= 1.0.2 for ALPN
|
||||
|
||||
```
|
||||
|
||||
或者
|
||||
|
||||
```
|
||||
version >= 1.0.1 for NPN
|
||||
|
||||
```
|
||||
|
||||
1. 类路径中包含
|
||||
|
||||
```
|
||||
netty-tcnative version >= 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代码示例
|
||||
|
||||
以基于JDK(Jetty-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 file(PEM格式),代码如下(SslContextBuilder类):
|
||||
|
||||
```
|
||||
public static SslContextBuilder forServer(File keyCertChainFile, File keyFile) {
|
||||
return new SslContextBuilder(true).keyManager(keyCertChainFile, keyFile);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Netty的SslContext加载keyCertChainFile和private key file(SslContextBuilder类):
|
||||
|
||||
```
|
||||
X509Certificate[] keyCertChain;
|
||||
PrivateKey key;
|
||||
try {
|
||||
keyCertChain = SslContext.toX509Certificates(keyCertChainFile);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("File does not contain valid certificates: " + 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<Object> out) throws SSLException {
|
||||
final int startOffset = in.readerIndex();
|
||||
final int endOffset = in.writerIndex();
|
||||
int offset = startOffset;
|
||||
int totalLength = 0;
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
对半包标识进行判断,如果上一个消息是半包消息,则判断当前可读的字节数是否小于整包消息的长度,如果小于整包长度,则说明本次读取操作仍然没有把SSL整包消息读取完整,需要返回I/O线程继续读取,代码如下:
|
||||
|
||||
```
|
||||
if (packetLength > 0) {
|
||||
if (endOffset - startOffset < packetLength) {
|
||||
return;
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
如果消息读取完整,则修改偏移量:同时置位半包长度标识:
|
||||
|
||||
```
|
||||
} else {
|
||||
offset += packetLength;
|
||||
totalLength = packetLength;
|
||||
packetLength = 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
下面在for循环中读取SSL消息,一个ByteBuf可能包含多条完整的SSL消息。首先判断可读的字节数是否小于协议消息头长度,如果是则退出循环继续由I/O线程接收后续的报文:
|
||||
|
||||
```
|
||||
if (readableBytes < 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 > 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 > 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("pubsub.googleapis.com")
|
||||
.build();
|
||||
GoogleCredentials creds = GoogleCredentials.getApplicationDefault();
|
||||
creds = creds.createScoped(Arrays.asList("https://www.googleapis.com/auth/pubsub"));
|
||||
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<String, List<String>> 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)
|
||||
776
极客时间专栏/geek/深入浅出gRPC/06 | gRPC 序列化机制.md
Normal file
776
极客时间专栏/geek/深入浅出gRPC/06 | gRPC 序列化机制.md
Normal 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依赖:
|
||||
|
||||
```
|
||||
<dependency>
|
||||
<groupId>org.msgpack</groupId>
|
||||
<artifactId>msgpack</artifactId>
|
||||
<version>${msgpack.version}</version>
|
||||
</dependency>
|
||||
|
||||
```
|
||||
|
||||
它的API使用示例如下:
|
||||
|
||||
```
|
||||
List<String> src = new ArrayList<String>();
|
||||
src.add("msgpack");
|
||||
src.add("kumofs");
|
||||
src.add("viver");
|
||||
MessagePack msgpack = new MessagePack();
|
||||
byte[] raw = msgpack.write(src);
|
||||
List<String> 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介绍
|
||||
|
||||
区别于Thrift,Protocol 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<key_type, value_type> map_field = N;
|
||||
message ValueType{...}
|
||||
map<string, ValueType> typeMap = 0;
|
||||
|
||||
```
|
||||
|
||||
对于map数据类型的约束如下:
|
||||
|
||||
- 键、值类型可以是内置的基本类型,也可以是自定义message类型;
|
||||
- 不要依赖键值的迭代顺序;
|
||||
- 不支持repeated关键字;
|
||||
- 如果在解析序列化文件的时候出现多个Key的情况,那么将会使用最后一个;如果在解析文本文件的时候出现多个key,那么将会报错。
|
||||
|
||||
如果类型不确定,类似Java中的泛型,可以使用proto3中的Any来表示任何类型的数据,它的定义如下:
|
||||
|
||||
```
|
||||
message PramMap{
|
||||
map<String, google.protobuf.Any> extentionTypes = 1;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过pack()可以将任何message打包成Any类型,代码如下(Any类):
|
||||
|
||||
```
|
||||
public static <T extends com.google.protobuf.Message> Any pack(
|
||||
T message) {
|
||||
return Any.newBuilder()
|
||||
.setTypeUrl(getTypeUrl("type.googleapis.com",
|
||||
message.getDescriptorForType()))
|
||||
.setValue(message.toByteString())
|
||||
.build();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过unpack()方法,可以将message从Any类型中取出,代码如下:
|
||||
|
||||
```
|
||||
public <T extends com.google.protobuf.Message> T unpack(
|
||||
java.lang.Class<T> clazz)
|
||||
throws com.google.protobuf.InvalidProtocolBufferException {
|
||||
if (!is(clazz)) {
|
||||
throw new com.google.protobuf.InvalidProtocolBufferException(
|
||||
"Type of the Any message does not match the given class.");
|
||||
}
|
||||
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 = "proto3";
|
||||
option java_package = "io.grpc.examples.helloworld";
|
||||
package helloworld;
|
||||
message HelloRequest {
|
||||
string name = 1;
|
||||
}
|
||||
message HelloReply {
|
||||
string message = 1;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其中,syntax proto3表示使用v3版本的Protocol Buffers,v3和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&can=2&q=](http://code.google.com/p/protobuf/downloads/detail?name=protoc-2.5.0-win32.zip&can=2&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中做如下配置:
|
||||
|
||||
```
|
||||
<plugin>
|
||||
<groupId>org.xolstice.maven.plugins</groupId>
|
||||
<artifactId>protobuf-maven-plugin</artifactId>
|
||||
<version>0.5.0</version>
|
||||
<configuration>
|
||||
<protocArtifact>com.google.protobuf:protoc:3.2.0:exe:${os.detected.classifier}</protocArtifact>
|
||||
<pluginId>grpc-java</pluginId> <pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}</pluginArtifact>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>compile</goal>
|
||||
<goal>compile-custom</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
```
|
||||
|
||||
## 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, "Not started");
|
||||
Preconditions.checkState(!cancelCalled, "call was cancelled");
|
||||
Preconditions.checkState(!halfCloseCalled, "call was half-closed");
|
||||
try {
|
||||
InputStream messageIs = method.streamRequest(message);
|
||||
stream.writeMessage(messageIs);
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
实际上并未真正发送消息,而是使用Protocol Buffers对消息做序列化(ProtoLiteUtils类):
|
||||
|
||||
```
|
||||
return new PrototypeMarshaller<T>() {
|
||||
@Override
|
||||
public Class<T> getMessageClass() {
|
||||
return (Class<T>) 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, "message");
|
||||
if (passThrough) {
|
||||
realStream.writeMessage(message);
|
||||
} else {
|
||||
delayOrExecute(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
realStream.writeMessage(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
根据序列化之后的消息长度,更新HTTP/2 Header的content-length(MessageFramer类):
|
||||
|
||||
```
|
||||
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 > 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("unchecked")
|
||||
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, "sendHeaders has not been called");
|
||||
checkState(!closeCalled, "call is closed");
|
||||
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关联获取到NettyClientStream(NettyClientHandler类):、
|
||||
|
||||
```
|
||||
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)
|
||||
Reference in New Issue
Block a user