高效的数据压缩编码方式 Protobuf

前言

  本文介绍protobuf的编码原理以及不同序列化协议之间的对比。基于c++的protobuf的demo用例见2-protobuf/

  本专栏知识点是通过零声教育的线上课学习,进行梳理总结写下文章,对c/c++linux课程感兴趣的读者,可以点击链接 C/C++后台高级服务器课程介绍 详细查看课程的服务。

1. 协议概述

  什么是协议:协议是⼀种约定,通过约定,不同的进程可以对⼀段数据产⽣相同的理解,从⽽可以相互协作,存在进程间通信的程序就⼀定需要协议。

通信协议设计核⼼

  • 解析效率
  • 可扩展可升级

协议设计细节

  • 帧完整性判断
  • 序列化和反序列化
  • 协议升级(包括客户端和服务端,需要指明协议版本)
  • 协议安全(加解密)
  • 数据压缩

2. 判断消息的完整性

为了能让对端知道如何给消息帧分界,⽬前⼀般有以下做法:

  1. 以固定⼤⼩字节数⽬来分界,如每个消息100个字节,对端每收⻬100个字节,就当成⼀个消息来解析

  2. 以特定符号来分界,如每个消息都以特定的字符来结尾(如\r\n),当在字节流中读取到该字符时,则表明上⼀个消息到此为⽌,但是如果消息中包含\r\n这种分解字符,该方案就不可行了。

  3. 固定消息头+消息体结构,这种结构中⼀般消息头部分是⼀个固定字节⻓度的结构,并且消息头中会有⼀个特定的字段指定消息体的⼤⼩。收消息时,先接收固定字节数的头部,解出这个消息完整⻓度,按此⻓度接收消息体。这是⽬前各种⽹络应⽤⽤的最多的⼀种消息格式;header + body

    在这里插入图片描述

  4. 在序列化后的buffer前⾯增加⼀个字符流的头部,其中有个字段存储消息总⻓度,根据特殊字符(⽐如根据\n或者\0)判断头部的完整性。http和redis就是这种方式,收消息的时候,先判断已收到的数据中是否包含结束符,收到结束符后解析消息头,解出这个消息完整⻓度,按此⻓度接收消息体。显然比第3种方法要麻烦一些。很多时候我们觉得HTTP协议简单只是因为我们对它比较熟悉罢了。header + \r\n + body

在这里插入图片描述

  HTTP协议是我们最常⻅的协议,我们是否可以采⽤HTTP协议作为互联⽹后台的协议呢?这个⼀般是不适当的,主要是考虑到以下2个原因:

  1. HTTP协议只是⼀个框架,没有指定包体的序列化⽅式,所以还需要配合其他序列化的⽅式使⽤才能传递业务逻辑数据。

  2. HTTP协议解析效率低,⽽且⽐较复杂(不知道有没有⼈觉得HTTP协议简单,其实不是http协议简单,⽽是HTTP⼤家⽐较熟悉⽽已)

有些情况下是可以使⽤HTTP协议的:

  1. 对公⽹⽤户api,HTTP协议的穿透性最好,所以最适合;
  2. 效率要求没那么⾼的场景;
  3. 希望提供更多⼈熟悉的接⼝,⽐如新浪微、腾讯博提供的开放接⼝;

3. 序列化

3.1 序列化⽅法

  • TLV编码及其变体(TLV是tag, length和value的缩写):⽐如Protobuf。
  • ⽂本流编码:⽐如XML/JSON
  • 固定结构编码: 基本原理是,协议约定了传输字段类型和字段含义,和TLV的⽅式类似,但是没有了tag和len,只有value,⽐如TCP/IP

主流序列化协议:xml、json、protob

  1. XML指可扩展标记语⾔(eXtensible Markup Language)。是⼀种通⽤和重量级的数据交换格式。以⽂本⽅式存储。
  2. JSON(JavaScript ObjectNotation, JS 对象简谱) 是⼀种通⽤和轻量级的数据交换格式。以⽂本结构进⾏存储。
  3. protocol buffer是Google的⼀种独⽴和轻量级的数据交换格式。以⼆进制结构进⾏存储。
