Protobuf 编码原理以及编码结构

1、Protobuf 简介

定义

protobuf 是一种用于对结构数据进行序列化的工具,从而实现数据存储和交换。

主要用于网络通信中收发两端进行信息交互。所谓的“结构数据”是指类似于 struct 结构体的数据,可用于表示一个网络信息。

序列化:将结构数据或者对象转换成能够用于存储和传输的格式。

反序列化:在其他的计算环境中,将序列化后的数据还原为数据结构和对象。

从 “序列化”的字面上来理解,似乎C语言中的 struct 结构体就可以实现序列化:将结构数据填充到定义好的结构体中的对应字段即可,接收方再对结构体进行解析。

在单机的不同进程间通信时,使用 struct 结构体这种方式实现“序列化”和“反序列化”的功能问题不大,但是,在网络编程中,即面向网络中不同主机间的通信时,则不能使用 struct 结构体,原因在于:

  • 跨语言平台,不同语言的 struct 结构体定义方式不同,不能直接解析
  • struct 结构体存在内存对齐和 CPU 不兼容的问题。

因此在网络编程,实现序列化和反序列化,需要使用各个语言之间通用的组件,如 Jsonxmlprotobuf

优缺点

优点:

  • 性能高效: 所需要的数据量更少,相对于 JSON 和 xml,这意味更少的网络带宽、更快的解析速度
  • 语言无关、平台无关(protobuf 支持多种语言,多个平台)
  • 扩展性、兼容性强:只需要使用 protobuf 对结构数据进行一次描述,即可从各种数据流中读取数据结构,更新数据结构时不会破坏原有的程序。

缺点:

  • 自解释性较差,数据存储格式为二进制,需要通过 .proto 文件才能了解到内部的数据结构

编码优势

Protobuf(Protocol Buffers)相对于JSON和XML在网络传输方面有许多优势。下面会详细举例说明这些优势:

  1. 数据尺寸更小:

    • 举例:考虑一个包含用户信息的数据结构,如果使用JSON表示,可能看起来是这样:

      1
      2
      3
      4
      5
      {
      "name": "John Doe",
      "age": 30,
      "email": "johndoe@example.com"
      }
      1
      而相同的信息,如果使用Protobuf进行序列化,由于它是二进制格式,通常比这个JSON表示要小得多。这是因为JSON和XML通过文本表示数据和字段名,而Protobuf只包含必要的值信息,并且字段名在序列化的数据中是不包含的。
    • 优势:在网络传输时,数据的尺寸直接影响到传输所需时间。Protobuf能够极大减少传输的数据量,从而加快数据传输速度,降低带宽使用。

  2. 解析速度快

    • 举例:从服务器收到一段JSON和一段Protobuf编码的消息,虽然它们代表同样的数据,但是解析JSON需要解析文本,并将文本转化成相应的数据结构。而Protobuf作为二进制格式,解析它的速度要快得多,因为它直接对应于内存中的数据结构,无需进行复杂的解析和转换过程。
    • 优势:这意味着对于需要高性能的实时系统,比如游戏服务器或大规模的分布式系统,使用Protobuf可以极大地减少数据处理的时间,提高整体效率。
  3. 数据结构的定义和兼容性

    • 举例:当数据结构发生变化时,如增加一个新的字段,Protobuf可以通过其版本控制机制保证对旧版本的兼容性。在Protobuf中,即使接收方的数据结构定义落后(或领先)于发送方,仍能正确解析数据,因为每个字段都有唯一的标识符。
    • 优势:相比之下,JSON和XML在无说明的情况下,字段的添加或删除可能会导致解析失败或者错误。Protobuf的这一特性使得在不断变化的系统中维护向后兼容性变得容易。
  4. 类型安全

    • 举例:在Protobuf中,每个字段都有明确的类型,这就意味着如果你尝试将错误类型的数据赋值给字段,会在编译时或序列化时捕捉到错误。而在JSON和XML中,数据类型必须通过应用逻辑来检查和强制实现,这使得错误更难以发现和修正。
    • 优势:类型安全可以减少运行时的错误,确保数据的一致性和准确性,特别是在大型和复杂的系统中。

