跳到主要内容

Protobuf 原理与编码方法

IDL

接口描述语言(Interface Description Language)

Protobuf 原理

当我拥有一个 .proto 文件,并使用相关命令生成 .pb.go 文件时,实际上是在进行 Protocol Buffers(Protobuf)的编译过程。这个过程涉及将 .proto 文件(定义了数据结构和服务接口)转换成特定编程语言的源代码文件。在这个例子中,目标语言是 Go。下面详细解释这个过程以及这样做的原因。

生成 .proto 的过程

编写 .proto 文件

首先,我在 .proto 文件中定义数据结构(消息)和服务(如果用于 gRPC)。这个文件作为数据交换的蓝图,可以跨多种语言和平台使用。

使用 Protobuf 编译器

然后,我使用 Protobuf 编译器 protoc.proto 文件编译为目标语言的代码。对于 Go,通常会生成 .pb.go 文件。

编译过程

在编译过程中,protoc 解析 .proto 文件,并生成包含所有必要 Go 代码的 .pb.go 文件。这些代码包括。

对于每个消息类型,一个对应的 Go 结构体。

每个字段的 Getter 方法。

消息类型的序列化(Marshal)和反序列化(Unmarshal)方法。

如果定义了服务(用于 gRPC),还会生成接口和客户端存根。

这样做的原因

跨语言的结构定义。Protobuf 允许我定义语言中立的数据结构。这意味着我可以在一个系统中使用 Go,而在另一个系统中使用 Java,只需共享 .proto 文件即可。

高效的数据序列化。Protobuf 旨在实现高效的数据序列化。生成的代码可以快速将数据结构序列化为二进制数据,比 JSON 或 XML 更高效。

类型安全。由于每个字段的类型都在 .proto 文件中明确定义,生成的代码自然是类型安全的。

兼容性。Protobuf 设计了版本控制和字段编号系统,使得数据结构可以向前兼容和向后兼容。

示例

假设我有一个名为 sumingcheng.proto 的文件,内容如下:

syntax = "proto3";

message Person {
string name = 1;
int32 id = 2;
bool has_pet = 3;
}

我可以使用以下命令生成 pb.go 文件:

protoc --go_out=. sumingcheng.proto

执行这个命令后,Protobuf 编译器 protoc 会解析 sumingcheng.proto,并生成一个 sumingcheng.pb.go 文件,其中包含 Person 结构体的定义,以及相关的序列化和反序列化方法。

通过这个过程,我就可以在 Go 程序中使用 Person 结构体,同时享受 Protobuf 提供的跨平台兼容性和高效数据编码等优点。

Protobuf 的高性能原因

性能优化特性描述
紧凑的数据格式二进制存储减少空间占用,数字标签替代字段名,进一步缩小数据大小。
可变长度编码(Varint)对小数值使用更少的字节,优化整数类型数据的空间效率。
高效的序列化反序列化优化的代码生成实现快速编解码,预定义结构无需运行时解析。
明确的数据结构定义.proto 文件确保结构一致性,减少编解码歧义和错误。
兼容性强大的向前兼容和向后兼容能力,降低维护成本,避免频繁数据迁移。
多语言平台支持广泛的编程语言支持,实现高效的跨系统平台数据交换。

字段的标签(Tag)

唯一性

每个消息类型中的每个字段都应该有一个唯一的标签,用于在二进制格式中唯一标识各个字段。

编码效率

Protobuf 使用这些标签来有效地编码消息。在二进制格式中,并不存储字段名,而是存储字段的标签,这可以显著减少消息大小。

1 => 00000001  // 可优化
name => 01101110 01100001 01101101 01100101 // 不可优化

兼容性

向后兼容性。可以向消息中添加新的字段,只要为它们分配新的唯一标签。已有的代码能够解析这些消息,即使它们不理解新字段。

向前兼容性。可以重命名字段,或删除不再使用的字段。只要不将已删除字段的标签重新分配给新字段,已有的代码仍然可以解析这些字段。例如,新版本的应用程序能够处理旧版本生成的数据。如果旧版本的数据结构缺少新版本中添加的字段,新版本在解析时会使用字段的默认值,或在没有可用数据的情况下合理运行。

范围和使用

