本文介绍protobuf的编码原理以及不同序列化协议之间的对比。基于c++的protobuf的demo用例见2-protobuf/
本专栏知识点是通过零声教育的线上课学习,进行梳理总结写下文章,对c/c++linux课程感兴趣的读者,可以点击链接 C/C++后台高级服务器课程介绍 详细查看课程的服务。
什么是协议:协议是⼀种约定,通过约定,不同的进程可以对⼀段数据产⽣相同的理解,从⽽可以相互协作,存在进程间通信的程序就⼀定需要协议。
通信协议设计核⼼
协议设计细节
为了能让对端知道如何给消息帧分界,⽬前⼀般有以下做法:
以固定⼤⼩字节数⽬来分界,如每个消息100个字节,对端每收⻬100个字节,就当成⼀个消息来解析
以特定符号来分界,如每个消息都以特定的字符来结尾(如\r\n),当在字节流中读取到该字符时,则表明上⼀个消息到此为⽌,但是如果消息中包含\r\n这种分解字符,该方案就不可行了。
固定消息头+消息体结构,这种结构中⼀般消息头部分是⼀个固定字节⻓度的结构,并且消息头中会有⼀个特定的字段指定消息体的⼤⼩。收消息时,先接收固定字节数的头部,解出这个消息完整⻓度,按此⻓度接收消息体。这是⽬前各种⽹络应⽤⽤的最多的⼀种消息格式;header + body。

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

HTTP协议是我们最常⻅的协议,我们是否可以采⽤HTTP协议作为互联⽹后台的协议呢?这个⼀般是不适当的,主要是考虑到以下2个原因:
HTTP协议只是⼀个框架,没有指定包体的序列化⽅式,所以还需要配合其他序列化的⽅式使⽤才能传递业务逻辑数据。
HTTP协议解析效率低,⽽且⽐较复杂(不知道有没有⼈觉得HTTP协议简单,其实不是http协议简单,⽽是HTTP⼤家⽐较熟悉⽽已)
有些情况下是可以使⽤HTTP协议的:
主流序列化协议:xml、json、protob
| 类型 | 通信性 | 数据量 | 格式 | 使用场景 |
|---|---|---|---|---|
| XML | 通用 | 重量级 | 文本格式(清晰易懂) | 本地配置,ui配置,qt,Android |
| JSON | 通用 | 轻量级 | 文本格式(方便调试) | websocket,http协议,web里面注册登陆 |
| Protobuf | 独立 | 轻量级 | 二进制格式(高效) | 业务内部使用,服务器之间RPC调用,游戏场景 |
测试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 |
Protocol buffers 是一种语言中立,平台无关,可扩展的序列化数据的格式,可用于通信协议,数据存储等。
Protocol buffers 在序列化数据方面,它是灵活的,高效的。相比于 XML 来说,Protocol buffers 更加小巧,更加快速,更加简单。一旦定义了要处理的数据的数据结构之后,就可以利用 Protocol buffers 的代码生成工具生成相关的代码。甚至可以在无需重新部署程序的情况下更新数据结构。只需使用 Protobuf 对数据结构进行一次描述,即可利用各种不同语言或从各种不同数据流中对你的结构化数据轻松读写。
Protocol buffers 很适合做数据存储或 RPC 数据交换格式。可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。
.proto文件.pb.cc和.pb.hh文件。其中定义了消息的类,消息字段就是类成员,包含了设置和获取各个成员的方法;g++ -std=c++11 -o proto_test *.cc *.pb.cc -lprotobuf -lpthreadIDL是Interface description language的缩写,指接⼝描述语⾔。可以看到,对于序列化协议来说,使⽤⽅只需要关注业务对象本身,即 idl 定义(.proto),序列化和反序列化的代码只需要通过⼯具⽣成即可。
protoc -I=input_dir --cpp_out=output_dir *.proto

直接参考官方文档即可
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⽂件中的类型,以及与之对应的、在⾃动⽣成的访问类中定义的类型:

wget https://github.com/protocolbuffers/protobuf/releases/download/v21.5/protobuf-cpp-3.21.5.tar.gz
tar zxvf protobuf-cpp-3.21.5.tar.gz protobuf-3.21.5/
cd protobuf-3.21.5/
./configure
make
sudo make install
sudo ldconfig
protoc --version
Language Guide (proto3)
Protocol Buffer Basics: C++
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文件
g++ -std=c++11 -o list_people list_people.cc addressbook.pb.cc -lprotobuf -lpthread
optimize_for是⽂件级别的选项,Protocol Buffer定义三种优化级别SPEED/CODE_SIZE/LITE_RUNTIME。缺省情况下是SPEED。

在讨论 Protocol Buffer 编码原理之前,必须先谈谈 Varints 编码。
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 个字节了。缩小了一半!
什么是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

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 从那一段取出来多长,具体在下面介绍。
从上面的表格里面可以看到 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 编码方式编码
Non-varint 数字比较简单,double 、fixed64 的 wire_type 为 1,在解析时告诉解析器,该类型的数据需要一个 64 位大小的数据块即可。同理,float 和 fixed32 的 wire_type 为5,给其 32 位数据块即可。两种情况下,都是高位在后,低位在前

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)的形式。
这里不做介绍了,有机会再写吧
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 的编码⻓度短, 传输效率⾼。
优点:序列化反序列化的耗时,以及数据的体积,都比xml和json小。自动化生成更易于编码方式使用的数据访问类
缺点:不如xml和json那样有很高的可读性,除非你有 .proto 定义,否则你没法直接读出 Protobuf 的任何内容。
读文本文,可以了解到protobuf下面六个性质: