说说我理解的grpc的编码协议

1,466 阅读11分钟

1. 一些思考:

一个好的网络服务,是会以尽量小的资源消耗,尽量快速地传输尽量多的数据和资源的。

联系到实际,一个服务流量小的时候,就没有必要花很多成本去考虑这方面的东西,传输的数据大小可能也无所谓,反正不会把带宽打满,接收方也有足够的时间和空间进行请求处理,但是,如果并发量增加,到了一定程度后,忽视数据编码的影响就会体现出来。

一种情况是传输的数据量过大,有个流量高峰可能整个带宽就被打满了。

另一种情况是对数据进行编码/解析的时候,对CPU资源也会造成消耗,最直接的结果就是拉高接口耗时,所以一种高效的序列化/反序列化方式对于一个网络传输框架来说也是很必要的。

那么归结起来,网络服务对于资源的消耗主要分为两类:

一类是时间方面的,即数据能否很高效地完成序列化,反序列化,占用请求两端尽量少的CPU时间。

另一类是空间方面的,即传输的数据体积能否尽量小,一是可以占用尽量小的带宽,二是可以减少数据接收方的空间占用。

以上这些都只是数据层面的问题,而网络传输还有另外一个问题,即对于传输过程的控制,比如说怎样保证数据传输的有序性,可靠性,请求能否并发等,这些问题本文先不涉及。

所以,说到对网络中其他节点的资源进行请求,有两个问题是肯定绕不过去的:1. 数据以何种协议进行整理,以实现尽可能小的体积,如何进行高效的编码/解析;2. 数据如何在网络中传输。即编码协议和传输协议问题,这篇文章先来聊聊编码协议。

2. 一个高效的数据传输方案都需要具备哪些能力

  1. 尽可能快地序列化、反序列化
  2. 序列化后的体积越小越好
  3. 跨语言,和语言无关
  4. 简单、类型明确
  5. 易扩展,可以简单的迭代,向后兼容

3. gRPC是如何实现这些能力的

gPRC的解决方案是抛弃掉JSON,XML这些传统的数据编码格式,并采用了Protocol Buffer这种跨平台,跨语言的数据编码协议。

syntax = "proto3";

package lsd;

service LSD {
    rpc lsdInsert (lsdInsertRequest) returns (lsdInsertReply) {
    }
}

message lsdInsertRequest {
    string ld = 1;
    int64 requestNo = 2;
    repeated string Phone = 3;
}
message lsdInsertReply {
    Result code = 1;
    string errNum = 2;
    string successNum = 3;
    map<string, string> errPhone = 4;
}

以上是一个protobuf的使用样例,不难看出有这样几个特点:

  1. 支持多种数据类型;
  2. 参数拥有自己的参数名;
  3. 参数拥有自己的顺序id;
  4. 支持循环,map等复杂的参数结构;
  5. 支持嵌套参数;
  6. 所有信息落在一个.proto文件中,请求两方都要通过同一份.proto文件进行数据编码/解析;

这样一份.proto文件可以看做是请求方和接收方两者对同一个资源如何交互的协议。

但是,只靠.proto文件并不能实现数据的编/解码以及跨语言,跨平台等能力。

为了实现上述的各项功能,gRPC提供了统一的protoc工具,通过该工具,可以生成可供各语言理解,执行的中间代码,比如go语言,protoc工具生成的就是pb.go文件,pb.go文件里保存的就是服务对应的接口函数,以及request,response的struct。

执行protoc命令
|----|  生成go语言对应的中间代码  |             对应.proto文件的地址            |
protoc --go_out=plugins=grpc:./ ./crm/leadsservicestm/leadsservicestm.proto

请求方把按照gRPC序列化规则编码后的二进制编码发送给接收方,接收方通过相应的规则解码后,通过pb.go文件,获得相应的信息,包括请求的时哪个接口,传了哪些请求参数,每个参数的值是多少等等。

4. 与其他编码方案的对比

以JSON为例

{"param1":"staffInfo","param2":[1,2,3],"param3":123,"param4":{"110":{"state":1,"gender":18},"119":{"state":1,"gender":20}}}

首先,JSON和protobuf从最初的的设计出发点就有所不同,JSON更多的是面向开发者阅读的,所以会以字符化的字段名和字段值,以及“,”,“{}”,“:”等符号明确数据的结构,这一切的设计都是为了让开发人员可以更方便地阅读。

但是,如之前所说,一个好的网络服务,是会以尽量小的资源消耗,尽量快速地传输尽量多的数据和资源的。

而protobuf的设计正是为了尽可能的减少资源消耗,提高数据传输的时空效率。那么,在摒弃了面向开发者阅读这个前提下,protobuf又有哪些地方可以进行优化呢?

  1. 通过JSON传输数据,会有很多重复的字段名。
  2. 会有很多的“{}”,“:”,“,”等符号来规定数据的结构。
  3. 字符化的数据承载会占用很多额外的空间,比如上例的param3,需要用3个字节来承载,实际上1个字节(8bits)就够用。

protobuf又是怎么做的呢?简单来说,有这么几项:

  1. 为了减少重复字段名的传输,protobuf传输的是参数的index,而且字段值是按index顺序排列的;
  2. protobuf没有“{}”,“:”,“,”这些符号,数据结构的明确是通过每个字节最高位的值维护的;
  3. 数据是通过二进制编码承载的,所以不会有额外的空间浪费,比如123这个数字,JSON需要3个字节,proto只需要一个字节(8bits);
  4. 因为传输的是二进制编码,所以序列化/反序列化的效率会比JSON高出很多;

综上,protobuf在数据传输的时空复杂度方面,远小于JSON,因此,gRPC非常适用于高并发,高频次的服务间数据传输。