标签编号 1 到 15 使用一个字节进行编码,应该分配给频繁出现或重要的字段。

标签编号 16 到 2047 使用两个字节编码,可用于次常见的字段。

避免使用标签编号 19000 到 19999(Protobuf 系统保留),以及过大的标签编号。

Varint(Variable-length integer)

Varint 的编码原理

Varint(Variable-length integer)是一种用于编码整数的变长编码方式,每个字节的最高位用作标志位,1表示后面还有字节,0表示这是最后一个字节。

编码规则

每个 Varint 字节的最低 7 位用于存储实际的数值。最高位作为继续位,若该位为 1,表示后续字节仍属于该数字;若该位为 0,表示当前字节是该数字的最后一个字节。数字的最低有效字节先编码,即 LSB 在序列化流中最先出现。

示例

以数字 300 为例。

300 的二进制表示为 100101100

按每 7 位分组,从低位到高位:00101100000010

每组前面加上标志位。最高位组标志位为 0,其他组标志位为 1:

  • 10010110(0b10010110)
  • 00000010(0b00000010)

将这些字节合并,低位在前:10010110 00000010

这样,数字 300 被编码为 2 个字节:0xAC 0x02(十六进制表示)。

ZigZag 编码

在 Protobuf 中,Varint 用于存储整数,但直接存储负整数会占用更多空间。为了解决这个问题,Protobuf 使用 ZigZag 编码来处理有符号整数,使其能够高效地编码负数。

编码原理

ZigZag 编码通过将有符号整数映射为无符号整数,使得绝对值较小的整数(无论正负)都能映射到较小的无符号整数。这对 Varint 编码非常有效,因为小的无符号整数占用更少的空间。

对于 32 位整数,编码公式为 (n << 1) ^ (n >> 31)

对于 64 位整数,编码公式为 (n << 1) ^ (n >> 63)

示例

考虑整数 -1-2-3,它们的编码过程如下。

-1 ZigZag 编码后为 1,Varint 编码为 0x01

-2 ZigZag 编码后为 3,Varint 编码为 0x03

-3 ZigZag 编码后为 5,Varint 编码为 0x05

2 ZigZag 编码后为 4,Varint 编码为 0x04

通过 ZigZag 编码,负数被映射为较小的正整数,使用 Varint 编码后占用更少空间。

TLV 编码

数据定义

数据项 1(年龄)的类型为 0x01(预定义的类型码,表示整数),值为 28。数据项 2(姓名)的类型为 0x02(预定义的类型码,表示字符串),值为 "Alice"

TLV 编码步骤

数据项 1(年龄)

类型(Type)为 0x01,长度(Length)为 0x01(1 个字节),值(Value)为 0x1C(28 的十六进制表示)。编码结果为 0x01 0x01 0x1C

数据项 2(姓名)

类型(Type)为 0x02,长度(Length)为 0x05("Alice" 长度为 5),值(Value)为 'A' 'l' 'i' 'c' 'e'(ASCII 表示)。编码结果为 0x02 0x05 'A' 'l' 'i' 'c' 'e'

整体编码结果

将两个数据项的编码结果串联,得到完整的 TLV 编码:0x01 0x01 0x1C 0x02 0x05 'A' 'l' 'i' 'c' 'e'。在传输时,这些数据将转换为二进制格式:

0x01 -> 00000001
0x01 -> 00000001
0x1C -> 00011100
0x02 -> 00000010
0x05 -> 00000101
'A' -> 01000001 // ASCII码中 'A' 的二进制
'l' -> 01101100 // ASCII码中 'l' 的二进制
'i' -> 01101001 // ASCII码中 'i' 的二进制
'c' -> 01100011 // ASCII码中 'c' 的二进制
'e' -> 01100101 // ASCII码中 'e' 的二进制

解码过程

接收方解析步骤如下。首先,读取 0x01,确定数据类型为整数。接着,读取 0x01,确定值长度为 1 字节。然后,读取 0x1C,得到年龄 28。接下来,读取 0x02,确定数据类型为字符串。随后,读取 0x05,确定值长度为 5 字节。最后,读取接下来的 5 个字节,得到姓名 "Alice"

通过类型和长度字段,接收方能够正确解析数据,即使不知道具体的数据项内容。