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 版本中这是默认功能。

相关推荐
柏油4 小时前
MySQL InnoDB 行锁
数据库·后端·mysql
咖啡调调。4 小时前
使用Django框架表单
后端·python·django
白泽talk5 小时前
2个小时1w字| React & Golang 全栈微服务实战
前端·后端·微服务
摆烂工程师5 小时前
全网最详细的5分钟快速申请一个国际 “edu教育邮箱” 的保姆级教程!
前端·后端·程序员
一只叫煤球的猫5 小时前
你真的会用 return 吗?—— 11个值得借鉴的 return 写法
java·后端·代码规范
Asthenia04125 小时前
HTTP调用超时与重试问题分析
后端
颇有几分姿色5 小时前
Spring Boot 读取配置文件的几种方式
java·spring boot·后端
AntBlack5 小时前
别说了别说了 ,Trae 已经在不停优化迭代了
前端·人工智能·后端
@淡 定6 小时前
Spring Boot 的配置加载顺序
java·spring boot·后端