总之,Protobuf在网络传输方面相对于JSON和XML的优势在于更小的数据尺寸、更快的解析速度、改进的版本控制和数据兼容性,以及内置的类型安全性。这些特性使Protobuf成为高效、可靠数据交换的理想选择,特别适合用在性能敏感和规模庞大的系统中。

2、使用流程

安装

使用 protobuf 时,需要先根据应用需求编写 .proto 文件,定义消息体格式,例如:

1
2
3
4
5
6
7
8
syntax = "proto3";
package tutorial;

message Person {
required int32 id = 1;
repeated string name = 2;
optional int32 opt = 3; // optional field
}

消息定义

字段格式:限定修饰符 | 数据类型 | 字段名称 | = 字段编码值 | [字段默认值]

  • 限定修饰符包含 required\optional\repeated

Required :表示是一个必须字段,必须相对于发送方,在发送消息之前必须设置该字段的值,对于接收方,必须能够识别该字段的意思。发送之前没有设置 reuiqred 字段或无法识别 required 字段都会引发编码异常,导致消息被丢失。

Optional :表示是一个可选字段,可选与发送方,在发送消息时,可以有选择性的设置或者不设置该字段的值。对于接收方,如果能够识别可选字段就进行相应的处理,如果无法识别,则忽略该字段,消息中的其他字段正常处理。因为 optional 字段的特性,很多接口在升级版本中都把后来添加的字段都统一的设置为 optional 字段,这样老的版本无需升级程序也可以正常的与新的软件进行通信,只不过新的字段无法识别而已,因为并不是每个节点都需要新的功能,因此可以做到按需升级和平滑过渡。

Repeated :表示该字段可以包含 0~N 个元素。其特性和 optional 一样,但是每一次可以包含多个值。可以看作是在传递一个数组的值。

然后使用 protobuf 编译器(protoc 命令)将编写好的 .proto 文件生成目标语言文件(例如目标语言是 C++,则会生成 .cc 和 .h 文件),例如:

1
$protoc -I=$SRC_DIR --cpp_out=$DST_DIR $SRC_DIR/xxx.proto
  • $SRC_DIR 表示 .proto文件所在的源目录;
  • $DST_DIR 表示生成目标语言代码的目标目录;
  • xxx.proto 表示要对哪个.proto文件进行解析;
  • –cpp_out 表示生成C++代码。

示例:protoc -I . order.proto --cpp_out=.

编译完成后,将目标目录中生成的 xxx.pb.hpb.cc 文件引入工程中即可以实现使用 protobuf 进行序列化

1
g++ main_test.cpp pb.cc -0 main_test -lprotobuf

protobuf 的重点侧重于数据序列化而非数据结构化

3、底层原理

protobuf 序列化后所生成的二进制消息十分紧凑,得益于它的编码结构。

前提知识:

Varint :一种紧凑的表示数字的方法。它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数。这能减少用来表示数字的字节数。

举例来说,对于 int32 类型的数字,一般需要 4 个 byte 来表示。但是采用 Varint,对于很小的 int32 类型的数字,则可以用 1 个 byte 来表示。但是对于大的数字需要 5 个 byte来表示。从统计的角度来说,一般不会所有的消息中的数字都是大数,因此大多数情况下,采用 Varint 后,可以用更少的字节数来表示数字信息。更多的详细细节看后面的 Base128 Varint 。

protobuf 采用的就是这种变长的方式

编码原理

什么是编码

从字符集到存储在计算机中的内容,即(A -》 二进制编码)的过程就是编码

UTF-8 是针对 Unicode 的可变长度编码

Protobuf 是采用变长的方式来表示数据。意思是:如果数字本身比较大,那么其使用的比特位可以较多,但如果数字很小那么就应该使用较少的比特位,这就叫变长。

如何变长

