你好,我是陶辉。

这一讲我们将以一个实战案例,基于前两讲提到的 HTTP/2 和 ProtoBuf 协议,看看 gRPC 如何将结构化消息编码为网络报文。

直接操作网络协议编程,容易让业务开发过程陷入复杂的网络处理细节。RPC 框架以编程语言中的本地函数调用形式,向应用开发者提供网络访问能力,这既封装了消息的编解码,也通过线程模型封装了多路复用,对业务开发很友好。

其中,Google 推出的 gRPC 是性能最好的 RPC 框架之一,它支持 Java、Javascript、Python、GoLang、C++、Object-C、Android、Ruby 等多种编程语言,还支持安全验证等特性,得到了广泛的应用,比如微服务中的 Envoy、分布式机器学习中的 Tensorflow,甚至华为去年推出重构互联网的 New IP 技术,都使用了 gRPC 框架。

然而,网络上教你使用 gRPC 框架的教程很多,却很少去谈 gRPC 是如何编码消息的。这样,一旦在大型分布式系统中出现疑难杂症,需要通过网络报文去定位问题发生在哪个系统、主机、进程中时,你就会毫无头绪。即使我们掌握了 HTTP/2 和 Protobuf 协议,但若不清楚 gRPC 的编码规则,还是无法分析抓取到的 gRPC 报文。而且,gRPC 支持单向、双向的流式 RPC 调用,编程相对复杂一些,定位流式 RPC 调用引发的 bug 时,更需要我们掌握 gRPC 的编码原理。

这一讲,我就将以 gRPC 官方提供的 example:data_transmisstion 为例,介绍 gRPC 的编码流程。在这一过程中,会顺带回顾 HTTP/2 和 Protobuf 协议,加深你对它们的理解。虽然这个示例使用的是 Python 语言,但基于 gRPC 框架,你可以轻松地将它们转换为其他编程语言。

如何使用 gRPC 框架实现远程调用?

我们先来简单地看下 gRPC 框架到底是什么。RPC 的全称是 Remote Procedure Call,即远程过程调用,它通过本地函数调用,封装了跨网络、跨平台、跨语言的服务访问,大大简化了应用层编程。其中,函数的入参是请求,而函数的返回值则是响应。

gRPC 就是一种 RPC 框架,在你定义好消息格式后,针对你选择的编程语言,gRPC 为客户端生成发起 RPC 请求的 Stub 类,以及为服务器生成处理 RPC 请求的 Service 类(服务器只需要继承、实现类中处理请求的函数即可)。如下图所示,很明显,gRPC 主要服务于面向对象的编程语言。

图片来源:https://grpc.io/

gRPC 支持 QUIC、HTTP/1 等多种协议,但鉴于 HTTP/2 协议性能好,应用场景又广泛,因此 HTTP/2 是 gRPC 的默认传输协议。gRPC 也支持 JSON 编码格式,但在忽略编码细节的 RPC 调用中,高效的 Protobuf 才是最佳选择!因此,这一讲仅基于 HTTP/2 和 Protobuf,介绍 gRPC 的用法。

gRPC 可以简单地分为三层,包括底层的数据传输层,中间的框架层(框架层又包括 C 语言实现的核心功能,以及上层的编程语言框架),以及最上层由框架层自动生成的 Stub 和 Service 类,如下图所示:

图片来源:https://platformlab.stanford.edu/Seminar%20Talks/gRPC.pdf

接下来我们以官网上的data_transmisstion 为例,先看看如何使用 gRPC。

构建 Python 语言的 gRPC 环境很简单,你可以参考官网上的QuickStart。

使用 gRPC 前,先要根据 Protobuf 语法,编写定义消息格式的 proto 文件。在这个例子中只有 1 种请求和 1 种响应,且它们很相似,各含有 1 个整型数字和 1 个字符串,如下所示:

package demo;

message Request {
int64 client_id = 1;
string request_data = 2;
}

message Response {
int64 server_id = 1;
string response_data = 2;
}

请注意,这里的包名 demo 以及字段序号 1、2,都与后续的 gRPC 报文分析相关。

