Protobuf进阶
一、默认值
前面我们说过:对于proto3的语法来说message
中的字段默认是用singular
来修饰的,被singular
修饰的字段在序列化的时候如果没有被设置值,那么protobuf
的序列化方法是不会将该字段进行编码的;在反序列化的时候,如果在反序列化序列中没有找到message
中某一字段的值,那么protobuf
会用该字段的默认值来填充该字段;
不同的类型对应的默认值不同,下面这张表就列举了不同类型的默认值:
以下是将您提供的信息转化为Markdown表格的示例:
字段类型 | 默认值描述 |
---|---|
string |
空字符串 |
bytes |
空字节 |
bool |
false |
数值类型(如 int32, uint32, sint32, fixed32 等) |
0 |
枚举 | 第一个定义的枚举值,必须为 0 |
消息字段 | 未设置时,取值依赖于语言 |
repeated 字段 |
空的(通常是相应语言的一个空列表) |
消息字段、oneof 字段和 any 字段 |
(C++ 和 Java) 有 has_ 方法来检测字段是否被设置 |
二、更新消息
我们在的序列化和反序列化时,除了考虑效率问题,有时还会考虑:字段的更新问题,如果现有的消息类型已经不再满足我们的需求,例如需要扩展一个字段,如何在不破坏任何现有代码的情况下更新消息类型呢?
1、规则介绍
在protobuf
中为了实现消息的更新,我们要遵循如下规则:
- 禁止修改任何已有字段的字段编号。
protobuf
在编码字段的时候实际上是将字段值、字段类型、字段编号一起进行编码的,而在反序列化的时候就是根据反序列化中的字段编号将字段值反序列化到对应字段,如果我们更改了已存在的字段的编号,那么很有可能造成数据错位或者数据丢失;
若是移除老字段,要保证不再使用移除字段的字段编号。正确的做法是保留字段编号(使用
reserved
的关键字),以确保该编号将不能被重复使用。(不建议直接删除或注释掉字段!)int32, uint32, int64, uint64
和bool
是完全兼容的。可以从这些类型中的一个改为另一个而不破坏前后兼容性。若解析出来的数值与相应的类型不匹配,会采用与 C++ 一致的处理方案(例如,若将 64 位整数当做 32 位进行读取,它将被截断为 32 位)。sint32
和sint64
相互兼容但不与其他的整型兼容。string
和bytes
在合法 UTF-8 字节前提下也是兼容的。bytes
包含消息编码版本的情况下,嵌套消息与bytes
也是兼容的。fixed32
与sfixed32
兼容,fixed64
与sfixed64
兼容。enum
与int32,uint32, int64
和uint64
兼容(注意若值不匹配会被截断)。但要注意当反序列化消息时会根据语言采用不同的处理方案:例如,未识别的 proto3 枚举类型会被保存在消息中,但是当消息反序列化时如何表示是依赖于编程语言的。整型字段总是会保持其的值。oneof
将一个单独的值更改为 新
oneof
类型成员之一是安全和二进制兼容的。
若确定没有代码一次性设置多个值那么将多个字段移入一个新
oneof
类型也是可行的。
将任何字段移入已存在的
oneof
类型是不安全的。
2、实例代码
server.proto
代码
client.proto
代码
这里两者是一样的!
read.cpp
反序列化代码
#include <iostream>
#include <fstream>
#include <filesystem>
#include "client.pb.h"
using namespace std;
namespace fs = std::filesystem;
void showContact(contact::Contacts& mycontact)
{
int sz = mycontact.peopleinfo_size();
for (int i = 0; i < sz; ++i)
{
cout << "---------------联系人" << i + 1 << "-----------------\n";
const ::contact::PeopleInfo& peopleinfo = mycontact.peopleinfo(i);
cout << "\t姓名 : " << peopleinfo.name() << endl;
cout << "\t年龄 : " << peopleinfo.age() << endl;
}
}
int main()
{
GOOGLE_PROTOBUF_VERIFY_VERSION;
fs::path file_path("contacts.data");
if (!fs::exists(file_path))
{
cerr << "文件不存在" << endl;
return 1;
}
ifstream ifs(file_path, ios::in | ios::binary);
if (!ifs.is_open())
{
perror("文件打开失败");
return 1;
}
// 反序列化
contact::Contacts mycontact;
if (!mycontact.ParseFromIstream(&ifs))
{
cerr << "解析失败" << endl;
return 1;
}
showContact(mycontact);
google::protobuf::ShutdownProtobufLibrary();
cout << "程序退出" << endl;
return 0;
}
write.cpp
序列化代码
#include <iostream>
#include <fstream>
#include <filesystem>
#include "server.pb.h"
using namespace std;
namespace fs = std::filesystem;
void addPeopleInfo(contact::Contacts& mycontact)
{
contact::PeopleInfo* people = mycontact.add_peopleinfo();
cout << "-------------新增联系人-------------" << endl;
// 添加姓名
string name;
cout << "请输入联系人姓名:";
getline(cin, name);
people->set_name(name);
// 添加年龄
int age;
cout << "请输入联系人年龄:";
cin >> age;
people->set_age(age);
// 清除age 输入以后的\n
cin.ignore();
cout << "\n-----------添加联系人成功-----------" << endl;
}
int main()
{
GOOGLE_PROTOBUF_VERIFY_VERSION;
fs::path file_path("contacts.data");
ofstream ofs;
if (!fs::exists(file_path))
{
// 文件不存在创建文件
ofs.open(file_path, ios::out | ios::binary);
}
else
{
// 文件存在打开文件
ofs.open(file_path, ios::app | ios::binary);
}
if (!ofs.is_open())
{
perror("文件打开失败!");
return 1;
}
// 到这里文件肯定是已经存在的并且可以正常打开
// 创建通讯录
contact::Contacts mycontact;
ifstream ifs(file_path, ios::in | ios::binary);
// 文件不为空文件
if (ifs.peek() != EOF)
{
// 读取文件
if (!mycontact.ParseFromIstream(&ifs))
{
cerr << "反序列化失败" << endl;
return 1;
}
// 清空文件
ofstream ofstmp("contacts.data", ios::trunc);
ofstmp.close();
}
// 添加新的联系人
addPeopleInfo(mycontact);
// 将通讯录中的数据持久化到文件中
mycontact.SerializeToOstream(&ofs);
ofs.close();
google::protobuf::ShutdownProtobufLibrary();
cout << "程序退出" << endl;
return 0;
}
编译我们的代码以后,运行程序:
没有问题!
服务端更新消息,客户端不更新消息
接下来,我们修改server.proto
文件新增字段int64 birthday= 2;
并且注释原来的年龄字段,同时修改write.cpp
中的代码。server.proto
write.cpp
文件
void addPeopleInfo(contact::Contacts& mycontact)
{
contact::PeopleInfo* people = mycontact.add_peopleinfo();
cout << "-------------新增联系人-------------" << endl;
// 添加姓名
string name;
cout << "请输入联系人姓名:";
getline(cin, name);
people->set_name(name);
// 添加年龄
// int age;
// cout << "请输入联系人年龄:";
// cin >> age;
// people->set_age(age);
int64_t birthday;
cout << "请输入联系人生日 : ";
cin >> birthday;
people->set_birthday(birthday);
// 清除输入以后的\n
cin.ignore();
cout << "\n-----------添加联系人成功-----------" << endl;
}
重新编译,运行程序:
可以看到数据还是正常解析了,这证明了我们前面说的兼容规制。
同时这里也有一个一个新问题,就是数据的认知不一致的问题,我么期望将这个数据解释为生日,而客户端却将这个数据解释为了年龄这个问题,我们需要去解决。
出现这个问题的原因是因为:我们设置的birthday
与废弃的age
使用了同一个编号。
在服务端,我们可以将birthday
这个字段设置为3,但是这个已经存在的2
编号,我们也不能给后续的字段使用,否则还是会出现数据不一致的问题。
所有我们需要保留字段,设置这个字段不能再被使用!
三、保留字段
如果通过 删除 或 注释掉字段来更新消息类型,未来的用户在添加新字段时,有可能会使用以前已经存在,但已经被删除或注释掉的字段编号。将来使用该 .proto
的旧版本时的程序会引发很多问题:数据损坏、隐私错误等等。
确保不会发生这种情况的一种方法是:使用 reserved
将指定字段的编号或名称设置为保留项 。当我们再使用这些编号或名称时,protocol buffer
的编译器将会警告这些编号或名称不可用。
举个例子,对于上面出现的问题我们使用reversed
进行解决 :
syntax = "proto3";
package contact;
// 联系人信息
message PeopleInfo
{
// 表示 2号编号不可用
reserved 2;
// 姓名
string name = 1;
// 年龄
// int32 age = 2;
int64 birthday = 2;
}
// 通讯录信息
message Contacts
{
repeated PeopleInfo peopleinfo = 1;
}
同时,我们也可以使用,
来指定多个保留的编号,使用to
关键字来设置一批编号不可以使用。
reserved 2, 100, 300 to 400;
编译.proto
文件
可以看到protoc
出现了错误提示!
我们将编号设置为3, int64 birthday = 3;
再次进行编译,运行我们的程序:
可以看到数据正常识别了。
client
中由于找不到年龄对应的值,使用了默认值进行反序列化,这是正常的- 对于字段编号为
3
的birthday
由于客户端无法识别,没有显示,这也是正常的,总之这次没有再将birthday
的值解析为age
的值了。
我们知道客户端解析数据时一定会尝试解析birthday
进行反序列化,但是由于客户端的proto
文件中没有更新,没有办法识别这个数据,那这个birthday
数据客户端是怎么处理的呢?是直接抛弃吗?还是说保留下来,如果保留下来,哪有保留在哪里了呢?
这就和未知字段有关了,我们接下来开始学习未知字段!
四、未知字段
未知字段: 当旧protobuf
程序解析带有新字段的数据时,这些新字段就会成为旧程序的未知字段。
本来,proto3
在解析消息时总是会丢弃未知字段,但在 3.5
版本中重新引入了对未知字段的保留机制。所以在 3.5
或更高版本中,未知字段在反序列化时会被保留在反序列化的对象中,同时也会包含在序列化的结果中。
可以使用下面的命令查看protrobuf
的版本:
protoc --version
1、 如何获取未知字段
获取未知字段的流程很复杂,我们需要先了解一下protobuf
内部的结构 :
看到这里你可能会觉得很复杂,让我们慢慢来了解这些类的关系
MessageLite 类
MessageLite
从名字看是轻量级的message
,仅仅提供序列化、反序列化功能。- 类定义在 google 提供的
message_lite.h
中。
Message 类
- 前面我们已经说过我们自定义的
message
类,都是继承自Message
。 - 类定义在
google
提供的message.h
中。 - Message 最重要的两个接口
GetDescriptor/GetReflection
,可以获取该类型对应的Descriptor
对象指针 和Reflection
对象指针。
- 前面我们已经说过我们自定义的
// 常用方法
const Descriptor* GetDescriptor() const;
const Reflection* GetReflection() const;
Descriptor 类
Descriptor
:是对message
类型定义的描述,包括message
的名字、所有字段的描述、原始的proto
文件内容等。- 类定义在
google
提供的descriptor.h
中。
Reflection 类
Reflection
接口类,主要提供了动态读写消息字段的接口,对消息对象的自动读写主要通过该类完成。- 提供方法来动态访问/修改
message
中的字段,对每种类型,Reflection
都提供了一个单独的接口用于读写字段对应的值。 - 针对所有不同的
field
类型FieldDescriptor::TYPE_*
,需要使用不同的Get*()/Set*()/Add*()
接进行获取或者修改; repeated
类型需要使用GetRepeated*()/SetRepeated*()
接口,不可以和非repeated
类型接口混用;message
对象只可以被由它自身的reflection
(message.GetReflection()得到
) 来操作;- 通过这个反射类中我们可以访问/修改未知字段。
- 类定义在
google
提供的message.h
中。
// 常用方法
// 获得反射对象中的未知字段集合
const UnknownFieldSet& GetUnknownFields(const Message& message) const;
- UnknownFieldSet 类
UnknownFieldSet
包含在分析消息时遇到但未由其类型定义的所有字段。- 若要将
UnknownFieldSet
附加到任何消息,请调用Reflection::GetUnknownFields()
。 - 类定义在
unknown_field_set.h
中。
// 常用的方法
// 获得未知字段的个数
int UnknownFieldSet::field_count() const
// 根据下标获取未知字段的值
const UnknownField& UnknownFieldSet::field(int index) const
- UnknownField 类介绍
- 表示未知字段集中的一个字段。
- 类定义在
unknown_field_set.h
中。
// 常用的方法
// 获取未知字段的类型
UnknownField::Type UnknownField::type() const
// 以整形的方式进行提取
uint64_t UnknownField::varint() const
// 以定长32类型来提取
uint32_t UnknownField::fixed32() const
// 以定长64类型来提取
uint64_t UnknownField::fixed64() const
// 以字符串类型进行提取
const std::string& UnknownField::length_delimited() const
// 以未知类型类进行继续提取
const UnknownFieldSet& UnknownField::group() const
- UnknownField::Type 枚举
enum Type {
TYPE_VARINT, // 表示的是变长整形
TYPE_FIXED32, // 表示是定长32类型,float类型
TYPE_FIXED64, // 表示定长64类型, double类型
TYPE_LENGTH_DELIMITED, // 表示字符串类型
TYPE_GROUP // 表示是一个未知字段的集合,需要进一步解析
};
2、提取未知字段
根据上面类的关系我们来提取未知字段的值:
- 使用
GetReflection()
得到反射对象 - 使用
GetUnknownFields
得到未知对象集合 - 使用
unknownFieldSet.field_count()
得到未知字段的个数 - 在循环中,通过
unknownFieldSet.field(i)
以此得到每一个未知字段 - 使用
unknownField.type()
确定每一个字段的类型 - 根据不同的类型,使用不同的方法进行提取。
read.cpp
反序列化代码
void showContact(contact::Contacts& mycontact)
{
using namespace google::protobuf;
int sz = mycontact.peopleinfo_size();
for (int i = 0; i < sz; ++i)
{
cout << "---------------联系人" << i + 1 << "-----------------\n";
const ::contact::PeopleInfo& peopleinfo = mycontact.peopleinfo(i);
cout << "\t姓名 : " << peopleinfo.name() << endl;
cout << "\t年龄 : " << peopleinfo.age() << endl;
// 取得reflection 对象
const Reflection* reflection = contact::PeopleInfo::GetReflection();
const UnknownFieldSet& unknownFieldSet = reflection->GetUnknownFields(peopleinfo);
// 提取未知字段
for (int i = 0; i < unknownFieldSet.field_count(); ++i)
{
const UnknownField& unknownField = unknownFieldSet.field(i);
switch (unknownField.type())
{
case UnknownField::Type::TYPE_VARINT:
cout << "未知字段: " << i + 1 <<" : " << unknownField.varint() << endl;;
break;
case UnknownField::Type::TYPE_FIXED32:
cout << "未知字段: " << i + 1 <<" : " << unknownField.fixed32() << endl;
break;
case UnknownField::Type::TYPE_FIXED64:
cout << "未知字段: " << i + 1 <<" : " << unknownField.fixed64() << endl;
break;
case UnknownField::Type::TYPE_LENGTH_DELIMITED:
cout << "未知字段: " << i + 1 <<" : " << unknownField.length_delimited() << endl;
break;
case UnknownField::Type::TYPE_GROUP:
// 递归解析
break;
default:
break;
}
}
}
}
结果:
可以看到我们客户端得到了未知字段的值
3、意义
根据上述的例子可以得出,pb
是具有向前兼容的。为了叙述方便,把增加了“生日”属性的 server
称为“新模块”;未做变动的 client
称为 “老模块”。
- 向前兼容:老模块能够正确识别新模块生成或发出的协议。这时新增加的“生日”属性会被当作未知字段(pb 3.5版本及之后)。
- 向后兼容:新模块也能够正确识别老模块生成或发出的协议。
前后兼容的作用:当我们维护一个很庞大的分布式系统时,由于你无法同时 升级所有 模块,为了保证在升级过程中,整个系统能够尽可能不受影响,就需要尽量保证通讯协议的“向后兼容”或“向前兼容”。
五、option选项
1、 选项介绍
.proto
文件中可以声明许多选项,使用 option
标注。选项能影响 proto
编译器的某些处理方式。
举个例子:
看下面的proto文件
编译之后我们打开生成的头文件进行查看:
可以看到我们自定义的消息的父类变成了MessageLite
类,这就是option
选项的作用。
2、 选项分类
选项的完整列表在google/protobuf/descriptor.proto
中定义。
ls /usr/local/protobuf/include/google/protobuf/descriptor.proto
部分代码:
可以看出 descriptor.proto
使用 proto2
语法版本。
其里面的内容大致分为这几类
message FileOptions { ... } // 文件选项 定义在 FileOptions 消息中
message MessageOptions { ... } // 消息类型选项 定义在 MessageOptions 消息中
message FieldOptions { ... } // 消息字段选项 定义在 FieldOptions 消息中
message OneofOptions { ... } // oneof字段选项 定义在 OneofOptions 消息中
message EnumOptions { ... } // 枚举类型选项 定义在 EnumOptions 消息中
message EnumValueOptions { .. } // 枚举值选项 定义在 EnumValueOptions 消息中
message ServiceOptions { ... } // 服务选项 定义在 ServiceOptions 消息中
message MethodOptions { ... } // 服务方法选项 定义在 MethodOptions 消息中
可以看出,这些选项分为文件级、消息级、字段级*等等, 但并没有一种选项能作用于所有的类型。
3、常用选项列举
optimize_for
: 该选项为文件选项,可以设置protoc
编译器的优化级别,分别为SPEED
、CODE_SIZE
、LITE_RUNTIME
。受该选项影响,设置不同的优化级别,编译 .proto 文件后生成的代码内容不同。SPEED
:protoc
编译器将生成的代码是高度优化的,代码运行效率高,但是由此生成的代码编译后会占用更多的空间。SPEED 是默认选项。CODE_SIZE
:proto
编译器将生成最少的类,会占用更少的空间,这种方式是不会包含protobuf
的反射库,但和SPEED 恰恰相反,它的代码运行效率较低。这种方式适合用在包含大量的.proto
文件,但并不盲目追求速度的应用中。LITE_RUNTIME
: 生成的代码执行效率高,同时生成代码编译后的所占用的空间也是非常少。这是以牺牲Protocol Buffer
提供的反射功能为代价的,仅仅提供encoding
+ 序列化 功能,所以我们在链接 PB 库时仅需链接libprotobuf-lite
,而非libprotobuf
。这种模式通常用于资源有限的平台,例如移动手机平台中。
allow_alias
: 允许将相同的常量值分配给不同的枚举常量,用来定义别名。该选项为枚举选项。
举个例子:
4、设置自定义选项
Protobuf
允许自定义选项并使用。该功能大部分场景用不到,有兴趣可以参考:https://developers.google.cn/protocol-buffers/docs/proto?hl=zhcn#customoptions