用了很久Protobuf,对它的一些设计很好奇。比如字段的序号是干嘛的?不同版本的消息类怎么兼容?

这一切的一切,都和Protobuf的序列化机制有关,也就是一个消息类是怎么变成二进制数据的,然后又是怎么从二进制数据还原回消息类的。

接下来我基于protobuf 3语法和c#语言进行讲解,大部分内容参考自protobuf官方文档。

varint编码

protobuf里很多地方都使用了varint编码,它有一个特点,就是可以将一些数值特别小的数据进行压缩,减少大小,用处非常广泛。

首先,这是一个最简单的消息类Test,只有一个字段a,是int类型。

message Test {
    int32 a = 1;
}

接着给a赋值150后进行序列化,会得到一个3字节的十六进制数据。

08 96 01

明明int32是4字节,怎么序列化后变成了3字节,还有1个字节去哪里了?

原因是protobuf使用了一个叫varint的变长编码方式来存储整数,这能减少序列化后的数据大小。

首先,protobuf中的int32是无符号整数,它能表示的范围是0到43亿(42,9496,7295)之间。

这个范围非常的大,但我们平常用的最多的可能就是几百,几千,几万这个样子,很少会用int32存储上千万和上亿的数字。

那么int32的高位大部分情况下都是0,我们都没有使用,空间被白白浪费掉了。


要明白varint编码的原理,也许从解码的角度来看会更加容易。

首先在varint中,每个字节只有7个bits是用来存储实际的数字的,还有一个最高bit是用来存储continuation bit(连续位)的,这个位置也是符号位的所在位置。

比如数字150经过varint编码会变成十六进制的96 01,或者二进制的10010110 00000001

要注意的是这里的96 0110010110 00000001都是小端编码的多字节数据,为了方便理解这里将其调换顺序,改成大端的写法。然后用加号注明了continuation bits和用下划线注明实际的payload数据位。

第2个字节 第1个字节
-----------------
00000001 10010110
+_______ +_______

第一个字节10010110的continuation bit是1,就说明第一个字节的数据还没有写完,后面还有。

此时我们就看第二个字节00000001,第二个字节的continuation bit是0,就说明数据到这里就结束了,后面已经没有更多数据了。

这就是continuation bit的作用,用来指示数据结束了没有,后面还有没有更多的数据需要读取,直到遇到continuation bit位是0的字节才停下。

有了continuation bit之后,我们就可以把多个字节里的payload数据拼接起来了。比如上面的例子中,我们把continuation bit直接丢掉之后,就只剩下payload实际数据位了,只有7位了。

第2个字节 第1个字节
-----------------
00000001 10010110 // 原数据
 0000001  0010110 // 把continuation bits丢弃
   00000010010110 // 把剩下的bits直接拼接起来

最后我们得到一个00000010010110的数据,把它丢入计算器,转换成十进制,就正好是150了。

如果是一个少于8bits的数据,比如只有7bits的十进制92(0101 1100),varint编码后就还是它本身0101 1100不变。

到这里已经能看出来varint的作用了,可以将一些很小数值的压缩存储,节省空间。

但是当varint遇到一些很大的值的时候,可能会变成负优化,因为varint编码中,每个字节只有7个bits的有效空间,如果拿来存储一个32bits的数据,比如十六进制的0x80808080,就会占用5个字节。

如果你的数据一直很大的话,就不建议使用varint编码类型了,而是使用定长的fixed32会更加合适。

字段序号

protobuf类的每一个字段,除了要写类型和名字以外,还得额外指定一个序号。

比如下面这个OldTest消息类,字段a的序号就是1,字段b的序号就是2,而且每个字段的序号还不能重复。

message OldTest {
    string a = 1;
    int32 b = 2;
}

序号最主要的作用就是做新旧消息的兼容。比如上面的OldTest消息类,过了一段时间之后,因为业务需要,删了原先的b字段,然后增加了一个新的c字段,变成了这样。

message NewTest {
    string a = 1;
    int32 c = 3;
}

假设服务端这边已经在使用后面的新版消息了,但客户端那边的因为一些原因无法更新版本,只能使用旧的版本。

现在将新版的消息发送到了旧版的客户端上,客户端在解析消息的时候,就会检查字段的序列号:a字段会正常返序列化,这个没有问题。

但是检查到b字段的时候,发现接受到的消息里面好像没有b字段的数据,此时客户端会给OldTest的b字段给一个默认值,b是int32类型的,所以会直接给默认值0。

最后还发现多了一个c字段,c的序号是3,没有任何可以对应的字段,所以直接跳过c。

这样即使两边的消息类版本的新旧不同,protobuf也能借助序号做出一定的兼容处理。

这里大家可能会有疑问,为什么不检测字段名,而是要单独写序号呢。这是因为字段名是字符串,发送的时候可能这个字段名本身占的长度比实际的数据还要大,这样就会造成数据的浪费。

使用序号同样也能确保字段的唯一性,而且所占的空间也大大缩小了。使用序号还有一个好处,那就是只要确保序号相同的情况下,字段的名字是可以随意修改的,不会影响序列化结果。比如上面的NewTest里的字段a,我不想叫它a了,我想叫它content,那么直接修改字段名就好了,只要不改动后面的序号和前面的类型,一样不会影响协议的兼容性,非常的实用。

message NewTest {
    string content = 1;
    int32 c = 3;
}

上面解释了字段的作用,现在来探索一下字段是怎么存储的。在最开头我们提到过,有一个最简单的消息类Test,然后赋值a = 150,序列化后会得到十六进制的08 96 01

message Test {
    int32 a = 1;
}

其中96 01已经在前面解释过了,这是二进制150在varint编码后的值。但是前面还有一个字节08。字段序列化的秘密可能就藏在这个08的里面。

Wire Type

在说明序号的存储格式之前,首先要介绍一下protobuf的Wire Type是什么。(wire type我也不知道怎么翻译,类似于编码类型?)

上面我们提到过,对于一些比较小的数字,protobuf使用了varint编码来存储,可以减小数据大小。而对于一些很大的数据,比如2155905152(0x80808080),还使用varint编码的话就不合适了,因为varint的连续位的原因,实际的4字节数据编码后会变成5字节,这样就不划算了。此时直接将4字节原样存储是更合适的选择。

刚刚提到的varint编码方式,以及原样存储,都是Wire Type的一种。Wire Type一共有6中类型:

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
  • 首先是最常用的varint类型,使用的范围非常广。

  • 接着是定长的i64类型,固定占用64 bits也就是8个字节。

  • 后面是len类型,这个类型和其它类型有个很大的不同,就是len是变长的,用来存储任意长度字节的,比如字符串,或者自定义的二进制字节数组等,使用起来是最灵活的。

  • 再跟着是3和4,这俩在protobuf 3里面已经弃用了,可以不用管它。

  • 最后是i32类型,也是定长的,和i64是一样的,只不过长度只有一半

总的来说Wire Type是protobuf在序列化后使用底层类型,根据不同的数据特点所划分出来的。

有些Wire Type会对应多个编程语言里的类型,比如int32,int64,bool,enum这些类型都有共同点,就是数据大小在8字节以下,而且值大部分情况都不会很大,所以就很适合用varint来表示。


知道了wire type之后,我们就可以来看前面提到的神秘字节0x08了。

这个08字节是有专门的名字的,它叫tag,这个tag里面就同时包含了字段的wire type和序号。

在tag里面,低3位是用来存储wire type的,3个bits可以表达的范围是0-7,而wire type总共才只有6种,所以使用3个bits来表示所有wire types是完全没有问题的。

剩余的位用来存储字段的序号。比如我们08从十六进制转换成二进制就是00001000。

可以看到低3位是000,正好对应wire type表里的varint编码。如果是001,则对应i64类型,也就是定长整数类型。

接着剩余的高位是00001,也就是1,正好对应字段a的序号int32 a = 1;

那看到这里有朋友可能会有一些疑问:00001才只有5位,也才能表示0-31的范围。那我的消息类里面有超过32个字段那要肿么办,会不会编译失败?

其实不用担心,protobuf早就想好了解决方法:tag这个数据本身就是使用varint编码的。这样即使定义了超过32个字段,也无非就是让tag从1个字节变成2个字节而已,仅仅是数据变大了一点,所以完全不用担心字段数量不够用。

但是一般情况下,我们都会尽量保证字段数量不要超过32个,这样就可以使tag维持在1个字节以内,也可以减少序列化的大小(毕竟蚊子肉也是肉嘛)

这里有一个小经验,如果我们这样定义消息类,即使字段数量没有超过32个,但最终也会使用2字节来存储这个tag。

message Test {
    int32 a = 33;
}

好了,到这里可以给protobuf对单个字段的序列化规则做个总结了,首先这个字段的序号和wire type会存储到一个叫tag的字节里,比如int32 a = 1;会序列化成08。(不一定是单个字节,因为是varint编码,字段超过32个的话也会使用多字节来存储字段的tag)

然后后面会跟着具体编码后的数据,比如a = 150就会序列化成96 01。组合起来就成了08 96 01,这三个字节就是这么来的。

bool类型

对于bool类型,protobuf的处理规则很简单,直接将其编码为定长整数int32类型,固定占用4个字节,值等于0就是false,否则就是true。

负数

varint编码很适合存储无符号的整数。但是对于有符号的数来说,尤其是负数,无论是用原码存储,还是用补码存储,都是一件很不划算的事情。

比如一个64位有符号整数-2,它的原码是:

10000000 00000000 00000000 00000010 // 原码

而补码是:

11111111 11111111 11111111 11111110 // 补码形式

甚至反码:

11111111 11111111 11111111 11111101 // 反码形式

可以看到无论用那种,最高位,也就是符号位都是1,这就会导致varint的优化机制失效,明明是一个最简单的不过的-2,却要用10个字节去存储,有点杀鸡用牛刀了。

那么protobuf给出的方法是既不使用原码存储,也不使用补码使用,而是使用ZigZag编码存储,zigzag编码将符号位用一种很讨巧的形式进行了存储,使其可以吃到varint的优化。

ZigZag编码的原理其实很简单,一个数p,如果它是正数,那么就将它乘以2,也就是p * 2。如果是负数,就将它乘以2再减一,也就是|p| * 2 - 1

这样我们拿到一个zigzag编码后的数后,就可以通过判断奇偶性来还原符号位,奇数一定是一个负数,偶数一定是正数。同时乘以2这个操作可以直接通过移位完成,CPU直接有对应的指令支持,效率也很高。这样就避免了符号位打断varint的优化了。

浮点数

浮点数的存储比较简单,如果是double类型,会直接按bits存进8个bytes里,大小端虽然官方文档里没有提及,我猜测可能是以小端模式存储的。同理float类型会存储在4个bytes里。

LEN变长数据

对于string这样的变长数据,protobuf的存储方式其实也很简单。

举个栗子,首先定义一个消息类Test2

message Test2 {
    string b = 2;
}

然后将b赋值为"testing",序列化后的数值就是:

12 07 74 65 73 74 69 6e 67

首先,最开始的12是字段的tag,也就是00010 010,00010是字段的序号2,010是LEN类型的wire type。

然后跟着的是一个07,这个07是一个varint,用来表示整个数据的长度,"testing"的长度正好是7,所以这里就是07。

既然长度是7,那么后面紧挨着的7个字节,就是对应的数据了,也就是"testing"。

12 07 [74 65 73 74 69 6e 67]

LEN类型的存储方式就是:先存储varint数据长度,然后再存储实际的数据。

嵌套消息类型

对于嵌套消息类型的处理也很简单,比如这里有一个Test3类型,里面有一个Test2类型的字段。

message Test3 {
    Test1 c = 3;
}

那么先将字段c正常序列化成二进制数据,然后再将这个嵌套类型当做bytes处理就好了。下面是为了方便理解的写法(虽然这样写不太准确)

message Test3 {
    bytes c = 3;
}

可选字段

在定义消息字段的时候,会有一个optional关键字可以用。只要写上这个关键字,就代表这个字段是可选的,即使不填充这个字段也不会有问题,就像下面这样。

message Test4 {
    optional string d = 4;
}

要理解可选字段如何序列化,我们首先要了解protobuf中record(记录)的概念。

record其实很好理解,一个消息里面的每个字段,序列化之后都是一个单独的record。

首先我们定义一个消息类Test,里面有两个字段a和d。

message Test4 {
    int32 a = 1;
    string d = 2;
}

接着我们给a赋值为63,d赋值为"testing",再将其序列化。序列化后的值就是:

08 96 01 12 07 74 65 73 74 69 6e 67

然后每个字段都是一个record,我们按record划分一下这组bytes。

08 96 01 | 12 07 [74 65 73 74 69 6e 67]

08 96 01是字段a序列化后的record数据,后面的12 07 74 65 73 74 69 6e 67是字段d序列化后端数据。

消息类里面的每个字段序列化成record后,都是直接追加在前一个record后面的。而每个record又有自己的长度边界信息,所以也不会相互干扰。

同时record里面也包含了序号信息,这样即使序列化的时候,record没有按消息定义里的字段顺序来排列,也不会影响最终的解析结果。

现在我们再来看可选字段,前面我们知道了序列化后的数据是由一串records组成的。那么这里也很容易想到,我们在序列化可选字段的时候,如果发现它为空,那么就跳过对应的record数据不序列化,最终的序列化数据里就会少一个record。

等到反序列化读取的时候,发现少了一个record数据,反序列化程序就知道了这是一个刻意留空的字段了。

不得不说,protobuf很巧妙地用字段的序号来实现了可选字段,在没有增加新的数据位的情况下就实现了可选字段,妙啊。

重复字段

重复字段的处理会相对复杂一点,protobuf有两种策略来序列化重复字段,一种是普通编码,另一种是紧密编码(packed)。

普通编码使用上没有任何限制,不管是什么类型都可以使用。而紧密编码只能用在原始类型(promitive type)的字段上。

原始类型在protobuf里的定义就是除了string和bytes以外的所有标量类型(scalar value types),包括这些:double、float、int32、int64、uint32、uint64、sint32、sint64、fixed32、fixed64、sfixed32、sfixed64、bool。

可以看到这些类型都有一些共同点,那就是数据的大小都很小,不会很大,且大多都使用varint编码。

首先来说普通编码,因为每个字段都有自己的序号,按序号序列化成一个个records,那么处理重复字段的方法就很简单了,比如有这么一个消息类Test:

message Test5 {
  repeated string a = 1;
}

a是一个重复字段,我们给a赋值为["test", "str1"],那么序列化后会变成:

08 04 [74 65 73 74] | 08 04 [73 74 72 31]

可以看到字段a被序列化了2次,生成了2个records。这样当反序列化程序读取到了两个相同序号的字段时,就会把它们当做一个列表收集起来了,也就实现了重复字段的传输。

而对于原始类型来说,protobuf为了保证效率,会使用紧密编码来存储它们。

需要说明的是,在protobuf2里,要在字段后面手动标明[packed=true]才会开启紧密编码。但是在protobuf3中,所有原始类型默认就已经开启了紧密排列。

普通编码时,每多出一个元素就会多序列化一个record,而record里面是包含tag部分的,虽然tag大部分情况下只有一个字节长,但是对应原始类型来说,原始类型本身大部分情况下也只有一个字节长,现在又加上了tag,长度从1个字节变成了2个字节的长度,多少有些不太划算了。

紧密编码正好就是用来解决这个问题的,它直接把所有元素打包到一起,所有元素共用一个tag,这样存储空间的利用率也就起来了。

紧密编码的存储原理是使用LEN可变长类型来存储重复字段,比如写一个重复的int32字段:

message Test5 {
    // protobuf3中已经默认启用原始类型的紧密编码,索引这里不需要额外再写什么
    repeated int32 f = 6;
}

我们给f赋值为[3, 270, 86942]再将其序列化后,就得到了一个3206038e029ea705。这个数据看起来很吓人,其实把它拆开以后还是很好理解的:

32 06 [03, 8e 02, 9e a7 05]

首先我们读取开头的0x32,二进制是00110 010,00110是6,代表字段f的序号,这点没问题。010代表这个record是wire type,010是2,对应上方表里的LEN类型,到这里也没问题。

LEN类型我们知道,数据的开头是一个varint编码的长度描述符。我们读取第二个字节0x06,发现这个字节的最高位,也就是varint连续位的是0,那么就表示所有的有效数据都在这一个字节里,也就是06。这个06就代表这个LEN类型的字段,后面有多少个字节。

然后我们把后面的6个字节单独拿出来:

[03 8e 02 9e a7 05]

此时还不知道这6个字节是什么意思,只知道它是3个int32类的数据。

首先从第一个字节0x03开始读取,因为int32也是使用varint编码的,我们在读取0x03的时候,也要检查它的连续位是否为1,这里显然为0,说明整个0x03就是第一个数据了。这里正好对应我们原始数据[3, 270, 86942]中的第一个数据3。

我们在03后面画上逗号进行分割:

[03, 8e 02 9e a7 05]

再来看第二个字节0x8e,8e的二进制编码是10001110,可以看到最高位,也就是varint的连续位是1,说明这个数据还没有写完,后面还有。那再读取一下8e后面的一个字节02。0x02这个字节是最高位是0,说明这个varint值是由2个字节组成的。

我们首先将0x8e和0x02翻译成二进制:1000 11100000 0010。丢弃连续位,变成00011100000010。然后将剩下的7 + 7 bits直接拼接在一起,变成00000100001110,也就是100001110,转换成十进制正好等于270。(注意varint是使用小端存储的,拼接位的时候从人类的角度看起来是反过来的,会有点反直觉)

我们在8e 02后面画上逗号进行分割:

[03, 8e 02, 9e a7 05]

接着是字节0x9e,二进制是1001 1110,连续位是1,说明后面还有数据。读取第二个字节0xa7,二进制是1010 0111,连续位是1,说明后面还有数据,读取第三个字节0x05。二进制是0000 0101,此时连续位是0了,说明没有数据了。收集到的9e a7 05拼接起来后,正好等于原数据中的第三个元素86942。此时已经过去了6个字节,LEN数据也正好到达末尾,解码到这里就结束了。

这里有一个小细节,如果LEN的长度描述和varint实际读取到长度不一样,是会出问题的。拿上面那个例子来说,如果最后一个字节不是05,而是85(0x85的连续位是1),反序列化时就会读取到超过有效范围之外的数据,可能会导致数据错误,这一点需要注意。不过实际使用时都是由protobuf在帮我们完成数据的序列化和反序列化工作,还是不用担心它会出现问题的。假如是自己序列化数据的话,就需要留意一下这个问题了。

Map类型

Map类型的处理非常简单,以下两种代码在序列化后是等价的

message Test6 {
    map<string, int32> g = 7;
}

等效代码:

message Test6 {
    message g_Entry {
        optional string key = 1;
        optional int32 value = 2;
    }
    repeated g_Entry g = 7;
}

序列化后字段顺序

因为有了字段的序号,序列化后,各个records之间的相对顺序就不再重要的,而protobuf也要求反序列化程序不能依赖records的顺序,而是要跟句record里的序号做反序列化更加合适。


封面来源:X@SR_LD_FR(原贴发布于 12:13 AM · Apr 1, 2024)