5. gRPC到底是怎么进行数据编解码的

上文提到了,protoBuf没有“{}”,“:”,“,”这些分隔符,这就又引出了几个问题:

  1. 数据如何分割,层级结构如何确定;
  2. 定长数据如何尽可能减少空间占用;
  3. 变长的数据类型,解析的时候如何确定已经结束;

这就是protoBuf设计精妙的地方,protoBuf在其拥有的数据类型之上又设计了一层wire type,每种不同wire type的数据类型拥有不同的编码方式。

wire type值编码方式编码长度存储方式数据类型
0varint(负数采用zigzag)变长(最长10字节)T-Vint32,int64,uint32,uint64,bool,enum,sint32,sint64,
164-bits固定8个字节T-Vfixed64,sfixed64,double
2length-delimi变长T-L-Vstring,bytes,embedded messages,packed repeated fields
532-bits固定4个字节T-Vfixed32,sfixed32,float

日常用到最多的应该就是varint和length-delimi这两种wire type的编码类型。

  1. varint:解决了定长数据的空间浪费问题,1字节长度的数据,就占用1字节;
  2. length-delimi:用来表达变长数据,比如字符串,嵌套结构,数组等。

下边先来介绍下T-V这种存储方式:

  1. T即type,展现形式为一个8bits的二进制码,其中,最低三位为参数字段类型的wire type值,较高5位位参数在message中的序号值(tagNum),格式:(tagNum<<3|wireType);
  2. V即value,为字段值的二进制,通过解析T,就能明确字段值的长度,如何解析等信息;
  3. 如果数据接收方解析出来的字段编号(tagNum),不在现有的protoBuf上,说明接收方现有的protoBuf是落后的版本,该字段会被抛弃,这也是gRPC兼容性的体现。

protoBuf编码里没有分隔符,那么T和V是根据什么规则进行分隔的呢? 答案是:protoBuf征用了每个字节的最高位,字节最高位叫做Most Significant Bit(MSB),如果MSB为0,代表当前T或者V到这个字节就结束了,从下一个字节开始就是另一个T或V,如果MSB为1,则代表当前T或者V还没完。

举个例子:

message request {
    int64 id = 1; // tagNum = 1, wireType = 0, 
}

当id=123时,这个字段的proto编码为:00001000 01111011

前8位为T,T的前5位是id这个字段的tagNum,第6-8位为id这个int64类型的wire type值。

从第9位到第15位就是id字段的值了,可以看到,这一个字节的最高位为0,代表这个字段用一个字节就能装下,这里就是protoBuf和JSON相比的一大优势,同样的数据用JSON的话需要3个字节。

那么如果一个大于127(最高位为1)的字段值应该怎么表示呢?

现在假设id=200,200换算成二进制是11001000,因为这里最高位是1,而protoBuf里每个字节的最高位有其自身的意义,不能用于承载数据,所以这里protoBuf的做法是把二进制码从低到高位,每7位分成一段,每段再加上一个最高位,再把每段倒序排列,最后一段的最高位置为0,其余段的最高位置为1。

按这种逻辑:

  1. 进行7位拆分,结果为:1 1001000;
  2. 把每段补全,结果为:00000001 01001000;
  3. 再倒序排列,结果为:01001000 00000001;
  4. 再设置每段的MSB,结果为:11001000 00000001;

所以,200这个值在protoBuf里就表示为11001000 00000001,接收方会按照同样的逻辑进行解码,最后获得200这个值。

相同的逻辑也可以套用在K的编码上,与V不同的是,字段的tagNum如果大于15,就需要用两个字节承载了,因为16的二进制码是00010000,tagNum还需要左移3位,这样字节就变成10000*** 了,最高位与MSB冲突,所以需要再扩展一个字节。

举个例子:

message request {
    int64 id = 18; // tagNum = 18, wireType = 0, 
}

在这个例子里,tagNum为18,18的二进制码为00010010,再加上三位的wire type值,组合起来的二进制码就是00010010000。

  1. 进行7位拆分,结果为:0001 0010000;
  2. 把每段补全,结果为:00000001 00010000;
  3. 再倒序排列,结果为:00010000 00000001;
  4. 再设置每段的MSB,结果为:10010000 00000001; 这样这个字段的K就完成编码了。

varint这种编码方式,对无符号的数据效率很高,但是如果碰上负数的话,会有效率的损耗,因为负数的最高位为1,那么就要多扩展一个字节来承载。而protoBuf又没有办法来区分某个字段值是不是负数。

protoBuf解决这个问题的方式是,指定了两种数据类型:sint32,sint64,这两种数据类型的编码方式不是varint,而是zigzag,这种编码方式对负数来说效率比varint高,所以,如果字段值可能会有负数的话,可以使用sint32,sint64这两种数据类型。

说完了T-V类型的编码方式,现在再来说说T-L-V。 顾名思义,T-L-V多了一个L,即数据的字节长度,因为像字符串,数组,嵌套message这些数据结构,是没有办法通过类型和MSB确定其长度的,只能通过额外的空间把字段的长度存下来,数据接收方进行反序列化的时候才能确定字段值在二进制码中的范围。

所以,对一个message进行编码,展现形式就是一个个紧密排列的T-V或者T-L-V串。对接收到的编码进行反序列化时,通过以.proto文件为依据,对每个字段T的解析,就能明确字段T的类型,长度,完成反序列化后,就可以得到请求参数的struct。

总结

  1. gRPC通过protocol Buffer的引入,降低了数据传输的时空复杂度,与JSON等方案相比,protoBuf序列化后的数据体积更小,反序列化的效率更高。
  2. 但是,protoBuf序列化生成的二进制码只凭人眼是不可读的,必须通过.proto文件才能进行反序列化,可读性上不如JSON等方案。