对于每一个字节来说,第一个比特位如果是1那么表示接下来的一个比特依然要用来解释为一个数字,如果第一个比特为0,那么说明接下来的一个字节不是用来表示该数字的。也就是说对于每个8个比特(1字节)来说,它的有效载荷是7个比特,第一个比特仅仅用来标记是否还应该把接下来的一个字节解析为数字。

Protobuf 的编码就是基于变种的 Base128 的。

Base 64

base64 出现的原因

​ ASCII字符集只包含了128个字符,这包括英文字母、数字和一些控制字符(例如换行符、回车符)。这个字符集主要设计用来处理文本信息。在早期的计算机系统和一些现代的文本传输协议中,还广泛地使用这个字符集。

​ 然而,二进制文件(如图片、PDF文档、可执行文件等)包含的是二进制数据,这些数据的每个字节都可以是从0到255(二进制的00000000到11111111)的任意值。这代表了比ASCII字符集更广泛的信息范围,很多值在ASCII字符集中没有对应的字符,或者被用作控制字符(比如文件中的null字符,ASCII编码为0)。

如果你尝试在邮件中直接发送这样的二进制文件,问题就出现了。如果邮件系统只支持ASCII字符集的传输,那么它处理不了那些非ASCII范围内的字节值。在最坏的情况下,这些不可见或控制字符(如换行、回车或空字符)可能被邮件服务器解释为特殊的控制指令,导致发送的二进制数据在到达目标前就被更改或”破坏”了。

举一个简化的例子:假设你有一张图片文件,其中一部分二进制数据可能会被解释成ASCII中的”结束传输”控制字符。如果邮件系统在读到这部分数据时认为消息结束了,它就会停止读取余下的文件内容。这样,接收方得到的文件就是不完整的,因而无法正确地打开或查看。

为了解决这个问题,人们使用Base64编码。Base64将二进制数据转换为一个只由ASCII字符组成的字符串。这样,无论原始二进制数据中包含什么字节值,经过Base64编码后,所有的数据都被安全地映射到ASCII字符集内,可以安全地通过只支持ASCII字符集的邮件系统发送。接收方收到这个编码后的字符串,可以使用Base64解码,恢复成原始的二进制文件。

在计算机之前传输数据时,数据本质上是一串字节流。

TCP 协议可以保证被发送的字节流正确地达到目的地(至少在出错时有一定的纠错机制),所以本文不讨论因网络因素造成的数据损坏。

但数据到达目标机器之后,由于不同机器采用的字符集不同等原因,我们并不能保证目标机器能够正确地“理解”字节流。

Base 64 最初被设计用于在邮件中嵌入文件(作为 MIME 的一部分):它可以将任何形式的字节流编码为“安全”的字节流。

Base64 是一种基于 64 个可打印字符来表示二进制数据的表示方法。由于 2^6 = 64,所以每 6 个比特为一个单元,对应某个可打印字符。3 个字节有 24 个 比特,对应于 4 个 Base64 单元,即 3 个字节可由 4 个可打印字符来表示。

base64 应用场景:电子邮件附件、URL 参数传数据传输和存储、图像数据嵌入

Base64 定义

任意的字节流均可以使用 Base64 进行编码,编码之后所有字节均可以用数字、字母和 + / = 号进行表示,这些都是可以正常显示的 ascii 字符,即安全的字节。绝大部分的计算机和操作系统都对 ascii 有着良好的支持,保证了编码之后的字节流能被正确地复制、传播、解析。

base64 是由 64 个字符组成,大写 A-Z,小写 a-z,数字 0-9,两个符号( + 和 /)

Base128 Varints

Base64 存在的问题就是:编码后的每一个字节的最高两位总是 0,在不考虑 pad 的情况下,有效 bit 只占 bit 总数的 75%,造成大量的空间浪费。

因此提出:Base 128 Varints ,它是基于 Base 64 的

使用一个或多个字节对整数进行序列化,小的数字占用更少的字节。

Base 128 的大致实现思路:将字节流按 7 bits 进行分组,然后低位补 0

简单来说,Base 128 Varints 编码原理就是尽量只存储整数的有效位,高位的 0 尽可能抛弃。

对于 Base 128 Varints 编码后的每个字节,低 7 位用于存储数据,最高位用来标识当前字节是否是当前整数的最后一个字节,称为最高有效位(most significant bit,简称 msb)。msb 为 1 时,代表着后面还有数据;msb 为 0 时代表着当前字节是当前整数的最后一个字节。

1
2
3
4
5
6
举个例子:
+ 编码后的整数 300:第一个字节的 msb 为 1,最后一个字节的 msb 为 0.
将这两个字节解码成整数,需要三个步骤:
1. 去除 msb
2. 将字节流逆序 (msb 为 0 的字节存储原始数据的高位部分,小端模式)
3. 最后拼接所有的 bits

编码过程

具体过程:

  • 将数据按每 7 bits 一组拆分
  • 逆序每一个组
  • 添加 msb

编码结构

一个 message 编码由一个个的 field 组成,每个 field 更具类型将有如下两种格式:

  • Tag-Length-Value :编码类型表中 Type = 2 即 Length-delimited 编码类型将使用这种结构
  • Tag-Value :编码类型表中 Varint64-bit32-bit 使用这种结构

Tag 的定义如下

Tag = (field_number << 3) | wire_type
​(字段编号 << 3) | 字段类型

  • field_number 对应着 proto 文件中的序号
  • wire_type 表示 Value 的传输类型

Go 版本 Protobuf 中生成 tag 的源码:

1
2
3
4
5
// google.golang.org/protobuf@v1.25.0/encoding/protowire/wire.go
// EncodeTag encodes the field Number and wire Type into its unified form.
func EncodeTag(num Number, typ Type) uint64 {
return uint64(num)<<3 | uint64(typ&7)
}

假设Server接收到了一个 Tag 为0x08,其二进制的表示为:

1
0000 1000

由于 Tag 也是利用varint编码的,因此需要将第一个比特位去掉。

这样我的得到:

1
000 1000

根据key的编码方式,其后三个比特位表示字段类型,即:

1
000

也就是0,这样我们知道该tag的类型是Varint(第0号类型),而字段编号为抹掉后3个比特位的值,即:

1
0001

这样,我们就知道了该 tag 对应的字段编号为1,得到编号我们就能根据编号找到对应的编号名称。

修改 proto 文件时需要注意的点:

  • field number 一旦被分配就不应该被更改,除非能保证所有的接收方都能更新到最新的 proto 文件
  • 由于 tag 中不携带 field name 信息,更改 field name 并不会改变消息的结构。
    wire_type 可能的类型如下所示:


在上面的 protobuf 例子中,filed id 所采用的数据类型为 int32 ,因此对应的 wire type 为 0。
需要注意:

  • 对于 string 类型的数据,由于其长度是不定的,所以 T-V 的信息结构是不能满足的,需要增加一个标识长度的 Length 字段,即 T-L-V 结构。
  • Type 0 中有两个相似的数据类型 int32sint32ProtoBuf 区别它们的主要意图是为了减少编码后的字节数。因为,在计算机内,一个负数一般会被表示为一个很大的整数,因为计算机定义负数的符号位为数字的最高位。如果采用 Varint 表示一个负数,那么一定需要 5 个 byte。为此 Protobuf 定义了 sint32 这种类型,采用 zigzag 编码。

Zigzag 编码
Zigzag 编码使用无符号数来表示有符号数字,正数和负数交错。

使用 zigzag 编码,绝对值小的数字,无论正负都可以采用较少的 byte 来表示,充分利用了 Varint 技术。
ZigZag 虽然能压缩部分负数的空间,但同时正数变得需要更多的空间来存储。因此,建议在业务场景允许的场景下尽量用无符号整型,有助于进一步压缩编码后的空间。

4、参考文档

1、C++使用protobuf实现序列化与反序列化 - 知乎 (zhihu.com)

2、protobuf的Required,Optional,Repeated限定修饰符_protobuf optional字段

3、Protobuf 原理讲解

4、深入 ProtoBuf - 编码