当前位置: 首页 > news >正文

protobuf编码原理

1. 引言

本文将解释 Protocol Buffers(protobuf) 如何将数据编码到文件或传输线路(wire)上。

本文描述的是 protocol buffer 的“wire format”(传输格式)——“wire format”(传输格式)定义了消息在传输中的实际结构细节,以及在磁盘上占用的空间大小。

在日常开发中使用 protobuf 时通常不需要理解这些底层细节,
但如果想进行性能或空间优化,这些知识会非常有用。

Protoscope 是一种非常简单的语言,用于描述 protocol buffer 的底层传输格式片段。
将在示例中使用Protoscope来直观地展示不同消息的编码方式。

Protoscope 的语法由一系列 token(标记) 组成,每个标记都对应特定的字节序列,如:

  • 反引号包裹的内容(70726f746f6275660a)表示原始十六进制字节序列;会被直接编码为十六进制字节。
  • 引号包裹的内容("Hello, Protobuf!")表示 UTF-8 字符串;等价于 48656c6c6f2c2050726f746f62756621(即 “Hello, Protobuf!” 的 ASCII 编码)。

随着后续讲解的深入,会逐步引入更多 Protoscope 语法特性。

也可以使用 Protoscope 工具 将已编码的 protobuf 数据反解析为文本。
查看 https://github.com/protocolbuffers/protoscope/tree/main/testdata 了解更多示例。

本文的所有示例均假设使用的是 Edition 2023 或更高版本。

2. 一个简单的消息示例(A Simple Message)

假设有如下非常简单的消息定义:

message Test1 {int32 a = 1;
}

在应用程序中,创建了一个 Test1 消息,并将字段 a 设置为 150
然后将此消息序列化(serialize)到输出流中。

如果查看序列化后的字节内容,会看到:

08 96 01

这三个字节非常短小,但它们代表什么呢?【第一个字节 0x08 是标签(tag): tag = ( field_number < < 3 ) ∣ wire_type \text{tag} = (\text{field\_number} << 3) | \text{wire\_type} tag=(field_number<<3)wire_type。】
如果使用 Protoscope 工具 来解析这些字节,它会显示出类似这样的结果:

1: 150

也就是说,Protoscope知道这个消息的字段编号是 1,值是 150
接下来的内容将解释——protobuf 是如何知道这些信息的。

3. 基于 128 的变长整数(Base 128 Varints)

可变长度整数(varint) 是 Protocol Buffers 传输格式(wire format)的核心概念。
varint允许使用 1 到 10 个字节 编码一个无符号 64 位整数,数值越小,所需的字节数越少。

在 varint 的编码中,每个字节都包含一个 延续位(continuation bit),它用于表示后续字节是否仍属于当前 varint。
这个延续位是字节的 最高有效位(MSB, Most Significant Bit),有时也被称为“符号位(sign bit)”。
剩下的 低 7 位 才是真正的数值负载(payload)。
最终的整数值由这些 7 位负载按顺序拼接组成。

如,数字 1 的编码是:

01

它只有一个字节,因此 最高位(MSB)未被设置(为 0):

0000 0001
^ msb

而数字 150 的编码则是:

9601

即两个字节:

10010110 00000001
^ msb    ^ msb

如何确认这确实表示 150 呢?

  • 1)首先,从每个字节中去掉最高位(MSB)。
    MSB仅用于标识“后续是否还有字节”。
    因为这里的第一个字节 MSB=1,说明后面还有一个字节。
  • 2)每个字节的低 7 位数据以 小端序(little-endian) 存储。
    因此,需要将它们反转为 大端序(big-endian)。
  • 3)将得到的 7 位片段拼接起来,并按无符号 64 位整数解释。

过程如下:

10010110 00000001        // 原始输入
0010110  0000001         // 去掉延续位
0000001  0010110         // 转换为大端序
00000010010110            // 拼接结果

计算得出:

128 + 16 + 4 + 2 = 150

这就是 varint 编码的解码过程。

