【ProtoBuf】ProtoBuf 进阶实战:默认值、消息更新与兼容性最佳实践

举报
是店小二呀 发表于 2025/09/09 14:38:24 2025/09/09
【摘要】 一、默认值 反序列化消息时,如果被反序列化的⼆进制序列中不包含某个字段,反序列化对象中相应字段时,就会设置为该字段的默认值。不同的类型对应的默认值不同:对于字符串,默认值为空字符串对于字节,默认值为空字节对于布尔值,默认值为 false对于数值类型,默认值为 0对于枚举,默认值是第⼀个定义的枚举值, 必须为 0对于消息字段,未设置该字段。它的取值是依赖于语⾔对于设置了 repeated 的字...


一、默认值 

反序列化消息时,如果被反序列化的⼆进制序列中不包含某个字段,反序列化对象中相应字段时,就会设置为该字段的默认值。不同的类型对应的默认值不同:

  • 对于字符串,默认值为空字符串

  • 对于字节,默认值为空字节

  • 对于布尔值,默认值为 false

  • 对于数值类型,默认值为 0

  • 对于枚举,默认值是第⼀个定义的枚举值, 必须为 0

  • 对于消息字段,未设置该字段。它的取值是依赖于语⾔

  • 对于设置了 repeated 的字段的默认值是空的( 通常是相应语⾔的⼀个空列表 )

  • 对于 消息字段oneof字段 和 any字段 ,C++ 和 Java 语⾔中都有has_⽅法来检测当前字段是否被设置

场景理解:has方法的作用

如果没有has方法,这里我们反序列化得到的数据,我不知道c数值是默认值还是输入导致的。但是这不是一个很严重的问题,可以兼容这个问题。比如没有银行卡和银行卡没钱,都是想表达没钱。


二、更新消息

2.1 更新规则 

如果现有的消息类型已经不再满⾜我们的需求,例如,需要扩展⼀个字段,在不破坏任何现有代码的情况下更新消息类型⾮常简单。遵循如下规则即可:

  • 禁⽌修改任何已有字段的字段编号

  • 若是移除⽼字段,要保证不再使⽤移除字段的字段编号。正确的做法是保留字段编号(reserved),以确保该编号将不能被重复使⽤。不建议直接删除或注释掉字段

  • int32uint32int64uint64bool是完全兼容的。可以从这些类型中的⼀个改为另⼀个,⽽不破坏前后兼容性。若解析出来的数值与相应的类型不匹配,会采⽤与 C++ ⼀致的处理⽅案(例如,若将 64 位整数当做 32 位进⾏读取,它将被截断为 32 位)

  • sint32sint64相互兼容但不与其他的整型兼容

  • stringbytes 在合法UTF-8字节前提下也是兼容的

  • bytes包含消息编码版本的情况下,嵌套消息与 bytes 也是兼容的。

  • fixed32sfixed32兼容,fixed64sfixed64兼容

  • enumint32uint32int64uint64兼容(注意若值不匹配会被截断)。但要注意当反序列化消息时会根据语⾔采⽤不同的处理⽅案:例如,未识别的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 ⽂件。根据结论,可能会出现数据损坏的现象,接下来就让我们来验证下这个结论。

新建两个⽬录:serviceclient。分别存放两个服务的代码。

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,仅仅提供序列化、反序列化功能

  • 类定义在 google 提供的 message_lite.h

Message 类介绍(了解)

  • 我们自定义的message类,都是继承自Message

  • Message最重要的两个接口GetDescriptor/GetReflection,可以获取该类型对应Descriptor对象指针和Reflection对象指针

  • 类定义在 google 提供的 message.h

//google::protobuf::Message 部分代码展示
const Descriptor* GetDescriptor() const;
const Reflection* GetReflection() const;

Descriptor 类介绍(了解)

  • Descriptor:是对message类型定义的描述,包括message的名字、所有字段的描述、原始的 proto文件内容等。

  • 类定义在 google 提供的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 生成代码的优化侧重点(不影响消息的线格式,跨端/跨语言互通不受影响)。

  • 取值与场景

    1. SPEED(默认)

      • 特点:生成的代码运行效率最高

      • 代价:体积相对更大

      • 适用:大多数服务端/性能优先的应用

    2. CODE_SIZE

      • 特点:最小化生成类数量,体积小;大量依赖反射来做序列化/反序列化

      • 代价:运行效率较低

      • 适用:.proto 文件很多、二进制体积敏感且不极致追求速度的应用

    3. 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)

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。