接着定义 service,所有的 RPC 方法都要放置在 service 中,这里将它取名为 GRPCDemo。GRPCDemo 中有 4 个方法,后面 3 个流式访问的例子我们呆会再谈,先来看简单的一元访问模式 SimpleMethod 方法,它定义了 1 个请求对应 1 个响应的访问形式。其中,SimpleMethod 的参数 Request 是请求,返回值 Response 是响应。注意,分析报文时会用到这里的类名 GRPCDemo 以及方法名 SimpleMethod。

service GRPCDemo {
rpc SimpleMethod (Request) returns (Response);
}

用 grpc_tools 中的 protoc 命令,就可以针对刚刚定义的 service,生成含有 GRPCDemoStub 类和 GRPCDemoServicer 类的 demo_pb2_grpc.py 文件(实际上还包括完成 Protobuf 编解码的 demo_pb2.py),应用层将使用这两个类完成 RPC 访问。我简化了官网上的 Python 客户端代码,如下所示:

with grpc.insecure_channel(“localhost:23333”) as channel:
stub = demo_pb2_grpc.GRPCDemoStub(channel)
request = demo_pb2.Request(client_id=1,
request_data=“called by Python client”)
response = stub.SimpleMethod(request)

示例中客户端与服务器都在同一台机器上,通过 23333 端口访问。客户端通过 stub 对象的 SimpleMethod 方法完成了 RPC 访问。而服务器端的实现也很简单,只需要实现 GRPCDemoServicer 父类的 SimpleMethod 方法,返回 response 响应即可:

class DemoServer(demo_pb2_grpc.GRPCDemoServicer):
def SimpleMethod(self, request, context):
response = demo_pb2.Response(
server_id=1,
response_data=“Python server SimpleMethod Ok!!!!”)
return response

可见,gRPC 的开发效率非常高!接下来我们分析这次 RPC 调用中,消息是怎样编码的。

gRPC 消息是如何编码的?

**定位复杂的网络问题,都需要抓取、分析网络报文。**如果你在 Windows 上抓取网络报文,可以使用 Wireshark 工具(可参考《Web 协议详解与抓包实战》第 37 课),如果在 Linux 上抓包可以使用 tcpdump 工具(可参考第 87 课)。当然,你也可以从这里下载我抓取好的网络报文,用 Wireshark 打开它。需要注意,23333 不是 HTTP 常用的 80 或者 443 端口,所以 Wireshark 默认不会把它解析为 HTTP/2 协议。你需要鼠标右键点击报文,选择“解码为”(Decode as),将 23333 端口的报文设置为 HTTP/2 解码器,如下图所示:

图中蓝色方框中,TCP 连接的建立过程请参见[第 9 讲],而 HTTP/2 会话的建立可参见《Web 协议详解与抓包实战》第 52 课(还是比较简单的,如果你都清楚就可以直接略过)。我们重点看红色方框中的 gRPC 请求与响应,点开请求,可以看到下图中的信息:

先来分析蓝色方框中的 HTTP/2 头部。请求中有 2 个关键的 HTTP 头部,path 和 content-type,它们决定了 RPC 方法和具体的消息编码格式。path 的值为“/demo.GRPCDemo/SimpleMethod”,通过“/ 包名。服务名 / 方法名”的形式确定了 RPC 方法。content-type 的值为“application/grpc”,确定消息编码使用 Protobuf 格式。如果你对其他头部的含义感兴趣,可以看下这个文档,注意这里使用了 ABNF 元数据定义语言(如果你还不了解 ABNF,可以看下《Web 协议详解与抓包实战》第 4 课)。

HTTP/2 包体并不会直接存放 Protobuf 消息,而是先要添加 5 个字节的 Length-Prefixed Message 头部,其中用 4 个字节明确 Protobuf 消息的长度(1 个字节表示消息是否做过压缩),即上图中的桔色方框。为什么要多此一举呢?这是因为,gRPC 支持流式消息,即在 HTTP/2 的 1 条 Stream 中,通过 DATA 帧发送多个 gRPC 消息,而 Length-Prefixed Message 就可以将不同的消息分离开。关于流式消息,我们在介绍完一元模式后,再加以分析。