由于 varint 在 protobuf 中非常关键,在 Protoscope 语法 中,通常直接将varint表示为普通整数。
1509601 等价:

150 ≡ `9601`

3. 消息结构(Message Structure)

一个 Protocol Buffers 消息(message) 是由一系列 键值对(key-value pairs) 组成的。

在二进制形式中,消息只使用字段的 编号(field number) 作为键。
字段的 名称和声明类型(name 与 declared type)只能在解码端通过 .proto 文件的消息定义来确定。

Protoscope 工具无法访问 .proto 定义文件,因此在展示时它只能显示字段编号,而无法显示字段名称或类型。

当一条消息被编码时,每个 键值对(key-value pair) 都会被转换为一个 记录(record),该记录由 字段编号(field number)、线类型(wire type) 和 数据负载(payload) 组成。

wire type 告诉解析器接下来负载(payload)的大小,从而使得旧版本的解析器能够跳过它无法识别的新字段。
这种结构化方案通常被称为 标签-长度-值(Tag-Length-Value, TLV)。

protobuf有 6种 Wire Type:

ID名称用途
0VARINTint32, int64, uint32, uint64, sint32, sint64, bool, enum
1I64fixed64, sfixed64, double
2LENstring, bytes, embedded messages, packed repeated fields
3SGROUPgroup 起始(已弃用)
4EGROUPgroup 结束(已弃用)
5I32fixed32, sfixed32, float

一个记录的 标签(tag) 是通过以下公式计算得到的 varint:
tag = ( field_number < < 3 ) ∣ wire_type \text{tag} = (\text{field\_number} << 3) | \text{wire\_type} tag=(field_number<<3)wire_type

换句话说,当对字段的 varint 进行解码后:

  • 低 3 位 表示 wire type
  • 其余高位 表示 字段编号(field number)

再次回顾前面的简单示例。
现在知道,字节流中的第一个数字总是一个 varint 键(key),
在这里它是:

08

去掉最高位(MSB)后,可以写作:

000 1000
  • 取 最后 3 位:000 → 表示 wire type = 0(VARINT)
  • 向右移 3 位:0001 → 表示 field number = 1

因此,在 Protoscope 语法中,这个标签可以写为:

1:VARINT

wire type 为 VARINT (0),由此知道接下来需要解析一个 varint 作为负载。
如前所述,字节序列 9601 解码为 150,于是得到了完整的记录:

1:VARINT 150

3.1 Protoscope 的类型推断

在 Protoscope 语法中,如果在冒号 (:) 后存在空格,则Protoscope会自动推断类型(即 wire type),方式是通过查看下一个 token 并根据其形式进行猜测。
这些规则在 Protoscope 的 language.txt 文档 中有详细描述。

如:

写法推断结果
1: 150由于后面是 varint,因此推断为 VARINT
2: {}由于 {} 表示长度限定的结构,因此推断为 LEN
3: 5i32由于后缀为 i32,推断为 I32

4. 更多整数类型(More Integer Types)

4.1 布尔(bool)与枚举(enum)

bool 与 enum 在编码时都按照 int32 处理。
特别地,布尔值总是编码为:

编码
false00
true01

在 Protoscope 中,falsetrue 是这两个字节串的别名(aliases)。

4.2 有符号整数(Signed Integers)

正如上一节所示,所有与 wire type = 0 对应的 Protocol Buffers 类型都使用 varint 编码。
然而,varint 是无符号的(unsigned),因此不同的有符号类型(如 sint32 / sint64int32 / int64)在表示负数时采用了不同的编码方式。

4.2.1 intN 类型(int32/int64)

intN 类型使用 二进制补码(two’s complement) 来编码负数。
这意味着它们在 64 位无符号整数的视角下,最高位始终为 1。
结果就是——负数编码后通常需要使用 完整的 10 个字节。

如,-2 在 Protoscope 中被转换为:

11111110 11111111 11111111 11111111 11111111
11111111 11111111 11111111 11111111 00000001