类型 通信性 数据量 格式 使用场景
XML 通用 重量级 文本格式(清晰易懂) 本地配置,ui配置,qt,Android
JSON 通用 轻量级 文本格式(方便调试) websocket,http协议,web里面注册登陆
Protobuf 独立 轻量级 二进制格式(高效) 业务内部使用,服务器之间RPC调用,游戏场景

3.2 序列化、反序列化速度对⽐

测试10万次序列化

默认 -O1 序列化后字节
cJSON(C语⾔) 488ms 452ms 297
jsoncpp(C++语⾔) 871ms 709ms 255
rapidjson(C++语⾔) 701ms 113ms 239
tinyxml2(XML) 1383ms 770ms 474
protobuf 241ms 83ms 117

测试10万次反序列化

默认 -O1
cJSON 284ms 251ms
jsoncpp 786ms 709ms
rapidjson 1288ms 128ms
tinyxml2 1781ms 953ms
protobuf 190ms 80ms

4. Protobuf

4.1 Protobuf概述

  Protocol buffers 是一种语言中立,平台无关,可扩展的序列化数据的格式,可用于通信协议,数据存储等。

  Protocol buffers 在序列化数据方面,它是灵活的,高效的。相比于 XML 来说,Protocol buffers 更加小巧,更加快速,更加简单。一旦定义了要处理的数据的数据结构之后,就可以利用 Protocol buffers 的代码生成工具生成相关的代码。甚至可以在无需重新部署程序的情况下更新数据结构。只需使用 Protobuf 对数据结构进行一次描述,即可利用各种不同语言或从各种不同数据流中对你的结构化数据轻松读写。

  Protocol buffers 很适合做数据存储或 RPC 数据交换格式。可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。

4.2 Protobuf的使用

  1. 根据消息的结构编写.proto文件
  2. 使用protobuf官方提供的工具生产.pb.cc和.pb.hh文件。其中定义了消息的类,消息字段就是类成员,包含了设置和获取各个成员的方法;
  3. 在代码中使用这些类并编译g++ -std=c++11 -o proto_test *.cc *.pb.cc -lprotobuf -lpthread

  IDL是Interface description language的缩写,指接⼝描述语⾔。可以看到,对于序列化协议来说,使⽤⽅只需要关注业务对象本身,即 idl 定义(.proto),序列化和反序列化的代码只需要通过⼯具⽣成即可。

protoc -I=input_dir --cpp_out=output_dir  *.proto

在这里插入图片描述

4.3 Protobuf的语法

  直接参考官方文档即可

Language Guide (proto3)
Protocol Buffer Basics: C++

Protocol Buffer 命名规范

message 采用驼峰命名法。message 首字母大写开头。字段名采用下划线分隔法命名。

message SongServerRequest {
  required string song_name = 1;
}

枚举类型采用驼峰命名法。枚举类型首字母大写开头。每个枚举值全部大写,并且采用下划线分隔法命名。每个枚举值用分号结束,不是逗号。

enum Foo {
  FIRST_VALUE = 0;
  SECOND_VALUE = 1;
}

服务名和方法名都采用驼峰命名法。并且首字母都大写开头。

service FooService {
  rpc GetSomething(FooRequest) returns (FooResponse);
}

标量数值类型

  ⼀个标量消息字段可以含有⼀个如下的类型——该表格展示了定义于.proto⽂件中的类型,以及与之对应的、在⾃动⽣成的访问类中定义的类型:

在这里插入图片描述

4.4 Protobuf的安装与编译

  1. 首先去git上下载git protobuf releases版本的tar.gz文件到服务器上,我现在下的是[protobuf-cpp-3.21.5.tar.gz]
wget https://github.com/protocolbuffers/protobuf/releases/download/v21.5/protobuf-cpp-3.21.5.tar.gz
  1. 解压
tar zxvf protobuf-cpp-3.21.5.tar.gz protobuf-3.21.5/
  1. 编译
cd protobuf-3.21.5/
./configure
make
sudo make install
sudo ldconfig
  1. 显示版本信息
protoc --version
  1. 编写proto文件

Language Guide (proto3)
Protocol Buffer Basics: C++

  1. 将proto⽂件⽣成对应的.cc和.h⽂件
protoc -I=input_dir --cpp_out=output_dir  [*.proto |/input_dir/specific.proto]

#input_dir为.proto所在的路径
#cpp_out为.cc和.h⽣成的位置
#/input_dir/specific.proto为指定某个proto文件
#*proto为所有proto文件
  1. 编译范例
g++ -std=c++11 -o list_people list_people.cc addressbook.pb.cc -lprotobuf -lpthread

4.5 protobuf option部分选项

  optimize_for是⽂件级别的选项,Protocol Buffer定义三种优化级别SPEED/CODE_SIZE/LITE_RUNTIME。缺省情况下是SPEED。

  1. SPEED: 表示⽣成的代码运⾏效率⾼,但是由此⽣成的代码编译后会占⽤更多的空间。
  2. CODE_SIZE: 和SPEED恰恰相反,代码运⾏效率较低,但是由此⽣成的代码编译后会占⽤更少的空间,通常⽤于资源有限的平台,如Mobile。
  3. LITE_RUNTIME: ⽣成的代码执⾏效率⾼,同时⽣成代码编译后的所占⽤的空间也是⾮常少。这是以牺牲Protocol Buffer提供的反射功能为代价的。因此我们在C++中链接Protocol Buffer库时仅需链接libprotobuf-lite,⽽⾮libprotobuf。

在这里插入图片描述

5. Protocol Buffer的编码原理

在讨论 Protocol Buffer 编码原理之前,必须先谈谈 Varints 编码。

5.1 Base 128 Varints 编码介绍 uint,sint

  Varint 是一种紧凑的表示数字的方法。它使用小端标识,用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数。这能减少用来表示数字的字节数。Varint 中的每个字节(最后一个字节除外)都设置了最高有效位(msb),这一位表示还会有更多字节出现每个字节的低 7 位用于以 7 位组的形式存储数字的二进制补码表示,最低有效组首位。如果用不到 1 个字节,那么最高有效位设为 0 ,如下面这个例子,1 用一个字节就可以表示,所以 msb 为 0.

0000 0001

如果需要多个字节表示,msb 就应该设置为 1 。例如 300,如果用 Varint 表示的话:

1010 1100 0000 0010

# 300二进制 = 10 0101100

  如果按照正常的二进制计算的话,这个表示的是 88068(65536 + 16384 + 4096 + 2048 + 4)。那 Varint 是怎么编码的呢?

  由于 300 超过了 7 位(Varint 一个字节只有 7 位能用来表示数字,最高位 msb 用来表示后面是否有更多字节),所以 300 需要用 2 个字节来表示。Varint 的编码,以 300 举例:由于varints用小端表示,所以第一个字节为x0101100,而还没有结束,所以msb为1,即第一个字节10101100,第二个字节为x…10,因为这个字节代表着结束,所以第二个字节为00000010。

  读到这里可能有读者会问了,Varint 不是为了紧凑 int 的么?那 300 本来可以用 2 个字节表示,现在还是 2 个字节了,哪里紧凑了,花费的空间没有变啊?!

  Varint 确实是一种紧凑的表示数字的方法。它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数。这能减少用来表示数字的字节数。比如对于 int32 类型的数字,一般需要 4 个 byte 来表示。但是采用 Varint,对于很小的 int32 类型的数字,则可以用 1 个 byte 来表示。当然凡事都有好的也有不好的一面,采用 Varint 表示法,大的数字则需要 5 个 byte 来表示。从统计的角度来说,一般不会所有的消息中的数字都是大数,因此大多数情况下,采用 Varint 后,可以用更少的字节数来表示数字信息

  300 如果用 int32 表示,需要 4 个字节,现在用 Varint 表示,只需要 2 个字节了。缩小了一半!

5.2 field_number和wire_type

  什么是field_number,在我们写的.proto文件里面,每个message的字段,都从1开始计数,这就是field_number。field_number:1,2,3…

message SongServerRequest {
  required string song_name = 1;
}

  那么wire_type呢?wire_type就是字段的类型的编号,例如上面的string,它的wire_type就等于2。所以 wire_type 取值目前只有 0、1、2、5

在这里插入图片描述

