Google Protocol Buffers 序列化原理

432 阅读6分钟

Google 的 Protocol Buffers 跨语言、性能好,序列化后的消息体小利于传输,被广泛应用于RPC调用,数据存储。

本文一共分为 6 部分(TLV编码、压缩整形、字符串、嵌套类型、数组、Map),跟大家拆解 ProtoBuffer 序列化原理。

01

TLV 编码

  • T:Type ,用于标识标签或者编码格式信息。

  • L:Length 定义数值的长度。

  • V:Value表示实际的数值。

这个是个比较通用的结构类型,很多序列化工具都是基于这种类型的优化。

我们来看一个简单的 proto 定义:

message Test1 {  optional int32 a = 1;}

最后一个数字是字段的序号(field_number),不能重复,每个字段有了唯一的编号,有了编号之后就可以不用存储字段名称。

正常情况下一个字段名称最少也2、3个字符,一个字符占用 2 个字节。而是用整数,再加上接下来讲的压缩整形,标识这个字段可能只需要 1 个字节就够了。

Protocol Buffers 将数据分了六种类型:

类型 含义使用场景
0压缩整形 int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 固定64位 fixed64, sfixed64, double
2 不定长 string, bytes, embedded messages, packed repeated fields
3 Start group 废弃
4 End group 废弃
5 固定32位 fixed32, sfixed32, float

6 种类型用 3 位二进制即可保存,我们用 wire_type 代表类型,对应 TLV 编码 T 的定义如下:

T = (field_number << 3) | wire_type

用低三位来存储 wire_type,剩下存放字段序号,然后 T 再用压缩整形进行序列化。

除了第二种不定长,我们可以认为其他类型都是定长的,所以定长的类型就不需要 L,直接 TV 就可以表示。

02

压缩整形

一个字节一共有 8 位,我们可以用低 7 位来存储数据,最高位表示是否需要更多字节来存储。

比如我们要存储一个 int 类型的 128 ,4个字节,转换后只需要两个字节存储。

接下来我们看个示例:

当我们给 a 赋值 128 的时候,输出的序列化后的字节数组:

[8, -128, 1]
  • 8: 对应的二进制为:

    0000 1000,低3位是 0 表示使用的是压缩整形,剩下的数字是 1,表示序号为 1 的字段。

  • -128: 转换成二进制:

    1000 0000,由于采用的是压缩整形,最高位是标记为,表示下一个字节跟这个字节是一起的。

  • 1: 转换成二进制:

    0000 0001,这里标志位是 0 表示到这里已经读取到所有数据了,跟前一个字节数据拼接在一起:

    000 0001 000 0000,对应的 int 类型数字是 128。

再看个示例,当 a 的值是 -1 时,输出的序列化数组:
[8, -1, -1, -1, -1, -1, -1, -1, -1, -1, 1]
这里 8 后面有 10 个字节,int32 大小是 4 个字节,算上压缩标志位最多也是 5个字节,为什么这里 10 个字节呢?其实对于 int32 类型的负数,ProtoBuffer 在序列化时候,会转换成 int64 为来处理。
对于 -1 来说,转换成 int64 每一位都是 1,一共有64个 1,7个一组,一共有 9 组,最后还剩一位 1 ,如下所示(第一组后面省略剩下相同的 8 组)
1111 1111, ... , 0000 0001
**ZigZag encoding**
通过上面的示例,我们可以发现,负数由于最高位是符号位,肯定是 1,对于负数来说并不能起到压缩效果,针对这种情况 ProtoBuffer 提供了有符号整型(sint32 和 sint64)。
这种类型怎么序列化呢?负数最高位符号位一定是 1,比如我们可以将符号位移到最低位。负数在计算机中是补码存储的,越接近 0 的负数,高位 1 越多,由于是补码取过反,我们是不是可以再取反下,将 1 转换成 0。
下面来看 sint32 公式(64 位的将 31 换成 63)
(n << 1) ^ (n >> 31)
  • n<<1: 相当于将最低位空出来。

  • n>>31: 带符号移位,正数就是 32 个 0,负数就是 32 个 1。

  • **异或:**相当于把符号位移到低位,并对负数做个取反。

如果 128 以这种方式序列化后会是什么样?过程如下:

与 0 异或相当于不做任何改变,所以对于正数来说,相当于做了左移一位。
接下来看下 -1 转换情况:

经过这样转换后对于数值比较小的负数,都转换成正数,来起到压缩的效果。

03

字符串

定义如下结构:
message Test2 {  optional string b = 1;}

给 b 赋值 "java" 输出的字节数组如下:

[10, 4, 106, 97, 118, 97]

第一个字节标记类型和序号,大家可以自己推算。第二个字节是长度,也是用压缩整形序列化,后面 4 个字节对应 “java” 这 4 个字符的 ASCII 码。

04

嵌套类型

如下 Test3 中包含 Test1 数据

message Test3 {  optional Test1 c = 1;}

Test1 中的 a 赋值 128,将 Test3 序列化输出如下:

[10, 3, 8, -128, 1]
  • 10:0000 1010 对应的是 c 字符的位置和序号

  • 3:接下来内容大小,3 个字节

  • 8, -128, 1:对应的是 Test1 的数据

05

数组

repeated 是表示字段可以重复。

message Test4 {   repeated int32 d = 1;}

这里给 d 添加 2 个值 “1” 和 “128”,输出如下:

[8, 1, 8, -128, 1]
  • 8, 1:这里表示 1

  • 8, -128, 1:这里表示 128

这里跟基本类型序列化一样,只不过数组中每一个元素序列化的时候都有一个相同的 T (Type,示例中的 8)。

这里在序列化的时候,ProtoBuffer 并不保证数组的元素一定是连续存储,有可能中间序列化的有别的元素。

对于基本类型来说数组可以压缩,省略掉每个元素都要标记 T 。配置了压缩之后,数组就是连续存储。

message Test4 {   repeated int32 d = 1 [packed=true];}

同样的 “1” 和 “128” 输出如下:

[10, 3, 1, -128, 1]
  • 10:field_number=1 ,wire_type=2,不定长。

  • 3:长度,接下来 3 个字节存放数据。

  • 1:0000 0001,高位是 0 说明是一个完整的数据,就是我们第一数据 1。

  • -128,1:对应的是第二个数据 128

06

Map

Map 的序列化方式跟数组是差不多的,我们可以认为一个 key-value 是一个 item,其实就相当于一个 item 数组。

定义如下结构

message Test5 {    map<string,Test1> e = 1;}

在 e 中添加 2 个元素: ("1", Test1(a=128)),("128", Test1(a=128)),序列化后的结果如下:

[10, 8, 10, 1, 49, 18, 3, 8, -128, 1, 10, 10, 10, 3, 49, 50, 56, 18, 3, 8, -128, 1]

上面的数据可以拆分成 2 组,带大家分析下第一组数据,剩下一组可以自己分析。

10, 8, 10, 1, 49, 18, 3, 8, -128, 1, 10, 10, 10, 3, 49, 50, 56, 18, 3, 8, -128, 1

参考文档
  1. developers.google.com/protocol-bu…

  2. developers.google.com/protocol-bu…

扫码关注,获得更多干货文章