这是数字 2 的 二进制补码(two’s complement),
按照无符号算术定义为~0 - 2 + 1,其中 ~0 表示所有位都为 1 的 64 位整数。
理解这一过程有助于明白为什么会出现那么多的 1。

4.2.2 sintN 类型(sint32/sint64)

sintN 类型采用 ZigZag 编码 来表示有符号整数,而不是二进制补码。

  • 对于 正整数 p:编码为 2 * p(偶数)
  • 对于 负整数 n:编码为 2 * |n| - 1(奇数)

这种方式在正负数之间“之字形”(zig-zag)分布,因此得名。
如:

原始值 (Signed Original)编码值 (Encoded As)
00
-11
12
-23
0x7fffffff0xfffffffe
-0x800000000xffffffff

换句话说,每个值 n 的 ZigZag 编码如下:

  • 对于 sint32
    (n << 1) ^ (n >> 31)
    
  • 对于 sint64
    (n << 1) ^ (n >> 63)
    

sint32sint64 被解析时,解码器会自动将其恢复为原始的有符号整数。

在 Protoscope 中,如果在整数后加上 z 后缀,表示该数使用 ZigZag 编码。
如:

-500z

等价于 varint 编码后的值:

999

4.3 非 Varint 数值类型(Non-varint Numbers)

非 varint 类型的数值编码方式相对简单:

  • double 与 fixed64 → wire type = I64 表示一个 固定长度 8 字节 的数据块。
    其中 double 使用 IEEE 754 双精度浮点格式 编码。
    如:
    5: 25.4        // double
    6: 200i64      // fixed64
    
  • float 与 fixed32 → wire type = I32 表示一个 固定长度 4 字节 的数据块。
    float 使用 IEEE 754 单精度浮点格式 编码。
    如:
    25.4i32        // float
    200i32         // fixed32
    

在这些情况下,Protoscope 会根据数值的形式自动推断 I32 或 I64 类型。

5. 长度定界记录(Length-Delimited Records)

长度前缀(Length prefix) 是 Protocol Buffers 二进制格式中的另一个核心概念。
LEN 类型的字段具有动态长度,长度由紧跟在标签(tag)之后的 varint 指定,随后是实际的负载数据(payload)。

假设有如下消息定义:

message Test2 {string b = 2;
}

字段 b 是一个字符串,而字符串在编码时使用 LEN 类型。
如果将 b 设置为 "testing",那么它会被编码为一个 字段号为 2 的 LEN 记录,内容是 ASCII 字符串 "testing"

编码结果如下:

12 07 74 65 73 74 69 6e 67

逐步拆解:

12    →  tag = 00010 010  → 字段号 2,类型 LEN  
07    →  长度 = 7  
[74 65 73 74 69 6e 67] → UTF-8 编码的 "testing"

其中的 07 是一个 int32 varint,因此字符串的最大长度理论上可以达到 2GB。

在 Protoscope 语法中,这段编码可以写作:

2:LEN 7 "testing"

但手动重复写字符串长度很麻烦(尤其在引号包围的字符串中)。
Protoscope 支持使用花括号 {} 自动生成长度前缀,如:

{"testing"}   // 等价于 7 "testing"

因此可以更简洁地写为:

2: {"testing"}

bytes 类型字段的编码方式与 string 相同。

5.1 子消息(Submessages)

子消息字段同样使用 LEN 类型。
如:

message Test1 {int32 a = 1;
}message Test3 {Test1 c = 3;
}

如果 Test1 中的字段 a (即 Test3c.a 字段)被设置为 150,则编码结果为1a03089601,将其分解为:

1a 03 [08 96 01]

拆解如下:

1a → tag = 3:LEN  【0x1a = '0b11010' = 0x3<<3 | 0x2】
03 → 长度 = 3  
[08 96 01] → 子消息 Test1 的编码结果(来自前面的示例)

也就是说,子消息的编码方式与字符串完全一致。在 Protoscope 中,可以更简洁地表示为:

3: {1: 150}

