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。
- 超过此限制,大多数实现会拒绝序列化或解析。