序列化协议Protobuf(二):C++ Proto3指南
protobuf使用
虽然protobuf的版本管理比较拉跨,然而其使用指南可以说非常详尽与方便,具体可参考:
本文记录了其中proto3使用和C++构建和使用proto较重点的部分,只有少量proto2部分(少用;
proto 3基础语法
第一行必须非空、非注释行,表示版本信息,如:syntax = "proto3";
若缺省默认为proto2;
第二行一般使用package xxx;指明包名,防止不同protobuf定义的冲突;
数据类型
标量类型(Scalar Value Types)
一般数据类型,和C++数据类型类似;
类型 | 说明 | 类型 | 说明 |
---|---|---|---|
double | 64浮点 | float | 32浮点 |
uint32 | 相当于uint32,可变长度编码 | fixed32 | 相当于uint32,定长4字节,值大于\(2^{28}\)更高效 |
uint64 | 相当于uint64,可变长度编码 | fixed32 | 相当于uint64,定长8字节,值大于\(2^{56}\)更高效 |
int32 | 相当于int32,可变长度编码 | sint32 | 相当于int32,可变长度编码,对负数编码更高效 |
int64 | 相当于int64,可变长度编码 | sint64 | 相当于int64,可变长度编码,对负数编码更高效 |
sfixed32 | 相当于int32,定长4字节 | bool | 布尔:false/true |
sfixed64 | 相当于int32,定长8字节 | string | 仅UTF-8字符 |
bytes | 任意二进制编码 |
string
和bytes
的区别是string会在序列化时检查UTF-8格式,UTF-8对二进制编码是有要求的,其变长编码开头几位是固定头,因此如果任意读取文本、音频等的二进制编码不应该使用string;
枚举类型enum
自定义枚举类型:第一个值必须为0值,其也充当了默认值;枚举类型最多支持32位,为了编码效率,不应该使用负数;
1
2
3
4
5
6
7
8
9
10enum Corpus {
CORPUS_UNSPECIFIED = 0;
CORPUS_UNIVERSAL = 1;
CORPUS_WEB = 2;
CORPUS_IMAGES = 3;
CORPUS_LOCAL = 4;
CORPUS_NEWS = 5;
CORPUS_PRODUCTS = 6;
CORPUS_VIDEO = 7;
}
枚举类型定义在message里面或者外面,都能够被重新复用,如果在某message内,使用message_.enum_访问对应的类型;
enum
的字段并不要求一一对应,因为enum
存在别名机制,两个枚举字段可映射到同一个编号,两字段互为别名,因此尽管弃用0编号别名,也能保证新字段能够利用该编号:
1
2
3
4
5
6enum Color{
PINK = 0[deprecated=true]; //若不弃用,PINK和YELLOW互为别名
YELLOW = 0;
WHITE = 1;
BLACK = 2;
}
自定义消息类型message
最常用类型,定义了要序列化结构体的信息,在proto3中,只有枚举类型支持设置默认值;
1
2
3
4
5
6
7
8
9
10enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
string number = 1;
PhoneType type = 2[default = HOME];
}
且message
可嵌套,其不同嵌套层级的命名也是相对独立的,如下的Inner;
1
2
3
4
5
6
7
8
9
10
11
12
13
14message Outer { // Level 0
message MiddleAA { // Level 1
message Inner { // Level 2
int64 ival = 1;
bool booly = 2;
}
}
message MiddleBB { // Level 1
message Inner { // Level 2,OK
int32 ival = 1;
bool booly = 2;
}
}
}
删除字段
必须要保证message
内名称和编号是一一对应的,因为protobuf的编码不是根据名称,而是编号,编号范围为1
到536870911
,其中19000
到19999
被protobuf保留,若使用会告警;
此外,对于编号需要谨慎处理,当我们删除字段时,不要直接注释或者删除该字段,应该使用关键字以声明该字段被弃用:
法1:通过[deprecated = true]
,如string deprecated_field = 20 [deprecated = true]
;
法2(推荐):reserved
关键字,如reserved 20
;reserved "deprecated_field"
;
若多个:reserved 200 to 299;
编号被reserved,新增字段时也不应该被重新使用,避免编号冲突导致数据损坏、隐私泄露;
此外,一旦消息类型被使用,对应的编号不应该发生更改(更改相当于删除该编号、重新创建名称和编号);
最常用的字段应该设置为0到15,因为它们在序列化中只占据1个字节,而更高编号会占据更多字节;
新增一个字段生成的代码仍然可以解析旧格式数据;
Specifying Field Cardinality
proto3采用了若干种关键字指定字段基数(即结构体某个字段在实际信息中出现次数),常用为optional(缺省)/repeated/map
等;
optional
proto3初始版本丢弃了proto2的optional,更名为Singular,但在3.19后重新引入,optional是缺省类型时的默认选择,表示该字段在message中只会出现0次或1次,它具有两种状态:
- 没有被设置,其不会出现在序列化中,反序列化时返回默认值;
- 被设置,将被序列化到线路中;
对于proto3语法,非repeated
的字段可缺省类型,默认就是singular
,这里singular
隐式地指向optional
,另一种同样表示singular
的关键字是implicit
,它并不推荐使用,因为它不含“显式基数标签”,因此在序列化时如果一个值被设置成默认值,它无法分辨究竟是没有设置该字段,还是设置成默认值,尽管设置了默认值使用has_xxx函数判断都会返回false,这也是proto2中optional的特性;
即如果需要区分一个值是否被设置,有两种方法:
- 显式指定optional,此时会对应生成
has_xxx
函数进行判断;
- 显式指定optional,此时会对应生成
- 缺省类型,此时仍然是optional,但是不会生成
has_xxx
接口,此时还需要区分只能将简单字段封装成message,才会生成has_xxx接口;
- 缺省类型,此时仍然是optional,但是不会生成
显然前者是更方便的。
特别的,optional
如果被用于修饰message
,不会改变其存在状态,message
类型是恒定存在的(包括空message);
repeated
表示该字段在message
中可出现0次或者多次,即类似数组一样;
map
1 | map<key_type, value_type> map_field = N; |
key_type
不能是浮点数
、bytes
类型,也不能直接用enum
类型定义,但因为enum
在wire format
中和int32/int64/uint32/uint64
兼容,因此对其传值是OK的,底层用整数存储。而value_type
可以是任意数据类型(不能是另一个map类型;
map
本身已经有数组的意味了,不能再使用repeated进行修饰,实际上从Encoding中,序列化上map
类型实际上是复写了一种嵌套:
1
2
3
4
5
6
7
8map<string, int32> g = 7;
//上述等效于:
message g_Entry {
optional string key = 1;
optional int32 value = 2;
}
repeated g_Entry g = 7;
map序列化时如果序列化成wire格式,则是无序的,若序列化为text格式且键是数字的,则按数字排序;此外****反序列化****时,若是wire格式,存在相同的键则使用最后序列化的,如果是text格式,序列化有可能失败。
数据类型兼容性
兼容性是指一种数据类型转换到另一种数据类型,也不会破坏数据的表达意义,因为其截断策略是相同的,只是可能需要使用C++的方法将类型重新转换到原来类型,兼容关系如下:
int32
,uint32
,int64
,uint64
,bool
均是兼容的(不同长度会截断);
sint32
和sint64
兼容,与其他均不兼容;
string
和bytes
在UTF-8数据上是兼容的;
fixed32
与sfixed32
兼容,fixed64
与sfixed64
兼容;
- 在
wire format
下,即一般二进制编码,enum
和int32/int64/uint32/uint64
均是兼容的;但需要注意,当反序列化遇到未定义的enum值时,该值一般会保留在消息中,后续如何表示,不同的语言会有不同的策略,例如C++提供相关方法,来判断其是否为未定义的enum值,而Go会提供一个带原始数值的特殊类型;对于int等整数类型则没有这种现象,数值总能被保留并且表示; **
- 在
bytes
和嵌套的message
是兼容的:1
2
3
4
5
6
7
8
9message test{
...
}
message test1{
message test = 1;
//编码上等效于:
bytes test = 1;
}
repeated
和singular
兼容性说明:对于string
、bytes
、message
类型,它们的单数和重复数是兼容的,即在旧版本上声明repeated,也不会影响数据本身解析,但是具体接收行为有所不同,对于前二者,旧客户端按照单数读取,会取重复序列化的最后一个对象;对于message
类型,旧客户端会将所有repeated message全部打包成一个单数message;对于此外的
整数、enum、bool
等类型,其repeated
和singular
是不兼容的,无法正确解析数据:这个原因在encoding规则可以了解,数值类型均会被声明为packed(在proto2中需要[packed=true]显示指定,在proto3是数值类型自动的),所谓的packed,即多个repeated数值传输时会序列化一个包,如:repeated int32 f = 6 [packed=true]
将序列化为3206038e029ea705二进制码
或6: {3 270 86942}打包的键值文本
,因此无法正确兼容repeated
和singular
;
等等;
Unknown Fields
基于兼容性和非统一格式读写,会产生消息中未知字段,只需要知道这些字段仍然会保留在消息中,但一些行为可能导致其永久丢失:例如将proto转换成json、迭代message以设置新的字段、使用文本而不是wire进行读写等;避免未知消息丢失还应使用CopyFrom()
和MergeFrom()
面对消息操作,而不是逐个字段解析。
高阶数据类型
Any
Any允许使用某个字段代替任何的数据类型,而不提前声明这个字段的具体类型,需要import "google/protobuf/any.proto"
,例如:
1
2
3
4
5
6import "google/protobuf/any.proto";
message ErrorStatus {
string message = 1;
repeated google.protobuf.Any details = 2; ///声明为Any
}
但打包和解包时,需要使用相关方法对该数据指明具体类型并进行序列化和解析,如C++的PackFrom()和UnpackTo();
Oneof
当一个message
中具有多个singular
成员,且最多只会设置一个成员的值时,可以考虑使用Oneof节省内存,因为在其内部内存是共享的,类似Union;
且注意:Oneof
的成员不能是repeated、map
字段,但不得不使用重复字段时,使用包含重复字段的message代替**,如:
1 | Oneof{ ///最多一个成员被设置值 |
import定义
proto允许使用另一个proto文件定义的自定义类型,使用import导入相关的包:
1 | import "myproject/other_protos.proto"; |
在编译时,可以使用-I/--proto_path
参数指定一个搜索目录,否则默认在编译器所在目录搜索;
序列化与反序列化实例
可能阅读规则已经看懵了,这里我引入了一个实例,能够辅助上面内容的理解,在能够看懂proto后,进行C++的序列化写入和读取,对于一般使用就够了。因为代码比较多且涉及多文件关系,提高观感,以下实例上传到代码仓库:ProtoBuf_Test
构造proto
为了了解导入语义,实例分成三个proto文件,且基本涵盖了常用的嵌套关系,结构如下:
定义了AddressBookProto
为地址本消息,其中包含一个Admin
管理员和含若干家庭Family
的community
,clarify
是为了介绍Any特别引入的,它在后面的实例中会分别解析成不同的数据类型;该社区以肤色Color
进行map组织,即community = map<Color,Family>
,Family
的成员是多个人,每个人又是一个结构体消息,其中一个信息是电话号码,一个人可以有0个或者多个电话号码,电话号码信息成员是电话类型和电话号,如下:
addressBook.proto
: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28syntax = "proto3";
package info.addressbook;
import "google/protobuf/any.proto";
import "phone.proto";
import "person.proto";
enum Color{ //肤色枚举:community的int32使用
YELLOW = 0;
WHITE = 1;
BLACK = 2;
}
message Family{ //家庭成员
repeated info.person.PersonProto people = 1;
}
//地址本消息:一个管理员 + 人种分类家庭消息 + 说明
message AddressBookProto{
message Admin{
info.phone.PhoneNumberProto admin_phone= 1;
optional string name = 2; //显式optional:生成has_name接口判断是否被set
}
map <int32,Family>community = 1; //多个家庭,分为黄人、白人、黑人家庭
repeated google.protobuf.Any clarify = 2; //说明字段,我们的目标将是把Any解释成string\int\message等测试
Admin admin = 3; //实例化一个管理员
}person.proto
: 1
2
3
4
5
6
7
8
9
10
11syntax = "proto3";
package info.person;
import "phone.proto";
message PersonProto{
string name = 1;
int32 id = 2;
string email = 3;
repeated info.phone.PhoneNumberProto phones = 4;
}phone.proto
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14syntax = "proto3";
package info.phone;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumberProto {
string number = 1;
PhoneType type = 2;
}
使用protoc
生成三个C++对应的头文件和源文件并且添加到项目:-I
表示import proto
的搜索目录
1
protoc .\phone.proto .\person.proto .\addressBook.proto -I . --cpp_out .
#define PROTOBUF_USE_DLLS
;
写入信息
新增初始化信息规律:
mutable_和add_均能返回对象的指针,然后使用set_设置或者修改;
其中
add_
是针对新增带repeated的复杂类型(message);mutable_则是针对新增singular复杂类型;
对于singular简单类型,如string、int32等,直接使用set_即可初始化设置;如果带repeated,既可以使用add_也可以使用mutable_进行获取,
Any写入简单数据类型,使用StringValue等封装在packfrom;写入message,先new一个对象按照上面填充message的方式再packfrom;
map写入,采用operator[] = xxx的方法;
1 | struct familyInfo { |
读取信息
读取信息规律:从最大封装开始取其成员,取值方法都是.xxx(); - map读取:将其装到STL的map中方便读取。
Any读取:通过
Is<google::protobuf::StringValue>()
等判断其初始类型,将Any UnpackTo该原始类型,再使用.value()函数获取其对应的C++简单类型,如果原始类型是message,则按照该message策略进行提取。无论singular、repeated,复杂类型也有has_xxx,简单类型只有显式声明optional才会产生;对于map、repeated any等,采用数组遍历或者size函数可以替代;
1 | //AddressBookProto解析 |
修改数据
基本上是使用mutable_
获取要修改的对象指针,如果是修改message类型的Any,Unpack以后重新打包;
这里一个坑是修改应该使用原地操作,例如map必须使用auto&
来获取对应的键值,而不是auto
;同理,假设写成info::addressbook::Family family = pos.second
,再进行family修改也是错误的,因为这也不是原地操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36void map_modify(info::addressbook::AddressBookProto& addressBookProto) {
for (auto& [key,family]:*addressBookProto.mutable_community()) {
info::person::PersonProto* people0 = family.mutable_people(1);
people0->set_name("Modify_name");
people0->set_id(0);
people0->set_email("Modify_email");
for (auto& ppos : *people0->mutable_phones()) {
ppos.set_type(info::phone::MOBILE);
ppos.set_number("00000");
}
}
//Any为string
for (auto& pos : *addressBookProto.mutable_clarify()) {
if (pos.Is<google::protobuf::StringValue>()) {
google::protobuf::StringValue str;
str.set_value("Modify Any String");
pos.PackFrom(str);
}
else if (pos.Is<google::protobuf::Int32Value>()) {
google::protobuf::Int32Value num;
num.set_value(0);
pos.PackFrom(num);
}
else if (pos.Is<info::addressbook::AddressBookProto::Admin>()) {
info::addressbook::AddressBookProto::Admin clarify_admin;
pos.UnpackTo(&clarify_admin);
clarify_admin.set_name("Modify extra Admin");
info::phone::PhoneNumberProto* admin_phone = clarify_admin.mutable_admin_phone();
admin_phone->set_type(info::phone::MOBILE);
admin_phone->set_number("00000");
pos.PackFrom(clarify_admin);
}
}
}
wire format:数据序列化
提供了序列化到文件、字符串两种写法: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19//序列化方法
int Serialize(info::addressbook::AddressBookProto& addressBookProto) {
//填充完毕,法一:序列化到字符串再以二进制写入文件
string str_output;
addressBookProto.SerializeToString(&str_output);
ofstream output_StrFile("output_StrFile.pb", ios::binary);
if (!output_StrFile.is_open())
return -1;
output_StrFile.write(str_output.c_str(), str_output.size());
output_StrFile.close();
//法二:直接序列化到文件
ofstream output_file("Serialize_output.pb", ios::binary);
if (!output_file.is_open())
return -1;
addressBookProto.SerializeToOstream(&output_file);
output_file.close();
return 0;
}
wire format:数据反序列化
提供了从文件数据反序列化、经过字符串反序列化两种写法,其中字符串处理既可以采用分块读取字符串,也可以采用迭代器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37//反序列化文件/字符串方法:
int AntiSerialize(string serializePath) {
ifstream input_file(serializePath, ios::binary);
if (!input_file.is_open())
return -1;
//法一:ParseFromIstream文件输入流直接反序列化成目标message
info::addressbook::AddressBookProto addressBookProto;
addressBookProto.ParseFromIstream(&input_file);
map_read(addressBookProto);
///法二:分块读取字符串,并对字符串反序列化
char buf[BUFSIZE];
string strBuf;
while (input_file.read(buf, BUFSIZE)) {
strBuf.append(buf, input_file.gcount()); //gcount()返回实际read的字节数
}
strBuf.append(buf, input_file.gcount()); //最后还要read掉末尾剩下、不足BUFSIZE的
//法二之二:迭代器从文件内容构造string:适用于中小文件,GB级别的应该使用分块读取
string strBuf((istreambuf_iterator<char>(input_file)), istreambuf_iterator<char>());
info::addressbook::AddressBookProto addressBookProto2;
addressBookProto2.ParseFromString(strBuf);
printAddressProto(addressBookProto2);
input_file.close();
return 0;
}
CRC校验
为了验证多种方法结果是否一致,以免反序列化出现空格、制表符等空白符无法识别,这里提供一种思路,采用CRC校验文件数据,确保若干种方法的结果是一致无误的,上述几种方法均经过该验证,需要boost库支持,比较简单。
MSVC + boost库
boost库是C++社区享有盛誉的一个开源库,支持丰富的文本、字符串、容器、迭代器、图像、模板编程、并发编程等处理,结构语法精美,MSVC环境安装方法比较简单:从官方boost库地址获取,解压后执行bootstrap.bat
处理(确保本地VS环境正常),生成b2.exe
可执行文件,使用命令b2.exe toolset=msvc stage
将使用msvc编译boost库到目录下的stage文件夹,MSVC只需要包含当前的下载目录即可使用boost库。
文件内容校验
计算每个文件的CRC冗余码,然后对比即可: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17//计算文件crc
uint32_t calcuCRC32(const string& filePath) {
ifstream inputFile(filePath, ios::binary);
boost::crc_32_type crc;
char buf[4096];
while (inputFile.read(buf, 4096)) {
crc.process_bytes(buf, inputFile.gcount());
}
crc.process_bytes(buf, inputFile.gcount());
return crc.checksum();
}
//比较文件crc
bool crc_compare(const string& filePath1, const string& filePath2) {
return calcuCRC32(filePath1) == calcuCRC32(filePath2);
}