一文解析 Protobuf 好在哪里。
一、什么是protobuf:数据序列化的一种方式
Protocol Buffers(通常缩写为 protobuf),它是 Google 开发的一种高效、跨平台的结构化数据序列化机制。
1.1 Protobuf的优点
它主要有四个好处:
1,空间小+速度快。空间小,指的是序列化后二进制数据的大小,和json、xml相比,占用的空间更小。速度快,指的是序列化、反序列化的速度,和json、xml相比更快。(和protobuf的二进制编码方式有关)
2,跨语言性好。使用 .proto 文件定义数据结构后,可以使用官方或社区提供的编译器 (protoc) 生成多种编程语言(如 C++, C#, Go, Java, Python,等)的源代码。这解决了不同语言编写的服务之间交换数据的难题,是构建异构系统(尤其是微服务架构)的理想选择。
3,清晰的接口定义和强类型。使用 .proto 文件定义数据结构后,生成的代码是强类型的,编译器(语言编译器或 IDE)能在开发阶段就捕获许多类型错误(例如赋值错误类型、访问不存在的字段),提高了代码的健壮性和可维护性。
4,优秀的向后/向前兼容性。旧代码可以读取新格式的数据, 新代码也可以读取旧格式的数据(见第四节)
1.2 与竞品的横向比较
protobuf 和 xml、json 横向对比
二、protubuf 为什么那么好?
Protobuf 之所以具有 1.1 中的四个优点,主要是因为两个东西,一是 TLV + 二进制编码,二是 .proto 文件。
2.1 TLV + 二进制编码
2.11 TLV
TLV指的是数据传输的格式。
T:Tag。字段的唯一标识。 标识数据的类型。要用Varin编码。
Tag = (字段编号 << 3) | Wire Type。例如字段编号=2,Wire Type=2 =》 (2<<3)|2 = 18,
后面再用Varin编码, 18 =》 0x12
L:Length。仅在 Wire Type=2 时存在。要用Varin编码。
当 Value 部分的长度是可变且无法从类型本身推断时(如Wire Type=2),需要明确指定其长度。
后面再用Varin编码,如长度是 5 =》 Varint 编码后为 0x05 (1 字节)。
V:Value。该字段的实际值。编码方式,由 wire_type 决定。
Protobuf 定义了 6 种wire_type :
0
(Varint): 用于 int32, int64, uint32, uint64, sint32, sint64, bool, enum。用 Varint 编码。1
(64-bit): 用于 fixed64, sfixed64, double。固定 8 字节。固定长度编码(Fixed32/Fixed64),8 字节定长。2
(Length-delimited): 用于 string, bytes, 嵌套消息 (messages), packed repeated fields。Value
前有Length
。长度分隔类型(Length-Delimited)。3
(Start group): 已废弃。4
(End group): 已废弃。5
(32-bit): 用于 fixed32, sfixed32, float。固定 4 字节。固定长度编码(Fixed32/Fixed64),4 字节定长。
2.12 常见的几种编码
1, base 128 Varints (针对≤28bit的正整数)
- 核心思想:
- 编码规则:
- 示例 (编码数字 300):
- 优缺点与适用场景:
2,ZigZag 编码(针对负整数的)
- 问题: 负整数(
int32
,int64
)如果直接用二进制补码表示,再用 Varint 编码,结果通常会很大(因为最高位是 1),占用很多字节,效率低下。 - 解决方案:
sint32
和sint64
类型使用 ZigZag 编码。 - 原理: 将有符号整数映射到无符号整数空间,使得小的负数和小的正数都能映射成小的无符号整数,从而可以用紧凑的 Varint 编码。
- 转换公式:
- 映射效果:
- 优势: 无论正负,绝对值小的数字都能被编码成小的无符号整数,从而用很短的 Varint 表示。例如,
-1
被映射成1
,只需一个字节 (0x01
) 编码。而直接用补码的int32
-1 (0xFFFFFFFF
) 用 Varint 编码需要 5 个字节! - 补充:Varints 编码的实质在于设法移除数字开头的 0 ⽐特, ⽽对于负数, 由于其数字⾼位都是 1, 因此 Varints 编码在此场景下失效, Zigzag 编码便是为了解决这个问题, Zigzag 编码的⼤致思想是⾸先对负数做⼀次变换, 将其映射为⼀个正数, 变换以后便可以使⽤ Varints 编码进⾏压缩, 这⾥关键的⼀点在于变换的算法, ⾸先算法必须是可逆的, 即可以根据变换后 的值计算出原始值, 否则就⽆法解码, 同时要求变换算法要尽可能简单, 以避免影响 Protobuf 编码、 解码的速度。
3,固定长度编码(Fixed32/Fixed64)
- 定长存储:无论数值大小,固定占用 4 字节(fixed32/sfixed32/float)或 8 字节(fixed64/sfixed64/double)。
- 适用场景:
4,长度分隔类型编码(Length-Delimited)
- TLV 结构:
- 示例:字符串 "hello"(UTF-8 编码,长度 5)
- 适用场景:
2.13 举例讲解 TLV+编码
message Person { int32 id = 1; // 字段编号=1, Wire Type=0 string name = 2; //字段编号=2, Wire Type=2 }
假设,id=42, name="Alice"。
id 字段的 TLV:
T (Tag): (1 << 3) | 0 = 8 -> Varint 编码后为 0x08 (1 字节)。0x08 是十六进制。
L (Length): 不存在 (因为 Wire Type 0 是固定含义,不需要显式长度)。
V (Value): 42 -> Varint 编码后为 0x2A (1 字节)。0x2A 是十六进制。
完整 TLV: 08 2A (2 字节)
name 字段的 TLV:
T (Tag): (2 << 3) | 2 = 16 | 2 = 18 -> Varint 编码后为 0x12 (1 字节)。
L (Length): "Alice" 的 UTF-8 字节长度是 5 -> Varint 编码后为 0x05 (1 字节)。
V (Value): "Alice" 的 UTF-8 字节: 0x41 (A), 0x6C (l), 0x69 (i), 0x63 (c), 0x65 (e)。
完整 TLV: 12 05 41 6C 69 63 65 (7 字节)
整个消息的二进制流: 08 2A 12 05 41 6C 69 63 65
2.2 .proto 文件
.proto 文件的好处。见1.1.
2,跨语言性好。使用 .proto 文件定义数据结构后,可以使用官方或社区提供的编译器 (protoc) 生成多种编程语言(如 C++, C#, Go, Java, Python,等)的源代码。这解决了不同语言编写的服务之间交换数据的难题,是构建异构系统(尤其是微服务架构)的理想选择。
3,清晰的接口定义和强类型。使用 .proto 文件定义数据结构后,生成的代码是强类型的,编译器(语言编译器或 IDE)能在开发阶段就捕获许多类型错误(例如赋值错误类型、访问不存在的字段),提高了代码的健壮性和可维护性。
4,优秀的向后/向前兼容性。旧代码可以读取新格式的数据, 新代码也可以读取旧格式的数据(见第四节)
三、接收方如何解析字段?—— 没有字段名如何工作
如题,我们知道protobuf传输数据的时候,是不传字段名的,那接受方怎么知道接受的是谁呢。
核心原理:字段编号 + 预共享Schema(.proto文件)
发送前:双方需完全相同的.proto文件(如 user.proto)
message User { int32 id = 1; // 字段编号1 → int32类型 string name = 2; // 字段编号2 → string类型 }
接收方解析流程:
为什么不需要字段名?
字段编号是终极标识符。
.proto 中字段编号和类型绑定,接收方通过编号直接定位字段定义(如编号2=string类型的name)
Wire Type提供物理存储方案
告诉解析器如何读取数据(如跳过未知字段时:Wire Type=2 → 先读长度再跳过对应字节)
Schema即协议
.proto 文件是通信双方的权威数据字典,字段名只在生成代码时有用,传输时完全被数字编号替代。
四、之前定义的 message 新增字段,会怎么样
实战示例
原始 .proto文件
message User { int32 id = 1; string name = 2; }
.proto文件 新增字段
message User { int32 id = 1; string name = 2; // 安全新增字段 ↓ optional string email = 3; repeated string tags = 4; }