Hello gRPC:protobuf 序列化 & gRPC 传输协议
gRPC 是一个高性能、通用的开源RPC框架,其由 Google 主要面向移动应用开发并基于HTTP/2 协议标准而设计,基于 ProtoBuf(Protocol Buffers) 序列化协议开发,且支持众多开发语言。
除了 protobuf 序列化,gRPC 本身的传输协议也值得研究下。
Hello Protobuf
protobuf 序列化协议是 grpc 的核心内容之一,它是一种压缩率很高的序列化协议,以二进制而非json那样的文本格式存储,牺牲可读性以换取更高的传输和编解码效率。
了解 proto 编码细节,可以先拉一个官方的解码小工具,可以用人眼看到二进制数据包含的信息:
go install github.com/protocolbuffers/protoscope/cmd/protoscope...@latest
protobuf 编码的官方定义可以在这里找到:Encoding | Protocol Buffers Documentation (protobuf.dev)
可变长整数 varint
可变长整数是一种表示64位以内整数的方法,占用1-10字节。
varint的编码方式比较好理解,每个字节(8位)的第一个位用来表示下个字节还有没有内容。
例如,1表示为00000001
;150表示为10010110 00000001
:
10010110 00000001 // Original inputs.
0010110 0000001 // Drop continuation bits.
0000001 0010110 // Convert to big-endian.
00000010010110 // Concatenate.
128 + 16 + 4 + 2 = 150 // Interpret as an unsigned 64-bit integer.
10010110 00000001
换成十六进制就是9601
。
message 结构
protobuf 的自定义数据结构叫 message,由一系列k-v对组成。对于 message 的字段来说,protobuf 使用 字段id-值的形式表示。例如:
message Test1 {
optional int32 a = 1;
}
对于a这个字段,如果a的值是150,键值对就是 1: 150
对字段进行编码时,protobuf 会把每个键值对转换成由字段id、数据类型和载荷三个部分。
其中数据类型如下:
ID | Name | Used For |
---|---|---|
0 | VARINT | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | I64 | fixed64, sfixed64, double |
2 | LEN | string, bytes, embedded messages, packed repeated fields |
3 | SGROUP | group start (deprecated) |
4 | EGROUP | group end (deprecated) |
5 | I32 | fixed32, sfixed32, float |
protobuf 用一个 varint 来表示数据类型和字段id,其中低3位表示数据类型,剩下的位表示字段id。
对于上面的例子,数据类型是0,字段id是1,所以这个标志整数为 1000
,换成varint就是 08
(一个字节,两个十六进制位)。于是我们就得到了这个字段的十六进制编码 089601
。
使用 protoscope 工具验证下正确性:
$ xxd -r -ps <<< '089601' | protoscope
1: 150
对于有多个字段的 message,只用在前一个字段后面直接 append 上下一个字段的字节就好。
其他整数
对于bool 和 enum,他们被直接视为i32编码;
对于有符号数,一些比较大的负数(-2,-1)如果用补码的varint表示会占用很多空间(填充1),所以 protobuf 引入了 zigzag 编码方式来处理他们,将正数和负数依次交叉排列来编码,例如:0->0, -1->1, 1->2, -2->3, 2->4, ...。用公式来表示就是 (n << 1) ^ (n >> 31)
(32位);
剩下的double、float、fixed64和fixed32 就是简单的定长位表示。
带长度记录
对于一些可变长的数据类型,比如 string,pb 采用 Length-Delimited
的形式来编码。比较好理解,用一个varint来标记记录的长度,剩下的就是记录本身。
考虑这个 message:
message Test2 {
optional string b = 2;
}
其中string的值为"testing"。
对于这个 message,首先需要构造键值对2:LEN
来表示字段id和数据类型,然后构建带长度记录7 testing
。键值对的做法上面讲过,数据类型010,键值对就是10010,也就是十六进制12。7的varint十六进制编码为07(varint最少1个字节),"testing"的ASCII编码为74 65 73 74 69 6e 67
,所以拼到一起就是 120774657374696e67
。
拿protoScope验证下:
$ xxd -r -ps <<< '120774657374696e67' | protoscope
2: {"testing"}
嵌套记录
protobuf 编码嵌套记录(或者叫submessage)时也使用带长度记录。例如:
message Test3 {
optional Test1 c = 3;
}
这里嵌套使用了前面用过的 Test1 这个 message,于是编码为:1a 03 08 96 01
。其中1a
是键值对3:LEN
(00011 010
),03
是varint编码的3
,08 96 01
则就是我们上面提到的 Test1
的编码值。
在解码的时候,因为已经预先知道了消息包含的类型,所以解码器能够区分LEN类型下具体是string还是其他嵌套类型。
Optional 和 Repeated 字段
Optional 修饰用来表示一个字段可出现或不出现。在编解码的实现上,只要他出现就给他 append 到 message 的 bytes 中,不出现就不处理即可。这个是因为 message 的编码在设计的时候各字段都是 length-prefixed,所以不需要对其特定格式或长度。
对于 Repeated 字段,用来表示一些可重复(变长)的多个数据,可以理解为数组。pb 对其编码的方式是将其视为多个 id 相同的字段,例如:
message Test4 {
optional string d = 4;
repeated int32 e = 5;
}
如果我们设置字段的值为 "hello"、[1,2,3],这个 message 的键值对就是:
4: {"hello"}
5: 1
5: 2
5: 3
这些字段在编码的时候是没有顺序规定的,排列顺序可以随意交错。
Hello Http/2
gRPC 是基于 http/2 的(默认,也可以使用其他协议),理解 http/2 可以直接理解很多特性。
http2 与 http/1.x 不同,它是一个二进制协议,而非文本协议,并且提出了一些新的概念。从这些概念,可以大概理解 http2提供的新特性。
Frame:http2 的最小数据传输但我
Stream:用于支持多路复用。一个连接可以包含多个 stream,之间互不干扰;stream 是双向的,可以被任意一边或共同使用,可以被任意一边关闭。
Priority:可以为 Stream 灵活地配置优先级
Flow Control:http2 支持面向 Stream 的流量控制,相比 tcp 的 flow control,http2的更面向应用层,且能单独针对一些 stream 而不是连接做控制。
HPack:把请求头的一些通用部分保存在双方,避免反复传输重复的头内容,和其他算法一起实现对header的压缩。
Server Push:服务端推送资源不需要客户端的请求,可以一次推送多个内容。
在使用 http2 后,要注意瓶颈可能会更多的转移到对单个tcp连接/socket/访问socket的线程的依赖上。
Hello gRPC
对于gRPC来说,仍然采用 request-response的模型。
gRPC 请求
grpc 请求包含请求头,并且就作为 http2 的 header 来传输,包含 Method、Content-Type、压缩算法定义 等等,还提供了 custom-metadata 作为k-v给用户使用,主要关注 path,它通过“/ 包名. 服务名 / 方法名”的格式确定了要访问的 rpc 方法。
Data frame 负责携带Length-Prefixed-Message ,带有是否压缩标记、信息长度和信息内容。
注意,grpc协议虽然主要利用http2的header传递header,但作为一个独立的协议,它会在 Data frame 的开头规定自己的 grpc header,也就是在Data frame的开头会有五个字节的grpc header,分别是一个字节的压缩标志位(1表示使用压缩,0表示不使用)和四个字节的payload长度,标识包含这个header的单个grpc消息的长度。如果要手动修改grpc的字节或位,需要注意大小端转换,因为grpc用的是网络字节序也就是大端序,而x86用的是小端序。
这里要带一个信息长度是因为 grpc 支持流式消息,可以在 data frame 传多条消息,就以这个长度作分隔依据。
最后一个 data frame 会发一个 End-Stream 的标志,表示可以关闭这个 stream了。
gRPC 响应
grpc 的 response 格式也比较类似,包括 header、若干个 Length-prefixed Message 和 Trailers。如果有 error 的情况,直接返回 trailers。
request header 主要是 http code、content-type、custom-metadata等。剩下的 message 和上面一样,是有长度标记的 protobuf 序列化数据。
gRPC 流式传输
gRPC 支持三种流式传输方法,基于 http2 提供的可以多向流式发送的 stream,提供客户端流模式、服务端流模式、双端双向流模式,可以在 IDL 上用 stream 关键字指明要用的模式:
// 普通 RPC
rpc SimplePing(PingRequest) returns (PingReply);
// 客户端流式 RPC
rpc ClientStreamPing(stream PingRequest) returns (PingReply);
// 服务器端流式 RPC
rpc ServerStreamPing(PingRequest) returns (stream PingReply);
// 双向流式 RPC
rpc BothStreamPing(stream PingRequest) returns (stream PingReply);
普通的 rpc 调用又叫 unary 模式,单次执行一来一回然后结束;带流式的 rpc 可以在 stream 中分多次发送多个 Data 帧,另一端可以边接收边处理,这样就可以比较方便地解决例如一次传输很大量级文件的问题。
在一般的业务中可能用不到 streaming 模式,但网关等中间件场景用处较多。