Hello gRPC:protobuf 序列化 & gRPC 传输协议

Hello gRPC:protobuf 序列化 & gRPC 传输协议

tk_sky 84 2024-02-04

Hello gRPC:protobuf 序列化 & gRPC 传输协议

gRPC 是一个高性能、通用的开源RPC框架,其由 Google 主要面向移动应用开发并基于HTTP/2 协议标准而设计,基于 ProtoBuf(Protocol Buffers) 序列化协议开发,且支持众多开发语言。

除了 protobuf 序列化,gRPC 本身的传输协议也值得研究下。

image-20240110221748808

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:LEN00011 010),03是varint编码的308 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 模式,但网关等中间件场景用处较多。