gRPC 数据包传输格式解析:从 Protobuf 到 HTTP/2
在使用 RPC 进行通信时,我们平时写的代码可能只是调用一个远程函数,例如 GetUser(),但真正传输到网络中的并不是函数名和参数对象本身,而是一段按照规则组织好的二进制数据
以 gRPC 为例,它的完整传输链路大致是:
text
业务对象
↓
Protobuf 二进制编码
↓
gRPC Message 封装
↓
HTTP/2 Frame 封装
↓
TCP / TLS / IP 传输
也就是说,Protobuf 负责把对象压缩成二进制,gRPC 负责在 Protobuf 数据前面加上自己的消息头,HTTP/2 负责把 gRPC 消息放进不同的 Frame 中传输
一、先定义一个 proto 文件
假设有这样一个 proto 文件:
proto
syntax = "proto3";
package demo;
service UserService {
rpc GetUser(GetUserRequest) returns (UserReply);
}
message GetUserRequest {
int32 id = 1;
string name = 2;
}
message UserReply {
int32 id = 1;
string name = 2;
int32 age = 3;
}
现在客户端要调用:
text
GetUserRequest {
id: 150
name: "abc"
}
我们重点分析这个对象在网络中到底会变成什么样
二、Protobuf 层:对象如何变成二进制
Protobuf 编码时,不会传字段名,比如不会传 id、name 这些字符串,而是传:
text
字段编号 + 字段类型 + 字段值
准确来说,字段编号和字段类型会合并成一个 tag
计算方式是:
text
tag = 字段编号 << 3 | wire_type
其中 wire_type 表示字段在二进制中的编码类型
常见的 wire type 如下:
| wire type | 名称 | 含义 | 常见字段类型 |
|---|---|---|---|
| 0 | Varint | 变长整数编码 | int32、int64、uint32、bool、enum |
| 1 | 64-bit | 固定 8 字节 | fixed64、double |
| 2 | Length-delimited | 长度 + 数据内容 | string、bytes、嵌套 message、packed repeated |
| 5 | 32-bit | 固定 4 字节 | fixed32、float |
其中 int32 使用 Varint 编码,string 使用 Length-delimited 编码
1、字段 id = 150 的编码
字段定义是:
proto
int32 id = 1;
字段编号是 1,int32 对应的 wire_type 是 0
所以 tag 是:
text
1 << 3 | 0 = 8
十六进制表示为:
text
08
接下来编码字段值 150
150 使用 Varint 编码,Varint 的核心思想是:每 7 bit 存一组,最高位用来表示后面是否还有数据
150 的二进制可以理解为:
text
1001 0110
按 7 位一组拆分后,再低位组放前面,高位组放后面,最后设置最高位标记,得到:
text
96 01
所以 id = 150 的完整 Protobuf 编码是:
text
08 96 01
其中:
text
08 // tag,表示字段编号 1,类型 Varint
96 01 // 字段值 150 的 Varint 编码
2、字段 name = "abc" 的编码
字段定义是:
proto
string name = 2;
字段编号是 2,string 对应的 wire_type 是 2,所以 tag 是:
text
2 << 3 | 2 = 18
十六进制表示为:
text
12
string 是 Length-delimited 类型,也就是:
text
长度 + 内容
字符串 "abc" 长度是 3,内容对应的 ASCII 十六进制是:
text
a = 61
b = 62
c = 63
所以 name = "abc" 的完整编码是:
text
12 03 61 62 63
其中:
text
12 // tag,表示字段编号 2,类型 Length-delimited
03 // 字符串长度为 3
61 62 63 // 字符串 abc 的内容
3、完整 Protobuf 数据
现在这个对象:
text
GetUserRequest {
id: 150
name: "abc"
}
最终会被 Protobuf 编码成:
text
08 96 01 12 03 61 62 63
拆开看就是:
text
08 96 01 // id = 150
12 03 61 62 63 // name = "abc"
总长度是 8 字节
三、gRPC 层:在 Protobuf 前面加 5 字节头部
gRPC 不会直接把裸 Protobuf 数据交给 HTTP/2,而是会在 Protobuf 数据前面加上一个 5 字节的 gRPC 头部
格式是:
text
1 字节压缩标志位 + 4 字节消息体长度 + Protobuf 数据
也就是:
text
+----------------------+----------------------+
| compressed flag | message length |
| 1 byte | 4 bytes |
+----------------------+----------------------+
| protobuf payload |
+---------------------------------------------+
1、压缩标志位
第 1 字节表示后面的 Protobuf payload 是否被压缩
text
00:没有压缩
01:已经压缩
注意,这个字节只表示"是否压缩",不表示"使用什么压缩算法",真正的压缩算法会放在 HTTP/2 的 header 里,例如:
text
grpc-encoding: gzip
如果压缩位是 01,服务端就会根据 grpc-encoding 指定的算法先解压,再把解压后的数据交给 Protobuf 反序列化
处理流程是:
text
收到 gRPC message
↓
检查 compressed flag
↓
如果是 1,根据 grpc-encoding 解压
↓
得到原始 Protobuf 二进制
↓
Protobuf 反序列化成业务对象
2、消息体长度
后面 4 字节表示 Protobuf payload 的长度,使用大端序,刚才的 Protobuf 数据是:
text
08 96 01 12 03 61 62 63
长度是 8 字节,所以 gRPC 的长度字段是:
text
00 00 00 08
3、完整 gRPC Message
由于这里不压缩,所以压缩标志位是:
text
00
Protobuf 长度是:
text
00 00 00 08
Protobuf 数据是:
text
08 96 01 12 03 61 62 63
所以完整 gRPC Message 是:
text
00 00 00 00 08 08 96 01 12 03 61 62 63
拆开看:
text
00 // compressed flag,表示没有压缩
00 00 00 08 // Protobuf payload 长度为 8 字节
08 96 01 // Protobuf: id = 150
12 03 61 62 63 // Protobuf: name = "abc"
这就是 gRPC 层真正要交给 HTTP/2 DATA Frame 的数据
四、HTTP/2 层:gRPC 基于 HTTP/2 传输
一次 gRPC 调用,本质上是一次 HTTP/2 请求
HTTP/2 不像 HTTP/1.1 那样使用纯文本格式传输,而是使用二进制 Frame
对于一次普通的 gRPC unary 调用,通常会涉及:
text
HEADERS Frame
DATA Frame
Trailers
HEADERS Frame 负责传请求头,比如调用哪个服务、哪个方法,DATA Frame 负责传真正的 gRPC Message,Trailers 负责传 gRPC 调用状态
1、HEADERS Frame:说明调用哪个 RPC 方法
逻辑上,gRPC 请求头大概可以理解为:
text
:method: POST
:scheme: http
:path: /demo.UserService/GetUser
:authority: 127.0.0.1:50051
content-type: application/grpc
te: trailers
grpc-encoding: identity
grpc-accept-encoding: gzip
其中最重要的是:
text
:path: /demo.UserService/GetUser
它表示当前 RPC 调用的是:
text
demo 包下的 UserService 服务中的 GetUser 方法
注意,这些 header 在 HTTP/2 中并不是直接以明文字符串传输的,而是会经过 HPACK 压缩,这里只是为了方便理解
2、DATA Frame:真正装载 gRPC Message
HTTP/2 中每个 Frame 都有 9 字节头部
格式是:
text
+-----------------------------------------------+
| Length: 24 bits |
+---------------+-------------------------------+
| Type: 8 bits | Flags: 8 bits |
+-----------------------------------------------+
| Reserved: 1 bit | Stream Identifier: 31 bits |
+-----------------------------------------------+
| Payload ... |
+-----------------------------------------------+
也就是:
text
Length 3 字节
Type 1 字节
Flags 1 字节
Reserved + StreamID 4 字节
Payload 真实数据
对于 DATA Frame 来说,Payload 里面放的就是刚才的 gRPC Message
五、HTTP/2 DATA Frame 头部字段含义
1、Length
Length 占 24 bit,也就是 3 字节,表示当前 Frame 的 payload 长度
这里要注意,Length 表示的是 HTTP/2 Frame 的 payload 长度,不包括 HTTP/2 自己的 9 字节头部
在我们的例子中,gRPC Message 是:
text
00 00 00 00 08 08 96 01 12 03 61 62 63
长度是:
text
5 字节 gRPC 头部 + 8 字节 Protobuf 数据 = 13 字节
所以 HTTP/2 DATA Frame 的 Length 是:
text
00 00 0d
0d 是十六进制,表示十进制的 13
2、Type
Type 占 1 字节,表示当前 Frame 的类型,常见类型有:
text
0x0:DATA
0x1:HEADERS
0x4:SETTINGS
0x6:PING
0x7:GOAWAY
0x8:WINDOW_UPDATE
在我们的例子中,当前 Frame 是 DATA Frame,所以 Type 是:
text
00
3、Flags
Flags 占 1 字节,表示当前 Frame 的附加标志,不同类型的 Frame,Flags 的含义不同
对于 DATA Frame,常见的 flag 有:
text
0x1:END_STREAM
0x8:PADDED
END_STREAM 表示当前发送方向的数据已经结束
对于普通 unary gRPC 请求来说,客户端通常只发送一个请求消息,发送完这个 DATA Frame 后,请求体就结束了,所以可以带上 END_STREAM
因此这里 flags 是:
text
01
表示:
text
这个 DATA Frame 发送完后,客户端不会继续向这个 stream 发送请求体数据
4、Reserved
Reserved 是保留位,占 1 bit,位于最后 4 字节的最高位
正常情况下它是:
text
0
它目前没有业务含义,只是 HTTP/2 协议预留出来的位
5、Stream Identifier
Stream Identifier 占 31 bit,表示当前 Frame 属于哪个 HTTP/2 stream
HTTP/2 支持在一个 TCP 连接上同时传输多个 stream,也就是多路复用
例如同一个连接上可以同时有多个 RPC 调用:
text
stream 1:/demo.UserService/GetUser
stream 3:/demo.FileService/Upload
stream 5:/demo.ChatService/SendMessage
这些 Frame 可以交错发送,接收方根据 stream identifier 判断每个 Frame 属于哪一次 RPC 调用
客户端主动发起的 stream 通常使用奇数编号,例如:
text
1, 3, 5, 7
服务端主动发起的 stream 通常使用偶数编号,例如:
text
2, 4, 6, 8
在我们的例子中,stream id 假设为 1,所以最后 4 字节是:
text
00 00 00 01
这里最高位的 Reserved 是 0,剩下的 31 bit 表示 stream id 是 1
六、完整 DATA Frame 示例
现在把前面的内容合起来
Protobuf 数据是:
text
08 96 01 12 03 61 62 63
gRPC Message 是:
text
00 00 00 00 08 08 96 01 12 03 61 62 63
HTTP/2 DATA Frame 头部是:
text
00 00 0d // Length = 13
00 // Type = DATA
01 // Flags = END_STREAM
00 00 00 01 // Reserved = 0,Stream Identifier = 1
所以完整的 HTTP/2 DATA Frame 可以表示为:
text
00 00 0d 00 01 00 00 00 01
00 00 00 00 08 08 96 01 12 03 61 62 63
拆开看:
text
00 00 0d // HTTP/2 DATA payload 长度 = 13
00 // HTTP/2 frame type = DATA
01 // END_STREAM
00 00 00 01 // stream id = 1
00 // gRPC compressed flag = 0,表示没有压缩
00 00 00 08 // gRPC message length = 8
08 96 01 // Protobuf: id = 150
12 03 61 62 63 // Protobuf: name = "abc"
这一段就是一次 gRPC 请求中,DATA Frame 部分的核心数据组织方式
七、如果开启压缩会怎样
假设还是这个 Protobuf 数据:
text
08 96 01 12 03 61 62 63
如果不开启压缩,gRPC Message 是:
text
00 00 00 00 08 08 96 01 12 03 61 62 63
如果开启 gzip 压缩,那么流程会变成:
text
Protobuf 原始数据
↓
gzip 压缩
↓
压缩后的 payload
↓
gRPC 头部 compressed flag 设置为 1
此时 gRPC Message 大概是:
text
01 00 00 00 xx <gzip 压缩后的数据>
其中:
text
01 // compressed flag = 1
00 00 00 xx // 压缩后的 payload 长度
<gzip压缩后的数据> // 不是原始 Protobuf,而是压缩后的 Protobuf
同时 HTTP/2 header 里会带上类似:
text
grpc-encoding: gzip
服务端收到后,不会直接把 payload 交给 Protobuf,而是先根据 grpc-encoding 解压,再得到原始 Protobuf 数据,最后反序列化成对象
所以 gRPC 的压缩不是对整个连接压缩,而是对每个 gRPC Message 的 payload 压缩,尤其在流式 RPC 中,一个 stream 里可能有多个 message,每个 message 前面都有自己的 5 字节 gRPC 头部,也就可以分别标记是否压缩
八、一次完整 gRPC 调用可以这样理解
一次普通的 gRPC unary 调用大致是:
text
客户端发送 HEADERS Frame
:method = POST
:path = /demo.UserService/GetUser
content-type = application/grpc
客户端发送 DATA Frame
HTTP/2 Frame Header
Length
Type
Flags
Stream Identifier
gRPC Message
compressed flag
message length
protobuf payload
服务端返回 HEADERS Frame
:status = 200
content-type = application/grpc
服务端返回 DATA Frame
gRPC Message
compressed flag
message length
protobuf payload
服务端返回 Trailers
grpc-status = 0
grpc-message = ""
这里还要注意一点,HTTP 状态码 200 不一定代表 RPC 业务成功,gRPC 真正的调用结果主要看 trailers 里的:
text
grpc-status
比如:
text
grpc-status: 0
表示 RPC 调用成功