6. 缺失字段(Missing Elements)

未设置的字段不会被编码。
也就是说,缺失字段 = 直接省略。
因此,一个“很大”的 proto 消息如果只设置了少数字段,其实际编码会非常紧凑稀疏。\

7. 重复字段(Repeated Elements)

从 Edition 2023 开始,所有 基础类型(primitive type) 的重复字段(即除 stringbytes 外的scalar type 标量类型)默认采用 “packed” 模式 编码。

在 packed 模式 下,重复字段不会为每个元素分别生成一条记录,而是作为单个 LEN 类型的记录,其中所有元素的编码结果被依次拼接。
解码时,解析器会从 LEN 记录中逐个解析元素,直到负载耗尽。
如:

message Test4 {string d = 4;repeated int32 e = 6;
}

如果构造一个 Test4 实例:

  • d = "hello"
  • e = [1, 2, 3]

那么它可以被编码为:

32 06 03 8e 02 9e a7 05

在 Protoscope 中表示为:

4: {"hello"}
6: {3 270 86942}

如果该重复字段被显式设置为 expanded 模式(覆盖默认的 packed 状态),
或字段类型本身不支持pack(如字符串或子消息),则每个元素会分别生成独立记录:

6: 1
6: 2
4: {"hello"}
6: 3

注意:

  • 同一字段的记录不必连续出现;
  • 仅要求相同字段的记录之间的顺序被保留;
  • 不同字段的记录可以交错排列。

只有重复的基础数值类型字段(repeated primitive numeric types)可以被声明为 “packed” 模式。
这些类型通常使用 VARINT、I32 或 I64 作为其底层 wire type。

需要注意的是,尽管通常不会在一个 packed 重复字段中编码多个键值对(key-value pair),解析器必须能够接受多个这样的键值对。
在这种情况下,多个键值对的负载(payload)应被拼接起来。
每个键值对都必须包含完整数量的元素。

如,以下两段编码虽然分成了两个记录,但语义等价且都是有效的:

6: {3 270}
6: {86942}

解析器必须能够处理这种情况。
换句话说,解析器必须能将一个字段无论是以 packed 还是 非 packed 方式编码的内容都正确解析。
这也意味着可以前向兼容和后向兼容地为已有字段添加 [packed = true] 属性。

7.1 Oneof 字段

Oneof 字段 的编码方式与普通字段完全相同。
也就是说,它们的编码规则与是否属于 oneof 无关。
oneof 的规则仅在语义层面起作用,而不会影响底层 wire 格式。

7.2 “最后一个生效”原则(Last One Wins)

在正常情况下,消息中不会包含同一个 非重复字段(non-repeated field) 的多个实例。
但解析器必须能够正确处理这种情况。

  • 对于数值类型和字符串:如果同一个字段多次出现,解析器会采用最后出现的值。
  • 对于嵌套消息字段(embedded message fields):解析器会将多个实例合并(merge),类似于调用 Message::MergeFrom 方法:
    • 后一个实例的单值字段会覆盖前一个;
    • 嵌套消息会被合并;
    • 重复字段的元素会被拼接。

这种机制的结果是:

  • 解析连接的两个消息的效果,与分别解析它们再合并结果对象的效果完全相同。

也就是说,以下两种写法是等价的:

MyMessage message;
message.ParseFromString(str1 + str2);

等价于:

MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);

这种特性在某些场景下非常有用,因为它允许在不知道消息类型的情况下,仅通过拼接就能合并两个消息。

7.3 Map 字段

map 字段实际上只是一个特殊的 repeated 字段语法糖。

如:

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

等价于:

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

因此,map 的编码方式几乎与重复消息字段完全相同:

  • 编码为一系列 LEN 类型的记录,每个记录包含两个字段(key 和 value)。

唯一的例外是:

  • map 的键值顺序在序列化时不保证被保留。

8. Groups(组)

Group 是一种已经被弃用(deprecated)的旧特性,不应再使用,但它仍然存在于底层的 wire 格式中,因此需要简单说明。