最后分析 Protobuf 消息,这里仅以 client_id 字段为例,对上一讲的内容做个回顾。在 proto 文件中 client_id 字段的序号为 1,因此首字节 00001000 中前 5 位表示序号为 1 的 client_id 字段,后 3 位表示字段的值类型是 varint 格式的数字,因此随后的字节 00000001 表示字段值为 1。序号为 2 的 request_data 字段请你结合上一讲的内容,试着做一下解析,看看字符串“called by Python client”是怎样编码的。

再来看服务器发回的响应,点开 Wireshark 中的响应报文后如下图所示:

其中 DATA 帧同样包括 Length-Prefixed Message 和 Protobuf,与 RPC 请求如出一辙,这里就不再赘述了,我们重点看下 HTTP/2 头部。你可能留意到,响应头部被拆成了 2 个部分,其中 grpc-status 和 grpc-message 是在 DATA 帧后发送的,这样就允许服务器在发送完消息后再给出错误码。关于 gRPC 的官方错误码以及 message 描述信息是如何取值的,你可以参考这个文档。

这种将部分 HTTP 头部放在包体后发送的技术叫做 Trailer,RFC7230 文档对此有详细的介绍。其中,RPC 请求中的 TE: trailers 头部,就说明客户端支持 Trailer 头部。在 RPC 响应中,grpc-status 头部都会放在最后发送,因此它的帧 flags 的 EndStream 标志位为 1。

可以看到,gRPC 中的 HTTP 头部与普通的 HTTP 请求完全一致,因此,它兼容当下互联网中各种七层负载均衡,这使得 gRPC 可以轻松地跨越公网使用。

gRPC 流模式的协议编码

说完一元模式,我们再来看流模式 RPC 调用的编码方式。

所谓流模式,是指 RPC 通讯的一方可以在 1 次 RPC 调用中,持续不断地发送消息,这对订阅、推送等场景很有用。流模式共有 3 种类型,包括客户端流模式、服务器端流模式,以及两端双向流模式。在data_transmisstion 官方示例中,对这 3 种流模式都定义了 RPC 方法,如下所示:

service GRPCDemo {
rpc ClientStreamingMethod (stream Request) returns Response);

rpc ServerStreamingMethod (Request) returns (stream Response);  

rpc BidirectionalStreamingMethod (stream Request) returns (stream Response);  

}

不同的编程语言处理流模式的代码很不一样,这里就不一一列举了,但通讯层的流模式消息编码是一样的,而且很简单。这是因为,HTTP/2 协议中每个 Stream 就是天然的 1 次 RPC 请求,每个 RPC 消息又已经通过 Length-Prefixed Message 头部确立了边界,这样,在 Stream 中连续地发送多个 DATA 帧,就可以实现流模式 RPC。我画了一张示意图,你可以对照它理解抓取到的流模式报文。

小结

这一讲介绍了 gRPC 怎样使用 HTTP/2 和 Protobuf 协议编码消息。

在定义好消息格式,以及 service 类中的 RPC 方法后,gRPC 框架可以为编程语言生成 Stub 和 Service 类,而类中的方法就封装了网络调用,其中方法的参数是请求,而方法的返回值则是响应。

发起 RPC 调用后,我们可以这么分析抓取到的网络报文。首先,分析应用层最外层的 HTTP/2 帧,根据 Stream ID 找出一次 RPC 调用。客户端 HTTP 头部的 path 字段指明了 service 和 RPC 方法名,而 content-type 则指明了消息的编码格式。服务器端的 HTTP 头部被分成 2 次发送,其中 DATA 帧发送完毕后,才会发送 grpc-status 头部,这样可以明确最终的错误码。

其次,分析包体时,可以通过 Stream 中 Length-Prefixed Message 头部,确认 DATA 帧中含有多少个消息,因此可以确定这是一元模式还是流式调用。在 Length-Prefixed Message 头部后,则是 Protobuf 消息,按照上一讲的内容进行分析即可。

思考题

最后,留给你一道练习题。gRPC 默认并不会压缩字符串,你可以通过在获取 channel 对象时加入 grpc.default_compression_algorithm 参数的形式,要求 gRPC 压缩消息,此时 Length-Prefixed Message 中 1 个字节的压缩位将会由 0 变为 1。你可以观察下执行压缩后的 gRPC 消息有何不同,欢迎你在留言区与大家一起探讨。

感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。