5.3 Message Structure 编码过程

  protocol buffer 中 message 是一系列键值对。message 的二进制版本只是使用字段号(field's number 和 wire_type)作为 key。每个字段的名称和声明类型只能在解码端通过引用消息类型的定义(即 .proto 文件)来确定。这一点也是人们常常说的 protocol buffer 比 JSON,XML 安全一点的原因,如果没有数据结构描述 .proto 文件,拿到数据以后是无法解释成正常的数据的。

在这里插入图片描述

  由于采用了 tag-value 的形式,所以 option 的 field 如果有,就存在在这个 message buffer 中,如果没有,就不会在这里,这一点也算是压缩了 message 的大小了。

  当消息编码时,键和值被连接成一个字节流。当消息被解码时,解析器需要能够跳过它无法识别的字段。这样,可以将新字段添加到消息中,而不会破坏不知道它们的旧程序。这就是所谓的 “向后”兼容性。

  为此,线性的格式消息中每对的“key”实际上是两个值,其中一个是来自.proto文件的字段编号,加上提供正好足够的信息来查找下一个值的长度。在大多数语言实现中,这个 key 被称为 tag。

在这里插入图片描述

  key 的计算方法是 (field_number << 3) | wire_type,换句话说,key 的最后 3 位表示的就是 wire_type

  举例,一般 message 的字段号都是 1 开始的,所以对应的 tag 可能是这样的:

000 1000

  末尾 3 位表示的是 value 的类型,这里是 000,即 0 ,代表的是 varint 值。右移 3 位,即 0001,这代表的就是字段号(field number)。tag 的例子就举这么多,接下来举一个 value 的例子,还是用 varint 来举例:

# val=150 二进制 10010110
varint变换--> 1 0010110 ->00000001 10010110 ->十六进制表示96 01

  所以 96 01 代表的数据就是 150 。

message Test1 {
  required int32 a = 1;
}

  如果存在上面这样的一个 message 的结构,如果存入 150,在 Protocol Buffer 中显示的二进制应该为 08 96 01

  额外说一句,type 需要注意的是 type = 2 的情况,tag 里面除了包含 field number 和 wire_type ,还需要再包含一个 length,决定 value 从那一段取出来多长,具体在下面介绍。

5.4 Zigzag 编码介绍 sint

  从上面的表格里面可以看到 wire_type = 0 中包含了无符号的 varints,但是如果是一个无符号数呢?
  一个负数一般会被表示为一个很大的整数,因为计算机定义负数的符号位为数字的最高位。如果采用 Varint 表示一个负数,那么一定需要 10 个 byte 长度。

为此 Google Protocol Buffer 定义了 sint32 这种类型,采用 zigzag 编码将所有整数映射成无符号整数,然后再采用 varint 编码方式编码,这样,绝对值小的整数,编码后也会有一个较小的 varint 编码值。

Zigzag(n) = (n << 1) ^ (n >> 31), n 为 sint32 时
Zigzag(n) = (n << 1) ^ (n >> 63), n 为 sint64 时

这里需要注意的是这句话,当定义了sint32或者sint64这种类型时,先采用zigzag 编码。将所有整数映射成无符号整数,然后再采用 varint 编码方式编码

5.5 Non-varint Numbers

  Non-varint 数字比较简单,double 、fixed64 的 wire_type 为 1,在解析时告诉解析器,该类型的数据需要一个 64 位大小的数据块即可。同理,float 和 fixed32 的 wire_type 为5,给其 32 位数据块即可。两种情况下,都是高位在后,低位在前

5.6 字符串

在这里插入图片描述

  wire_type 类型为 2 的数据,是一种指定长度的编码方式:key + length + content,key 的编码方式是统一的,length 采用 varints 编码方式,content 就是由 length 指定长度的 Bytes。

举例,假设定义如下的 message 格式:

message Test2 {
  optional string b = 2;
}
# 设置该值为"testing",二进制格式查看:
12 07 [74 65 73 74 69 6e 67]

74 65 73 74 69 6e 67 是“testing”的 UTF8 代码。

此处,key 是16进制表示的,所以展开是:

0x12 -> 0001 0010,后三位 010 为 wire type = 2,0001 0010 右移三位为 0000 0010,即 wire_type = 2,field_num=2。

length 此处为 7,后边跟着 7 个bytes,即我们的字符串"testing"。

  所以 wire_type 类型为 2 的数据,编码的时候会默认转换为 T-L-V (Tag - Length - Value)的形式。

5.7 嵌套message,optional,repeated

  这里不做介绍了,有机会再写吧

5.8 编码总结

  Protobuf 采⽤ Varints 编码和 Zigzag 编码来编码数据, 其中 Varints 编码的思想是移除数字⾼位的 0, ⽤变⻓的⼆进制位来描述⼀个数字, 对于⼩数字, 其编码⻓度短, 可提⾼数据传输效率, 但由于它在每个字节的最⾼位额外采⽤了⼀个标志位来标记其后是否还跟有有效字节, 因此对于⼤的正数, 它会⽐使⽤普通的定⻓格式占⽤更多的空间, 另外对于负数, 直接采⽤ Varints 编码将恒定占⽤ 10 个字节, Zigzag 编码可将负数映射为⽆符号的正数, 然后采⽤ Varints 编码进⾏数据压缩, 在各种语⾔的 Protobuf 实现中, 对于 int32 类型的数据, Protobuf 都会转为 uint64 ⽽后使⽤ Varints 编码来处理, 因此当字段可能为负数时, 我们应使⽤ sint32 或 sint64, 这样Protobuf 会按照 Zigzag 编码将数据变换后再采⽤ Varints 编码进⾏压缩, 从⽽缩短数据的⼆进制位数

  Protobuf 不是完全⾃描述的信息描述格式, 接收端需要有相应的解码器(即 proto 定义)才可解析数据格式, 序列化后的 Protobuf 数据不携带字段名, 只使⽤字段编号来标识⼀个字段, 因此更改proto 的字段名不会影响数据解析(但这显然不是⼀种好的⾏为), 字段编号会被编码进⼆进制的消息结构中, 因此我们应尽可能地使⽤⼩字段编号

  Protobuf 是⼀种紧密的消息结构, 编码后字段之间没有间隔, 每个字段头由两部分组成: 字段编号和 wire type, 字段头可确定数据段的⻓度, 因此其字段之前⽆需加⼊间隔, 也⽆需引⼊特定的数据来标记字段末尾, 因此 Protobuf 的编码⻓度短, 传输效率⾼。

6. Protocol Buffers 的优缺点

  优点:序列化反序列化的耗时,以及数据的体积,都比xml和json小。自动化生成更易于编码方式使用的数据访问类

  缺点:不如xml和json那样有很高的可读性,除非你有 .proto 定义,否则你没法直接读出 Protobuf 的任何内容。

读文本文,可以了解到protobuf下面六个性质:

  1. Protocol Buffer 利用 varint 原理压缩数据以后,二进制数据非常紧凑,option 也算是压缩体积的一个举措。所以 pb 体积更小,如果选用它作为网络数据传输,势必相同数据,消耗的网络流量更少。但是并没有压缩到极限,float、double 浮点型都没有压缩。
  2. Protocol Buffer 比 JSON 和 XML 少了 {、}、: 这些符号,体积也减少一些。再加上 varint 压缩,gzip 压缩以后体积更小!
  3. Protocol Buffer 是 Tag - Value (Tag - Length - Value)的编码方式的实现,减少了分隔符的使用,数据存储更加紧凑。
  4. Protocol Buffer 另外一个核心价值在于提供了一套工具,一个编译工具,自动化生成 get/set 代码。简化了多语言交互的复杂度,使得编码解码工作有了生产力。
  5. Protocol Buffer 不是自我描述的,离开了数据描述 .proto 文件,就无法理解二进制数据流。这点即是优点,使数据具有一定的“加密性”,也是缺点,数据可读性极差。所以 Protocol Buffer 非常适合内部服务之间 RPC 调用和传递数据。
  6. Protocol Buffer 具有向后兼容的特性,更新数据结构以后,老版本依旧可以兼容,这也是 Protocol Buffer 诞生之初被寄予解决的问题。因为编译器对不识别的新增字段会跳过不处理。

参考

Protocol-buffers-encode.md

相关文章

学习编程是顺着互联网的发展潮流,是一件好事。新手如何学习...
IT行业是什么工作做什么?IT行业的工作有:产品策划类、页面...
女生学Java好就业吗?女生适合学Java编程吗?目前有不少女生...
Can’t connect to local MySQL server through socket \'/v...
oracle基本命令 一、登录操作 1.管理员登录 # 管理员登录 ...
一、背景 因为项目中需要通北京网络,所以需要连vpn,但是服...