Group 的作用类似于子消息(submessage),但不同之处在于它使用特殊的起止标签(tags)来标识边界,而不是用 LEN 长度前缀。

如,一个字段号为 8 的 group:

  • 以 8:SGROUP 标签开始(SGROUP 记录无负载,表示 group 的起点);
  • 包含该 group 的所有字段;
  • 以 8:EGROUP 标签结束(EGROUP 记录也无负载,表示 group 的终点)。

字段号必须匹配。
如果遇到不匹配的结束标签,如在期望 8:EGROUP 时出现 7:EGROUP,则说明该消息格式错误(malformed)。

Protoscope 为书写 groups 提供了一种更简洁的语法。
传统写法如下:

8:SGROUP1: 23: {"foo"}
8:EGROUP

而使用 Protoscope,可以写成:

8: !{1: 23: {"foo"}
}

这段语法会自动生成相应的起始和结束组标记。
!{} 语法只能出现在一个无类型标签表达式(un-typed tag expression)之后,如 8:

9. 字段顺序(Field Order)

.proto 文件中,字段编号(field numbers)可以以任意顺序声明。
字段的声明顺序不会影响消息的序列化方式。

当消息被序列化时,其字段的输出顺序并不固定。
无论是已知字段还是 未知字段,输出顺序都取决于实现细节,而这些细节在不同实现中可能有所不同,甚至在将来的版本中也可能发生变化。

因此,protocol buffer 解析器必须能够解析任意顺序的字段。

9.1 含义与注意事项(Implications)

  • 不要假设序列化后消息的二进制输出是稳定的。
    这在消息中嵌套其他序列化的 proto(如 bytes 字段)时尤为重要。
  • 默认情况下,对同一个消息对象多次调用序列化方法,输出的字节流不一定完全相同。
    即默认序列化是非确定性的(non-deterministic)。
    • 确定性序列化(deterministic serialization) 仅保证在同一个二进制程序内输出一致,不同版本的程序可能仍然输出不同的字节序列。
  • 因此,下列检查可能会失败:
    foo.SerializeAsString() == foo.SerializeAsString()
    Hash(foo.SerializeAsString()) == Hash(foo.SerializeAsString())
    CRC(foo.SerializeAsString()) == CRC(foo.SerializeAsString())
    FingerPrint(foo.SerializeAsString()) == FingerPrint(foo.SerializeAsString())
    
  • 以下是一些逻辑上等价的消息(foobar)却可能序列化出不同字节输出的情形:
    • bar 由旧版本服务器序列化,部分字段被视为未知;
    • bar 由另一种编程语言实现的服务器序列化,字段顺序不同;
    • bar 中的某个字段使用了非确定性序列化;
    • bar 中的某个字段本身包含一个以不同方式序列化的子消息;
    • bar 由新版本服务器序列化,由于实现变化,字段顺序不同;
    • foobar 是相同子消息的不同连接顺序。

10. 编码后的 Proto 大小限制(Encoded Proto Size Limitations)

序列化后的 Protocol Buffer 消息必须小于 2 GiB。
许多实现会拒绝序列化或解析超过该限制的消息。

11. 精简参考卡(Condensed Reference Card)

以下是 Protobuf 二进制格式的核心规则速查表:

message    ::= (tag value)*tag        ::= (field << 3) bit-or wire_type;encoded as uint32 varintvalue      ::= varint      for wire_type == VARINT,i32         for wire_type == I32,i64         for wire_type == I64,len-prefix  for wire_type == LEN,<empty>     for wire_type == SGROUP or EGROUPvarint     ::= int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64;encoded as varints (sintN are ZigZag-encoded first)i32        ::= sfixed32 | fixed32 | float;encoded as 4-byte little-endian(float is IEEE 754 single-precision);memcpy of equivalent C types (u?int32_t, float)i64        ::= sfixed64 | fixed64 | double;encoded as 8-byte little-endian(double is IEEE 754 double-precision);memcpy of equivalent C types (u?int64_t, double)len-prefix ::= size (message | string | bytes | packed);size encoded as int32 varintstring     ::= valid UTF-8 string (e.g. ASCII);max 2GB of bytesbytes      ::= any sequence of 8-bit bytes;max 2GB of bytespacked     ::= varint* | i32* | i64*,consecutive values of the type specified in `.proto`

