Protocol Buffers编码原理
目次
Protocol Buffers(简称 proto)是一种高效的二进制序列化协议。它的核心编码方式基于 Base-128 Varint(128 进制变长整数)。理解它的编码规则是掌握 proto 的关键。
基于 Base-128 的 Varint 编码 #
Varint 是一种可变长度整数的编码方式。一个整数可以用 1~10 个字节存储,数值越小,占用的字节越少。
每个字节有 8 位,其中:
最高位(MSB,Most Significant Bit)是标志位,用来表示是否还有后续字节。
0
表示这是最后一个字节。1
表示后面还有字节。
剩下的 7 位存储实际数值。
举例:
|
|
数字 1
的 MSB 是 0
,表示这是最后一个字节。
再看一个稍大的数字 150
:
|
|
官方计算示例 #
|
|
可以看到,MSB 只用于标识,不参与数值计算,最终拼接得到的二进制 00000010010110
就是 150。
消息结构 #
Protocol Buffers 的二进制消息是一系列键值对。编码时并不存储字段名称,而是使用字段编号(field number)作为键,类型则由一个叫 wire type 的值表示。
wire type #
可以理解为“底层存储格式”,不同类型的字段对应不同的 wire type。
ID | Name | 说明 / 对应类型 |
---|---|---|
0 | VARINT | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | I64 | fixed64, sfixed64, double |
2 | LEN | string, bytes, 嵌套 message, packed repeated |
3 | SGROUP | group start(已弃用) |
4 | EGROUP | group end(已弃用) |
5 | I32 | fixed32, sfixed32, float |
字段头(tag) 也是一个 Varint,由以下两部分组成:
- 高位部分:字段编号(field number)左移 3 位
- 低 3 位:wire type
例如: 一个字节的结构可以粗略理解为:
- 第 1 位:标识 Varint 是否还有后续字节(若是 Varint 类型)
- 接下来的几位:字段编号
- 最后 3 位:wire type
整数编码 #
bool 和 enum #
bool
与enum
都使用int32
编码。bool
的值编码为0
(false)或1
(true)。
有符号整数 #
Varint 本身只能表示无符号整数,如果直接把负数当成普通整数来编码,就会被视为一个很大的无符号数,占用的字节数也会增多。 为了让负数也能紧凑地表示,Proto 在编码前会使用 ZigZag 进行转换,把有符号整数映射到无符号整数空间。
为什么需要这样的转换,可以先看计算机中**补码(Two’s Complement)**的表示方式:
|
|
在 8 位系统中,-5
的补码是 11111011
。
如果把 5
和 -5
相加:
|
|
负数的补码设计,就是为了让它能与对应的正数直接相加得到 0。 但对 Varint 来说,这个“补码”只是一个极大的无符号值(这里是 251),导致编码冗长。
ZigZag 的作用就是重新排列整数到无符号空间,让靠近 0 的正负数都能获得更短的编码。 映射规律如下:
原始值 | ZigZag 编码 |
---|---|
0 | 0 |
-1 | 1 |
1 | 2 |
-2 | 3 |
… | … |
计算公式(以 32 位为例):
|
|
其中:
n << 1
:左移一位,为符号腾出空间。n >> 31
:提取符号位(正数全 0,负数全 1)。- 异或运算保证正负数在无符号整数中交替分布。
经过 ZigZag 转换后,再用普通 Varint 编码,就能在保持正负数信息的同时,保证数值越接近 0,占用的字节越少。
固定长度类型 #
对于 double、fixed64 等需要固定大小的类型,直接占用对应的字节数:
double
/fixed64
对应 wire type I64,占 8 字节。fixed32
/float
对应 wire type I32,占 4 字节。
长度限定(Length-delimited) #
string、bytes、嵌套 message、packed repeated 等类型,使用 wire type LEN 编码。 其结构是:
|
|
其中 长度 也是 Varint 编码。
示例:
|
|
当 b
的值是 "testing"
时,编码结果为:
|
|
逐字节解释:
12
:字段编号 2(binary 0010)和 wire type 2 组合07
:字符串长度 774 65 73 74 69 6e 67
:ASCII 字符串 “testing”
嵌套 Message #
子消息字段同样使用 LEN 编码。 例如:
|
|
若 Test1.a = 150
,编码结果为:
|
|
解释:
1a
:字段编号 3,wire type 203
:长度 308 96 01
:子 message 的编码内容(字段 1 的值为 150)
List(repeated 字段) #
在 proto 中,repeated
表示一个字段可以重复多次,相当于列表(list)。
数值类型的 repeated
字段在 proto3 中默认使用 packed 编码,这是一种更节省空间的方式。
packed 编码原理 #
packed 会把所有元素打包进一个字段,而不是为每个元素重复写入字段头。 编码格式:
|
|
例如:
|
|
当 nums = [3, 270]
时,编码结果是:
|
|
逐字节解释:
0A
:字段编号 1,wire type 2(Length-delimited)。03
:后续数据总长度为 3 个字节。03
:第一个数 3 的 Varint 编码。8E 02
:第二个数 270 的 Varint 编码。
这样只写一次字段头,比非 packed 模式(写多次字段头)更紧凑。
map #
map
在底层等价于嵌套 message 的 repeated 字段:
|
|
实际等价于:
|
|
Proto 消息大小限制 #
- 单个序列化后的 Proto 消息最大不能超过 2 GiB。
- 超过此限制,大多数实现会拒绝序列化或解析。