【ProtoBuf】ProtoBuf 进阶实战:默认值、消息更新与兼容性最佳实践
一、默认值
反序列化消息时,如果被反序列化的⼆进制序列中不包含某个字段,反序列化对象中相应字段时,就会设置为该字段的默认值。不同的类型对应的默认值不同:
对于字符串,默认值为空字符串
对于字节,默认值为空字节
对于布尔值,默认值为 false
对于数值类型,默认值为 0
对于枚举,默认值是第⼀个定义的枚举值, 必须为 0
对于消息字段,未设置该字段。它的取值是依赖于语⾔
对于设置了 repeated 的字段的默认值是空的( 通常是相应语⾔的⼀个空列表 )
对于
消息字段
、oneof
字段 和any
字段 ,C++ 和 Java 语⾔中都有has_
⽅法来检测当前字段是否被设置
场景理解:has方法的作用
如果没有has方法,这里我们反序列化得到的数据,我不知道c数值是默认值还是输入导致的。但是这不是一个很严重的问题,可以兼容这个问题。比如没有银行卡和银行卡没钱,都是想表达没钱。
二、更新消息
2.1 更新规则
如果现有的消息类型已经不再满⾜我们的需求,例如,需要扩展⼀个字段,在不破坏任何现有代码的情况下更新消息类型⾮常简单。遵循如下规则即可:
禁⽌修改任何已有字段的字段编号
若是移除⽼字段,要保证不再使⽤移除字段的字段编号。正确的做法是保留字段编号(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.2 保留字段 reserved
如果通过 删除 或 注释掉 字段来更新消息类型,未来的⽤⼾在添加新字段时,有可能会使⽤以前已经存在,但已经被删除或注释掉的字段编号。将来使⽤该
.proto
的旧版本时的程序会引发很多问题:数据损坏、隐私错误等等
确保不会发⽣这种情况的⼀种⽅法是:使⽤ reserved 将指定字段的编号或名称设置为保留项 。当我们再使⽤这些编号或名称时,protocol buffer
的编译器将会警告这些编号或名称不可⽤。举个例子
message Message {
// 设置保留项
reserved 100, 101, 200 to 299;
reserved "field3", "field4";
// 注意:不要在⼀⾏ reserved 声明中同时声明字段编号和名称。
// reserved 102, "field5";
// 设置保留项之后,下⾯代码会告警
int32 field1 = 100; //告警:Field 'field1' uses reserved number 100
int32 field2 = 101; //告警:Field 'field2' uses reserved number 101
int32 field3 = 102; //告警:Field name 'field3' is reserved
int32 field4 = 103; //告警:Field name 'field4' is reserved
}
2.3 创建通讯录 3.0 版本---验证 错误删除字段 造成的数据损坏
现模拟有两个服务,他们各⾃使⽤⼀份通讯录 .proto ⽂件,内容约定好了是⼀模⼀样的。
服务1(service):负责序列化通讯录对象,并写⼊⽂件中
服务2(client):负责读取⽂件中的数据,解析并打印出来
⼀段时间后,service
更新了⾃⼰的.proto ⽂件
,更新内容为:删除了某个字段,并新增了⼀个字段,新增的字段使⽤了被删除字段的字段编号。并将新的序列化对象写进了⽂件。
但 client
并没有更新⾃⼰的 .proto ⽂件。根据结论,可能会出现数据损坏的现象,接下来就让我们来验证下这个结论。
新建两个⽬录:service
、client
。分别存放两个服务的代码。
service
⽬录下新增 contacts.proto
(通讯录 3.0)
syntax = "proto3";
package s_contacts;
//联系人
message PeopleInfo{
reserver 2,10,11, 100 to 200;
reserver age;
string name = 1; //姓名
int32 age = 2; //年龄
message Phone{
string number = 1; //电话号码
}
repeated Phone phone = 3; //电话
}
//通讯录
message Contacts{
repeated PeopleInfo contacts = 1;
}
client
目录下新增 contacts.proto
(通讯录 3.0)
syntax = "proto3";
package c_contacts;
//联系人
message PeopleInfo{
string name = 1; //姓名
int32 age = 2; //年龄
message Phone{
string number = 1; //电话号码
}
repeated Phone phone = 3; //电话
}
//通讯录
message Contacts{
repeated PeopleInfo contacts = 1;
}
继续对 service 目录下新增 service.cc (通讯录 3.0),负责向文件中写通讯录消息,内容如下:
#include <iostream>
#include <fstream>
#include "contacts.pb.h"
using namespace std;
using namespace s_contacts;
// 新增联系⼈
void AddPeopleInfo(PeopleInfo *people_info_ptr) {
cout << "-------------新增联系⼈-------------" << endl;
cout << "请输⼊联系⼈姓名: ";
string name;
getline(cin, name);
people_info_ptr->set_name(name);
// cout << "请输⼊联系⼈年龄: ";
// int age;
// cin >> age;
// people_info_ptr->set_age(age);
// cin.ignore(256, '\n');
cout << "请输⼊联系⼈生日: ";
int birthday;
cin >> birthday;
people_info_ptr->set_birthday(birthday);
cin.ignore(256, '\n');
for(int i = 1; ; i++) {
cout << "请输⼊联系⼈电话" << i << "(只输⼊回⻋完成电话新增): ";
string number;
getline(cin, number);
if (number.empty()) {
break;
}
PeopleInfo_Phone* phone = people_info_ptr->add_phone();
phone->set_number(number);
}
cout << "-----------添加联系⼈成功-----------" << endl;
}
int main(){
Contacts contacts;
//先读取已存在的 contacts
fstream input("../contact.bin", ios::in | ios::binary);
if (!input) {
cout << "contacts.bin not found. Creating a new file." << endl;
} else if (!contacts.ParseFromIstream(&input)) {
cerr << "Failed to parse contacts." << endl;
input.close();
return -1;
}
//新增一个联系人
AddpeopleInfo(contact.add_contacts());
//向磁盘文件写入信的contacts
fstream output("../contacts.bin", ios::out | ios::trunc | ios::binary);
if (!contacts.SerializeToOstream(&output)) {
cerr << "Failed to write contacts." << endl;
input.close();
output.close();
return -1;
}
input.close();
output.close();
return 0;
}
client 目录下新增 client.cc (通讯录 3.0),负责向读出文件中的通讯录消息,内容如下:
#include <iostream>
#include <fstream>
#include "contacts.pb.h"
using namespace std;
using namespace c_contacts;
using namespace google::protobuf
// 打印联系⼈列表
void PrintfContacts(const Contacts& contacts) {
for (int i = 0; i < contacts.contacts_size(); ++i) {
const PeopleInfo& people = contacts.contacts(i);
cout << "------------联系⼈" << i+1 << "------------" << endl;
cout << "联系⼈姓名:" << people.name() << endl;
cout << "联系⼈年龄:" << people.age() << endl;
int j = 1;
for (const PeopleInfo_Phone& phone : people.phone()) {
cout << "联系⼈电话" << j++ << ": " << phone.number() << endl;
}
const Reflection* reflection = PeopleInfo::GetReflection();
const UnknownFieldSet& set = reflection->GetUnknownFields(people);
for (int j = 0; j < set.field_count(); j++) {
const UnknownField& unknown_field = set.field(j);
cout << "未知字段" << j+1 << ": "
<< " 编号:" << unknown_field.number();
switch(unknown_field.type()) {
case UnknownField::Type::TYPE_VARINT:
cout << " 值:" << unknown_field.varint() << endl;
break;
case UnknownField::Type::TYPE_LENGTH_DELIMITED:
cout << " 值:" << unknown_field.length_delimited() << endl;
break;
// case ...
}
}
}
}
int main(){
Contacts contacts;
//先读取已存在的 contacts
fstream input("../contact.bin", ios::in | ios::binary);
if(!contacts.ParseFromIstream(&input)){
cerr << "Failed to parse contacts." << endl;
input.close();
return -1;
}
// 打印 contacts
PrintfContacts(contacts);
input.close();
return 0;
}
2.3.1 验证 "直接删除老字段"问题
我们不能直接已删除一些老字段,如果非要这么做的话,将来在实现我们自己的业务代码的时候,会造成一些意向。
确认无误后,对 service
目录下的contacts.proto
文件进行更新:删除 age
字段,新增birthday
字 段,新增的字段使用被删除字段的字段编号。
问题说明:
在 Protobuf 中,数据映射依赖“字段编号”而非字段名。反序列化时,只要编号一致,值就会被填充到对应编号的字段上。
我们遇到的现象是:移除了“生日(birthdate)”字段后,又复用了它的字段编号给“年龄(age)”,结果旧数据/旧客户端发来的该编号的值在反序列化时被错误地填到了“年龄”字段,造成数据错配。
结论与规范:
移除旧字段时,必须确保其字段编号与名称不再被复用。建议采用以下做法:
用 reserved 保留已废弃的字段编号和名称,防止后续误用。
若需“软删除”,保留字段并标记为 deprecated=true(提醒后续开发者该字段已废弃)。
不建议直接删除或注释字段定义,因为其他开发者可能不了解字段的历史状态,容易误复用编号。
新增字段一律使用全新且从未使用过的编号;切勿更改已发布字段的编号、类型或语义。
在团队与工程层面建立约束:通过 lint/CI/代码审查等手段校验 reserved 与编号复用风险,统一管理 schema 变更。
2.3.2 避免"直接删除老字段"问题
reserve关键字,指定一批的字段编号变为保留下设定为保留项。如果我们要使用这个保留的字段,protobuffer编译器就会报警。
设置多个字段编号 to范围 100 to 200
三、未知字段
在通讯录 3.0 版本中,我们向 service
目录下的contacts.proto
新增了‘生日’字段,但对于client
相关的代码并没有任何改动。验证后发现 新代码序列化的消息(service)也
可以被旧代码(client)
解析。并且这里要说的是,新增的 ‘生日’字段在旧程序(client)
中其实并没有丢失,而是会作为旧程序的未知字段。
未知字段:解析结构良好的
protocol buffer
已序列化数据中的未识别字段的表示方式。例如,当旧程序解析带有新字段的数据时,这些新字段就会成为旧程序的未知字段本来,
proto3
在解析消息时总是会丢弃未知字段,但在3.5
版本中重新引入了对未知字段的保留机 制。所以在3.5
或更高版本中,未知字段在反序列化时会被保留,同时也会包含在序列化的结果中
3.1 未知字段从哪获取
了解相关类关系图
MessageLite 类介绍(了解)
MessageLite
从名字看是轻量级的message
,仅仅提供序列化、反序列化功能类定义在
message_lite.h
中
Message 类介绍(了解)
我们自定义的
message
类,都是继承自Message
Message
最重要的两个接口GetDescriptor/GetReflection
,可以获取该类型对应Descriptor
对象指针和Reflection
对象指针类定义在
message.h
中
//google::protobuf::Message 部分代码展示
const Descriptor* GetDescriptor() const;
const Reflection* GetReflection() const;
Descriptor 类介绍(了解)
Descriptor:是对
message
类型定义的描述,包括message
的名字、所有字段的描述、原始的proto
文件内容等。类定义在
descriptor.h
中
// 部分代码展示
class PROTOBUF_EXPORT Descriptor : private internal::SymbolBase {
string& name () const
int field_count() const;
const FieldDescriptor* field(int index) const;
const FieldDescriptor* FindFieldByNumber(int number) const;
const FieldDescriptor* FindFieldByName(const std::string& name) const;
const FieldDescriptor* FindFieldByLowercaseName(
const std::string& lowercase_name) const;
const FieldDescriptor* FindFieldByCamelcaseName(
const std::string& camelcase_name) const;
int enum_type_count() const;
const EnumDescriptor* enum_type(int index) const;
const EnumDescriptor* FindEnumTypeByName(const std::string& name) const;
const EnumValueDescriptor* FindEnumValueByName(const std::string& name)
const;
}
Reflection 类介绍(了解)
-
Reflection
接口类,主要提供了动态读写消息字段的接口,对消息对象的自动读写主要通过该类完 成。 -
提供方法来动态访问/修改
message
中的字段,对每种类型,Reflection
都提供了一个单独的接口用 于读写字段对应的值。-
针对所有不同的field类型
FieldDescriptor::TYPE_*
,需要使用不同的Get*()/Set*()/Add*()
接口 -
repeated
类型需要使用GetRepeated*()/SetRepeated*()
接口,不可以和非repeated
类型接口混用 -
message
对象只可以被由它自身的reflection(message.GetReflection())
来操作
-
-
类中还包含了访问/修改未知字段的方法。
-
类定义在
google
提供的message.h
中。
// 部分代码展示
class PROTOBUF_EXPORT Reflection final {
const UnknownFieldSet& GetUnknownFields(const Message& message) const;
UnknownFieldSet* MutableUnknownFields(Message* message) const;
bool HasField(const Message& message, const FieldDescriptor* field) const;
int FieldSize(const Message& message, const FieldDescriptor* field) const;
void ClearField(Message* message, const FieldDescriptor* field) const;
bool HasOneof(const Message& message,
const OneofDescriptor* oneof_descriptor) const;
void ClearOneof(Message* message,
const OneofDescriptor* oneof_descriptor) const;
const FieldDescriptor* GetOneofFieldDescriptor(
const Message& message, const OneofDescriptor* oneof_descriptor) const;
// Singular field getters ------------------------------------------
// These get the value of a non-repeated field. They return the default
// value for fields that aren't set.
int32_t GetInt32(const Message& message, const FieldDescriptor* field) const;
int64_t GetInt64(const Message& message, const FieldDescriptor* field) const;
uint32_t GetUInt32(const Message& message,
const FieldDescriptor* field) const;
UnknownFieldSet 类介绍(重要)
-
UnknownFieldSet
包含在分析消息时遇到但未由其类型定义的所有字段。 -
若要将
UnknownFieldSet
附加到任何消息,请调用Reflection::GetUnknownFields()
-
类定义在
unknown_field_set.h
中
UnknownField 类介绍(重要)
-
表示未知字段集中的一个字段。
-
类定义在
unknown_field_set.h
中
3.2 打印未知字段
四、前后兼容性
根据上述的例子可以得出,pb是具有向前兼容的。为了叙述方便,把增加了“生日”属性的service
称为“新模块”;未做变动的 client
称为 “老模块”。
向前兼容:老模块能够正确识别新模块生成或发出的协议。这时新增加的“生日”属性会被当作未 知字段(pb 3.5版本及之后)。
向后兼容:新模块也能够正确识别老模块生成或发出的协议。
前后兼容的作用:当我们维护一个很庞大的分布式系统时,由于你无法同时 升级所有 模块,为了保证在升级过程中,整个系统能够尽可能不受影响,就需要尽量保证通讯协议的“向后兼容”或“向前兼容”。
五、option选项
.proto
文件中可以声明许多选项,使用option
标注。选项能影响 proto
编译器的某些处理方式。
5.1 选项分类
选项的完整列表在google/protobuf/descriptor.proto
中定义。部分代码:
syntax = "proto2"; //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 消息中
...
由此可见,选项分为文件级
、消息级
、字段级
等等, 但并没有一种选项能作用于所有的类型
5.2 optimize_for(文件级选项)
-
作用:控制 protoc 生成代码的优化侧重点(不影响消息的线格式,跨端/跨语言互通不受影响)。
-
取值与场景
-
SPEED(默认)
-
特点:生成的代码运行效率最高
-
代价:体积相对更大
-
适用:大多数服务端/性能优先的应用
-
-
CODE_SIZE
-
特点:最小化生成类数量,体积小;大量依赖反射来做序列化/反序列化
-
代价:运行效率较低
-
适用:.proto 文件很多、二进制体积敏感且不极致追求速度的应用
-
-
LITE_RUNTIME
-
特点:运行效率高且体积小
-
代价:去掉反射能力,仅保留编码与序列化能力
-
适用:资源受限平台(如移动端/嵌入式);C++ 需链接
libprotobuf-lite 而非 libprotobuf
-
-
-
常见误写纠正
-
错误:
option optimize_1 for = LITE_RUNTIME;
-
正确:
option optimize_for = LITE_RUNTIME;
-
5.3 allow_alias(枚举级选项)
-
作用:允许多个枚举常量拥有相同的数值(枚举“别名”)。
-
不开启时(默认),同值会编译报错;开启后可复用值。
-
使用注意:
-
Proto3 中通常要求枚举的第一个值为 0(例如 UNKNOWN = 0)。
-
反序列化时,同一数值会映射到第一个定义的枚举名;请谨慎使用并做好文档说明。
-
5.4 示例(含正确写法)
syntax = "proto3";
package demo;
// 文件级选项:控制生成代码的优化策略
option optimize_for = LITE_RUNTIME;
enum PhoneType {
// 枚举级选项:允许枚举值别名
option allow_alias = true;
MP = 0;
TEL = 1;
LANDLINE = 1; // 与 TEL 共享同一数值(别名)
}
【选型建议速览】
默认选择:SPEED
体积更敏感、.proto 很多:CODE_SIZE
移动端/内存受限、无需反射:LITE_RUNTIME(C++ 记得链接 libprotobuf-lite)
- 点赞
- 收藏
- 关注作者
评论(0)