更多信息可参阅

  • Protoscope 语言参考(Protoscope Language Reference)

其中:

  • message := (tag value)*
    消息(message) 被编码为一系列 标签(tag) 与 值(value) 的配对序列,可包含零个或多个这样的对。
  • tag := (field << 3) bit-or wire_type
    标签(tag) 是由 .proto 文件中定义的 字段编号(field number) 与 wire_type 组合而成。
    其中,最低的 3 个比特位存储 wire_type,剩余的高位表示字段编号。
  • value := varint for wire_type == VARINT, ...
    值(value) 的存储方式取决于标签中指定的 wire_type。
  • varint := int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64
    varint 类型可用于存储上述任意数据类型。
  • i32 := sfixed32 | fixed32 | float
    fixed32(即 wire_type = I32)可用于存储上述任意数据类型。
  • i64 := sfixed64 | fixed64 | double
    fixed64(即 wire_type = I64)可用于存储上述任意数据类型。
  • len-prefix := size (message | string | bytes | packed)
    长度前缀类型(length-prefixed) 的值由两部分组成:
    • 长度(size),以 varint 形式编码;
    • 实际数据内容,可为 message、string、bytes 或 packed。
  • string := valid UTF-8 string (e.g. ASCII)
    字符串必须使用 UTF-8 编码(如 ASCII 也是 UTF-8 的子集)。
    字符串长度上限为 2GB。
  • bytes := any sequence of 8-bit bytes
    bytes 字段可存储任意二进制数据,大小上限同样为 2GB。
  • packed := varint* | i32* | i64*
    packed 类型 用于连续存储多个相同类型的数值(如在 .proto 中定义的 repeated 字段)。
    在 packed 编码中,只有第一个元素包含标签(tag),后续元素仅存储值,从而节省空间。

参考资料

[1] protobuf doc Protocol Buffers Encoding

http://www.dtcms.com/a/597407.html

相关文章:

  • 港股实时行情API接入全流程
  • 公司网站制作计入什么科目重庆建筑网
  • Next.js第一章(入门)
  • 数据管理战略|数字化改革的四个体系
  • 设备外绝缘强度将随海拔的升高而降低,导致设备允许的最高工作电压下降。
  • crm系统设计东莞百度seo地址
  • 2025年第四期DAMA数据治理CDGA考试练习题
  • 面向对象(上)-package关键字的使用
  • 自己做电影网站违法吗dz网站收款即时到账怎么做的
  • 电子商务网站开发语言wordpress读取相册
  • 全面了解云手机的安全性
  • 数据结构代码练习DAY2
  • 声网SDK让音视频开发效率翻倍
  • 网站图片尺寸如何免费建站
  • 360做网站和推广怎么样网站后端架构如何做
  • 从零到一构建数据驱动的业务落地
  • 测试题-6
  • 那个网站上有做婚礼布场样图的营销型网站有意义吗
  • 安卓和苹果手机通用的备忘录app测评
  • 宸建设计网站哪里能做网页建站
  • VsionMaster筛选机错误情况
  • Spring Boot 面试专题及答案
  • 利用k8s client-go库创建CRD的informer的操作流程
  • 企业网站建设的参考文献wordpress的小程序
  • MATLAB视频目标追踪中的块匹配算法详解
  • Xilinx FIFO Generate IP核(9):FIFO清空操作详解
  • 网站后台建设公司永久免费云主机
  • 佛山市建设工程交易中心网站运营怎么自学
  • 【开题答辩全过程】以 基于Python的Bilibili平台数据分析与可视化实现为例,包含答辩的问题和答案
  • 转转客服IM聊天系统背后的技术挑战和实践分享