ProtoBuf
1、背景
- 序列化: 将数据结构或对象转换成能够被存储和传输(例如网络传输)的格式,同时应当要保证这个序列化结果在之后(可能是另一个计算环境中)能够被重建回原来的数据结构或对象。
- Xml、Json是目前常用的数据交换格式,它们直接使用字段名称维护序列化后类实例中字段与数据之间的映射关系,一般用字符串的形式保存在序列化后的字节流中。消息和消息的定义相对独立,可读性较好。但序列化后的数据字节很大,序列化和反序列化的时间较长,数据传输效率不高。
- Protobuf和Xml、Json序列化的方式不同,采用了二进制字节的序列化方式,用字段索引和字段类型通过算法计算得到字段之前的关系映射,从而达到更高的时间效率和空间效率,特别适合对数据大小和传输速率比较敏感的场合使用。
以一个数字的序列化为例:
- JSON:
{"id":42}
,9 bytes - xml:
<id>42</id>
,11 bytes 。一般还需要外层包裹实现。 - protobuf:
0x08 0x2A
,2 bytes- 0x08 = field 1, type :Variant
- 0x2A = 42 (raw) or 21 (zigzag)
2、定义和特点
protobuf(Google Protocol Buffers)是Google提供一个具有高效的协议数据交换格式工具库(类似Json)。
- 时间开销小;
- 空间开销小;
- 支持多种编程语言(C++、java、python)
- 二进制格式导致可读性差
3、结构和使用
3.1、编码结构
Protobuf 提供了C++、java、python语言的支持,提供了windows(proto.exe) 和linux平台动态编译生成 proto文件 对应的源文件。
proto文件定义了协议数据中的实体结构(message ,field)
- 1、关键字message: 代表了实体结构,由多个消息字段field组成。通过 key-value对来表示,其实它是把 message 转成一系列的 key-value,
- key 就是字段号,value 就是字段值,具体保存的时候实际保存的不是 key 而是 tag,key 字段号需要根据一个公式计算出 tag。
- Tag 由
field_number
和wire_type
两个部分组成:- field_number: message 定义字段时指定的字段编号
- wire_type: ProtoBuf 编码类型(见下图),根据这个类型选择不同的 Value 编码方案。
-
wire_type由三位bit构成,故能表示8种类型。
-
1.当wire_type等于0的时候整个二进制结构为:Tag-Value
value的编码也采用Varints编码方式,故不需要额外的位来表示整个value的长度。因为Varint的msb位标识下一个字节是否是有效的就起到了指示长度的作用。 -
2.当wire_type等于1、5的时候整个二进制结构也为:Tag-Value
因为都是取固定32位或者64位,因此也不需要额外的位来表示整个value的长度。 -
3.当wire_type等于2的时候整个二进制结构为:Tag-[Length]-Value
因为表示的是可变长度的值,需要有额外的位来指示长度。
-
- key 就是字段号,value 就是字段值,具体保存的时候实际保存的不是 key 而是 tag,key 字段号需要根据一个公式计算出 tag。
- 2、消息字段(field): 包括数据类型、字段名、字段规则、字段唯一标识、默认值。message使用数字标签作为key,Key 用来标识具体的 field,在解包的时候,Protocol Buffer 根据 Key 就可以知道相应的 Value 应该对应于消息中的哪一个 field。
- 3、数据类型(type):常见的原子类型都支持
- 4、字段规则:
- required:必须初始化字段,如果没有赋值,在数据序列化时会抛出异常
- optional:可选字段,可以不必初始化。
- repeated:数据可以重复(相当于java 中的Array或List)
- 字段唯一标识:序列化和反序列化将会使用到。
数据类型type的对应关系如下:
- 第一列即是对应的类型编号,
- 第二列为面向最终编码的编码类型(wire_type ),
- 第三列是面向开发者的 message 字段的类型。
- 虽然 wire_type 代表编码类型,但是 Varint 这个编码类型里针对 sint32、sint64 又会有一些特别编码(ZigTag 编码)处理,相当于 Varint 这个编码类型里又存在两种不同编码。
- fixed32、fixed64:总是4字节和8字节
- sfixed32、sfixed64:总是4字节和8字节
- int32、int64:如果负数指定这两个类型编码效率较低。负数应该使用下面的类型。
- sint32、sint64:对负数会进行ZigZag编码提高编码效率。
3.2、不同编码类型(wire_type ) 使用
wire_type 目前已定义 6 种,其中两种已被遗弃(Start group 和 End group),只剩下四种类型: Varint、64-bit、Length-delimited、32-bit
。
参考
3.2.1、Varints 编码简介
1)、简介
支持编码数据类型如下
int32、int64、uint32、uint64、bool、enum//bool 的本质为 0 和 1,enum 本质为整数常量。
sint32、sint64 类型的 ZigZag 编码
编码结构为 Tag - Value,其中 Tag 和 Value 均采用 Vartins 编码。
2)、过程:
- 1、先读一个 Varints 编码块,进行 Varints 解码,读取最后 3 bit 得到 wire_type(由此可知是后面的 Value
采用的哪种编码); - 2、随后获取到 field_number (由此可知是哪一个字段);
- 3、依据 wire_type 来正确读取后面的 Value;
- 4、接着继续读取下一个字段 field…
3)、Varints 编码的3规则:
- 1、在每个字节开头的 bit 设置了 msb(most significant bit ),标识是否需要继续读取下一个字节
- 2、存储数字对应的二进制补码
- 3、补码的低位排在前面,字节旋转操作
00000101
|00011010
经常字节旋转,得到
00011010
|00000101
3.2.1.1、Varints 编码和解码举例
实例1:
int32 val = 1; // 设置一个 int32 的字段的值 val = 1; 这时编码的结果如下
原码:0000 ... 0000 0001 // 1 的原码表示,这里前面为什么这么多0,int32数据类型,对应32位,4个字节
补码:0000 ... 0000 0001 // 1 的补码表示,正数的补码和源码一样
Varints 编码:0#000 0001(0x01) // 1 的 Varints 编码,其中第一个字节的 msb = 0
1)编码过程:
-
1、数字 1 对应补码
0000 ... 0000 0001
(规则 2), -
2、从末端开始取每 7 位一组并且反转排序(规则 3),
- 因为
0000 ... 0000 0001
除了第一个取出的 7 位组(即原数列的后 7 位),剩下的均为 0。所以只需取第一个 7位组,无需再取下一个 7 bit。
- 因为
-
3、那么第一个 7 位组的 msb = 0(规则1)
最终得到
0 | 000 0001 对应16进制表达(0x01) ,注意最高位的0即为msb位
2)解码过程:
0#000 0001(0x01)
- 1、每个字节的第一个 bit 为 msb 位,
msb = 1
表示需要再读一个字节(还未结束),msb = 0
表示无需再读字节(读取到此为止)。- 这里数字 1 的 Varints 编码中 msb = 0,所以只需要读完第一个字节无需再读
- 2、剩下的
000 0001
就是补码的逆序,但是这里只有一个字节,所以无需反转,直接解释补码 000 0001, - 3、还原即为数字 1。
这里编码数字 1,Varints 只使用了 1 个字节。而正常情况下 int32 将使用 4 个字节存储数字 1。
实例2:
int32 val = 666; // 设置一个 int32 的字段的值 val = 666; 这时编码的结果如下
原码:000 ... 101 0011010 // 666 的源码
补码:000 ... 101 0011010 // 666 的补码
Varints 编码:1#0011010 0#000 0101 (9a 05) // 666 的 Varints 编码
1)编码过程:
- 1、规则2,666 的补码为
000 ... 101 0011010
- 2、规则3,从后依次向前取 7 位组并反转排序
0011010 | 0000101
- 3、规则1,每个字节对应加上 msb,翻转后的高位字节的高位为1,低位字节的高位为0
1 0011010 | 0 0000101 对应16进制表达(0x9a 0x05)
2)解码过程:
1#0011010 0#000 0101 (9a 05)
- 1、这里的第一个字节 msb = 1,所以需要再读一个字节,第二个字节的 msb = 0,则读取两个字节后停止。读到两个字节后先去掉两个 msb,剩下:
0011010 000 0101
- 2、将这两个 7-bit 组翻转得到补码:
000 0101 0011010 还原其原码为 666
这里编码数字 666,Varints 只使用了 2 个字节。而正常情况下 int32 将使用 4 个字节存储数字 666。
3.2.1.2、Varints 编码特征和问题
Varints 的本质实际上是每个字节都牺牲一个 bit 位(msb),来表示是否已经结束(是否还需要读取下一个字节),msb 实际上就起到了 Length 的作用,正因为有了 msb(Length),所以我们可以摆脱原来那种无论数字大小都必须分配四个字节的窘境。通过 Varints 我们可以让小的数字用更少的字节表示。从而提高了空间利用和效率。
这里为什么强调牺牲?
因为每个字节都拿出一个 bit 做 msb,而原先这个 bit 是可直接用来表示 Value 的,现在每个字节都少了一个bit 位即只有 7 位能真正用来表达 Value。那就意味这 4 个字节能表达的最大数字为 2^28, 而不再是 2^32 了。 这意味着什么?意味着当数字大于 2^28 时,采用 Varints 编码将导致分配 5 个字节,而原先明明只需要 4 个字节,此时Varints 编码的效率不仅不是提高反而是下降。 但这并不影响 Varints 在实际应用时的高效,因为事实证明,在大多数情况下,小于2^28 的数字比大于 2^28 的数字出现的更为频繁。
问题:负数的编码。
int32 val = -1
原码:1000 ... 0001 // 注意这里是 8 个字节
补码:1111 1111 1111 1111 1111 1111 1111 1111 // 注意这里是 8 个字节
再次复习 Varints 编码:对补码取 7 bit 一组,低位放在前面。
1.以7位1组单位逆序:
111 1111 111 1111 111 1111 111 1111 1111
2.7位1组,第一位高位为msb表示是否需要下一个字节。
1111 1111 1111 1111 1111 1111 1111 1111 0111 1000
故数字-1的varint编码为:
1111 1111 1111 1111 1111 1111 1111 1111 0111 1000
十六进制表示为:
0xFFFFFFFF78
因为负数必须在最高位(符号位)置 1,这一点意味着无论如何,负数都必须占用所有字节,所以它的补码总是占满 8 个字节。你没法像正数那样去掉多余的高位(都是 0)。再加上 msb,最终 Varints 编码的结果将固定在 10 个字节。
为什么是十个字节? int32 不应该是 4 个字节吗?这里是 ProtoBuf 基于兼容性的考虑(比如开发者将 int64 的字段改成 int32 后应当不影响旧程序),而将 int32 扩展成 int64 的八个字节。
所以目前的情况是我们定义了一个 int32 类型的变量,如果将变量值设置为负数,那么直接采用 Varints 编码的话,其编码结果将总是占用十个字节,这显然不是我们希望得到的结果。如何解决?ZigZag 编码
3.2.1.3、Varints 编码——int32实例(int64、uint32、uint64同)
1)、int32
syntax = "proto3";
// message 定义
message Example1 {
int32 int32Val = 1;//字段的编号为1
}
程序中设置字段值为 10,其编码结果为:
// 设置字段值 为 10
Example1 example1;
example1.set_int32val(10);
// 编码结果
tag-(Varints)0#0001 000 + value-(Varints)0#000 1010 = 0x08 0x10
编码采用Tag-Value
方式
1、字段的Tag解码:00001|000
- field_number=1表示第一个字段的编号为1;
- 最后3位(000)表示wire_type=0指示了接下来Value的编码采用Varint方式
2、字段的Value解码:
- 由wire_type=0可知Value是采用Varint编码。故读取下一个字节(00001010),
- 该字节的第一位msb位为0。故接下来的7位(0001010)表示第一个字段的值。
- 因为只有一个字节因此逆序还是0001010,
所以二进制0001010表示数字为:10
在程序中设置字段值为 666,其编码结果为:
// 设置字段值 为 666
Example1 example1;
example1.set_int32val(666);
// 编码结果
tag-(Varints)00001 000 + value-(Varints)1#0011010 0#000 0101 = 0x08 0x9a 0x05
3.2.1.3、Varints 编码——bool、enum实例
1)、bool 的例子:
syntax = "proto3";
// message 定义
message Example1 {
bool boolVal = 1;
}
在程序中设置字段值为 true
,其编码结果为:
// 设置字段值 为 true
Example1 example1;
example1.set_boolval(true);
// 编码结果
tag-(Varints)00001 000 + value-(Varints)0#000 0001 = 08 01
在程序中设置字段值为 false
,其编码结果为:
// 设置字段值 为 false
Example1 example1;
example1.set_boolval(false);
// 编码结果
空
当 boolVal = false 时,其编码结果为空,为什么?
这里是 ProtoBuf 为了提高效率做的又一个小技巧:规定一个默认值机制,当读出来的字段为空的时候就设置字段的值为默认值。而 bool 类型的默认值为 false。也就是说将 false 编码然后传递(消耗一个字节),不如直接不输出任何编码结果(空),终端解析时发现该字段为空,它会按照规定设置其值为默认值(也就是 false)。如此,可进一步节省空间提高效率。
2)、enum 的例子:
syntax = "proto3";
// message 定义
message Example1 {
enum COLOR {
YELLOW = 0;
RED = 1;
BLACK = 2;
WHITE = 3;
BLUE = 4;
}
// 枚举常量必须在 32 位整型值的范围
// 使用 Varints 编码,对负数不够高效,因此不推荐在枚举中使用负数
COLOR colorVal = 1;
}
在程序中设置字段值为 Example1_COLOR_BLUE,其编码结果为:
// 设置字段值 为 Example1_COLOR_BLUE
Example1 example1;
example1.set_colorval(Example1_COLOR_BLUE);
// 编码结果
tag-(Varints)00001 000 + value-(Varints)0#000 0100 = 08 04
3.2.2、ZigZag 编码
Google 又新增加了一种数据类型,叫 sint
,专门用来处理这些负数,其实现原理是采用ZigZag
编码。
有符号整数映射到无符号整数,编码结构依然为 Tag - Value,然后再使用 Varints 编码,ZigZag 编码的映射函数为:
Zigzag(n) = (n << 1) ^ (n >> 31), n为sint32时
Zigzag(n) = (n << 1) ^ (n >> 63), n为sint64时
uint32 a = -1;
-1的二进制编码:
1111 1111 1111 1111 1111 1111 1111 1111
n << 1后为:
1111 1111 1111 1111 1111 1111 1111 1110
n >> 31后为:
1111 1111 1111 1111 1111 1111 1111 1111
故(n << 1) ^ (n >> 31)后为:
1111 1111 1111 1111 1111 1111 1111 1110
1111 1111 1111 1111 1111 1111 1111 1111----两行执行不进位的半加操作
0000 0000 0000 0000 0000 0000 0000 0001
故:Zigzag(-1) = 1;
最终的效果就是把所有的整数映射为正整数,比如0->0, -1->1, 1->1, -2->3
解码时,解出正数之后再按映射关系映射回原来的负数。
我们设置 int32 val = -2。映射得到 3,那么对数字 3 进行 Varints 编码,将结果存储或发送出去。接收方接到数据后进行 Varints 解码,得到数字 3,再将 3 映射回 -2。
3.2.2.1、ZigZag 编码——sint32(sint64同)
1)、sint32 的例子:
syntax = "proto3";
// message 定义
message Example1 {
sint32 sint32Val = 1;
}
在程序中设置字段值为 -1,其编码结果为:
// 设置字段值 为 -1
Example1 example1;
example1.set_colorval(-1);
// 编码结果,1 映射回 -1
tag-(Varints)00001 000 + value-(Varints)0#000 0001 = 08 01
在程序中设置字段值为 -2,其编码结果为:
// 设置字段值 为 -2
Example1 example1;
example1.set_colorval(-2);
// 编码结果,3 映射回 -2
编码结果:tag-(Varints)00001 000 + value-(Varints)0#000 0011 = 08 03
3.2.3、64-bit 编码(32-bit 类型 同)
64-bit 和 32-bit 比较简单,与 Varints 一样其编码结构为 Tag-Value
,不同的是不管数字大小,64-bit 存储 8 字节,32-bit 存储 4 字节。读取时同理,64-bit 直接读取 8 字节,32-bit 直接读取 4 字节。
为什么需要 64-bit 和 32-bit?
之前已经分析过了 Varints编码在一定范围内是有高效的,超过某一个数字占用字节反而更多,效率更低。如果现在有场景是存在大量的大数字,那么使用 Varints就不太合适了,此时使用 64-bit 和 32-bit 更为合适。具体的,如果数值比 256 大的话,64-bit 这个类型比 uint64高效,如果数值比 228 大的话,32-bit 这个类型比 uint32 高效。
3.2.3.1、64-bit编码 ——fixed64、sfixed64、double
// message 定义
syntax = "proto3";
message Example1 {
fixed64 fixed64Val = 1;
sfixed64 sfixed64Val = 2;
double doubleVal = 3;
}
在程序中分别设置字段值 1、-1、1.2,其编码结果为:
// 设置字段值 为 -2
example1.set_fixed64val(1)
example1.set_sfixed64val(-1)
example1.set_doubleval(1.2)
// 编码结果,总是 8 个字节
09 # 01 00 00 00 00 00 00 00
11 # FF FF FF FF FF FF FF FF (没有 ZigZag 编码)
19 # 33 33 33 33 33 33 F3 3F
3.2.4、Length-delimited 类型编码实例
Length-delimited 类型的编码结构为 Tag - Length - Value
支持数据类型:string、bytes、EmbeddedMessage、repeated
message Test
{
required string name = 1;
}
设置相应的值:
#include <iostream>
#include "Test.pb.h"
#include <string>
#include <fstream>
using namespace std;
int main()
{
string fileName = "BinaryResult";//定义输出文件名
Test a;
a.set_name("Steven");//赋值
fstream output(fileName.c_str(), ios::out | ios::trunc | ios::binary);
a.SerializeToOstream(&output);
return 0;
}
最终编码的结果为:
十六进制:
0a06 5374 6576 656e
二进制表示为:
0000101000000110010100110111010001100101011101100110010101101110
编码采用Tag-Length-Value
方式
1、字段的Tag解码:00001|010
- field_number=1表示第一个字段的编号为1;
- 最后3位(010)表示wire_type=2指示了接下来的解码形式应该是Length-Value
2、字段的Length解码
- Length(00000110)=6指示接下来的Value占据6个字节。
3、字段的Value解码:
二进制:
010100110111010001100101011101100110010101101110
得到ASCII码:
83 116 101 118 101 110
对照ASCII码表得到的字符串为:
Steven
参考
1、https://www.zhihu.com/topic/19564722/hot
2、http://www.voidcn.com/article/p-orhbkble-oe.html
3、https://www.jianshu.com/p/73c9ed3a4877
4、https://www.cnblogs.com/jialin0x7c9/p/12418487.html