Protobuf的高效编码

Google 在2008 年推出的 Protobuf,是一个针对具体编程语言的编解码工具。它面向Windows、Linux 等多种平台,也支持 Java、Python、Golang、C++、Javascript 等多种面向对象编程语言。使用 Protobuf 编码消息速度很快,消耗的 CPU 计算力也不多,而且编码后的字符流体积远远小于JSON 等格式,能够大量节约昂贵的带宽,因此gRPC也把Protobuf 作为底层的编解码协议。

然而,很多同学并不清楚 Protobuf 到底是怎样做到这一点的。这样,当你希望通过更换通讯协议这个高成本手段,提升整个分布式系统的性能时,面对可供选择的众多通讯协议,仅凭第三方的性能测试报告,你仍将难以作出抉择。

而且,面对分布式系统中的疑难杂症,往往需要通过分析抓取到的网络报文,确定到底是哪个组件出现了问题。可是由于 Protobuf 编码太过紧凑,即使对照着 Proto 消息格式文件,在不清楚编码逻辑时,你也很难解析出消息内容。

Protobuf是怎样压缩编码的呢

消息由多个名、值对组成,比如 HTTP 请求中,头部 Host: www.example.com 就是一个名值对,其中,Host 是字段名称,而 www.example.com 是字段值。我们先来看 Protobuf 如何编码字段名。

对于多达几十字节的HTTP 头部,HTTP/2 静态表仅用一个数字来表示,其中,映射数字与字符串对应关系的表格,被写死在HTTP/2实现框架中。这样的编码效率非常高,但通用的HTTP/2框架只能将61个最常用的 HTTP头部映射为数字,它能发挥出的作用很有限。

动态表可以让更多的 HTTP 头部编码为数字,在上一讲的例子中,动态表将 Host 头部减少了96% 的体积,效果惊人。但动态表生效得有一个前提: 必须在一个会话连接上反复传输完全相同的 HTTP 头部。如果消息字段在1个连接上只发送了1次,或者反复传输时字段总是略有变动,动态表就无能为力了。

有没有办法既使用静态表的预定义映射关系,又享受到动态表的灵活多变呢?其实只要把由HTTP/2 框架实现的字段名映射关系,交由应用程序自行完成即可。而 Protobuf 就是这么做的。比如下面这段39字节的JSON消息,虽然一目了然,字段名 name、id、sex 其实都是多余的,因为客户端与服务器的处理代码都清楚字段的含义。

json 复制代码
{"name" :"John" , "id" : 1234 , "sex" : "MALE"}

Protobuf 将这 3 个字段名预分配了 3 个数字,定义在 proto 文件中

ini 复制代码
message Person {
string name = 1;
uint32 id = 2;

SexType {
enumMALE =0;
FEMALE = 1;
}

SexType sex= 3;

接着,通过 protoc 程序便可以针对不同平台、编程语言,将它生成编解码类,最后通过类中自动生成的 SerializeToString 方法将消息序列化,编码后的信息仅有11 个字节。其中,报文与字段的对应关系我放在下面这张图中。

从图中可以看出,Protobuf 是按照字段名、值类型、字段值的顺序来编码的,由于编码极为紧凑,所以分析时必须基于二进制比特位进行。比如红色的 00001、00010、00011 等前5个比特位,就分别代表着 name、id、sex 字段。

图中字段值的编码方式我们后面再解释,这里想必大家会有疑问,如果只有 5个比特位表示字段名的值,那不是限制消息最多只有 31个(251)字段吗?当然不是,字段名的序号可以从1到536870911(即 229 1),可是,多数消息不过只有几个字段,这意味着可以用很小的序号表示它们。因此,对于小于 16 的序号,Protobuf 仅有 5个比特位表示,这样加上3位值类型,只需要1个字节表示字段名。对于大于16 小于 2027 的序号,也只需要 2个字节表示。

Protobuf 可以用1到5个字节来表示一个字段名,因此,每个字节的第1个比特位保留,它为0时表示这是字段名的最后一个字节。下表列出了几种典型序号的编码值(请把黑色的二进制位,从右至左排列,比如 2049 应为 000100000000001,即2048+1)。

怎样高效地编码字段值?

Protobuf 对不同类型的值,采用6种不同的编码方式,如下表所示

字符串用 Length-delimited 方式编码,顾名思义,在值长度后顺序添加 ASCII 字节码即可比如上文例子中的John,对应的ASCII 码如下表所示:

这样,"John"需要 5个字节进行编码,如下图所示绿色表示长度,紫色表示ASCII 码)

这里需要注意,字符串长度的编码逻辑与字段名相同,当长度小于 128 (27)时,1个字节就可以表示长度。若长度从 128 到16384 (214),则需要2个字节,以此类推。

由于字符串编码时未做压缩,所以并不会节约空间,但胜在速度快。如果你的消息中含有大量字符串,那么使用 Huffman 等算法压缩后再编码效果更好。

我们再来看id:1234 这个数字是如何编码的。其实 Protobuf 中所有数字的编码规则是一致的,字节中第1个比特位仅用于指示由哪些字节编码1个数字。例如图中的 1234,将由14个比特位 00010011010010 表示 (1024+128+64+16+2,正好是 1234)。

由于消息中的大量数字都很小,这种编码方式可以带来很高的空间利用率!当然,如果你确定数字很大,这种编码方式不但不能节约空间,而且会导致原先 4个字节的大整数需要用5个字节来表示时,你也可以使用 fixed32、fixed64 等类型定义数字。

Protobuf 还可以通过enum 枚举类型压缩空间。回到第1幅图,sex: FEMALE仅用2个字节就编码完成,正是枚举值 FEMALE 使用数字1表示所到的效果。

而且,由于 Protobuf 定义了每个字段的默认值,因此,当消息使用字段的默认值时Protobuf 编码时会略过该字段。以 sex: MALE 为例,由于MALE=O 是 sex 的默认值,因此在第2幅示例图中,这2个字节都省去了。

另外,当使用repeated 语法将多个数字组成列表时,还可以通过打包功能提升编码效率。比如下图中,对numbers 字段添加 101、102、103、104这4个值后,如果不使用打包功能,共需要8个字节编码,其中每个数字前都需要添加字段名。而使用打包功能后,仅用6个字节就能完成编码,显然列表越庞大,节约的空间越多。

在Protobuf2 版本中,需要显式设置[packed=True] 才能使用打包功能,而在 Protobuf3 版本中这是默认功能。

相关推荐
Source.Liu1 小时前
【学Rust写CAD】25 变量类(variable.rs)
后端·rust
冷琅辞6 小时前
Elixir语言的云计算
开发语言·后端·golang
Asthenia04128 小时前
编译原理基础:LL(1) 文法与 LL(1) 分析法
后端
Asthenia04128 小时前
编译原理基础:FIRST 集合与 FOLLOW 集合的构造与差异
后端
Asthenia04128 小时前
编译原理基础:FOLLOW 集合与 LL(1) 文法条件
后端
Asthenia04128 小时前
编译原理基础:FIRST 集合与提取公共左因子
后端
欧宸雅9 小时前
Clojure语言的持续集成
开发语言·后端·golang
Bruce_Liuxiaowei9 小时前
基于Flask的DeepSeek~学术研究领域智能辅助系统设计与实现
后端·python·flask·deepseek
Asthenia04129 小时前
面试官问:你谈谈网络协议栈是什么?你觉得Java工程师需要了解哪些部分?
后端