Protobuf:消息更新

Protobuf:消息更新


在开发中,需要对产品进行版本迭代。迭代前后,类的成员可能就会有所改动,一旦类成员改动,那么老版本的对象,新版本可能就无法解析,此时就会出现问题。为此,Protobuf设计了一套机制,用于对消息进行更新,使其可以向前向后兼容。

更新字段

如果说,消息前后,部分变量的类型发生改变,此时在部分情况下,是可以完成兼容的。

更新前:

cpp 复制代码
message Person {
    int32 id = 1;
    int32 age = 2;
    string name = 3;
}

更新后:

cpp 复制代码
message Person {
    int64 id = 1;
    int64 age = 2;
    string name = 3;
}

这就是一个合法的类型修改。

  1. int32uint32int64uint64bool这几个类型之间相互兼容,可以进行转化。

变长整型家族的类型,都是基于VarInt编码 ,编码方式是一样的,因此可以进行转化。如果从64位整型转到32位整型,此时就会发生截断,与C/C++的整型处理策略一致。

  1. sint32sint64相互兼容

这两个整型使用VarInt编码 + ZigZag编码,因为相比于上面四个类型,引入了ZigZag编码来提高负数的存储效率,所以无法与之前的整型兼容,这两个整型自成一派。

  1. stringbytes在采用UTF-8对字符进行编码的情况下,可以相互兼容

  2. fixed32sfixed32兼容

  3. fixed64sfixed64兼容

这些采用的都是定长编码,所以可以相互兼容。但是不能跨位数进行兼容,32位之间相互兼容,64位之间相互兼容。

  1. 将一个单独的值更改为新 oneof 类型成员之一是安全和二进制兼容的

如果你有一个现有的字段,并决定将其移入一个新的 oneof,这是安全的。因为在 ProtoBuf 的序列化格式中,oneof 的字段与普通字段的序列化格式是相同的。

假设有以下消息定义:

cpp 复制代码
message OriginalMessage {
  string name = 1;
  int32 age = 2;
}

可以安全地将 name 移入一个新的 oneof

cpp 复制代码
message UpdatedMessage {
  oneof identifier {
    string name = 1;
  }
  int32 age = 2;
}
  1. 若确定没有代码一次性设置多个值,那么将多个字段移入一个新 oneof 类型也是可行的

如果确保在所有使用该消息的代码中,多个字段不会同时被设置,那么可以将这些字段一起移入一个新的 oneof,以实现更严格的数据约束。

cpp 复制代码
message OriginalMessage {
  string first_name = 1;
  string last_name = 2;
}

假设确定 first_namelast_name 从未同时被设置,可以重构为:

cpp 复制代码
message UpdatedMessage {
  oneof name {
    string first_name = 1;
    string last_name = 2;
  }
}
  1. 将任何字段移入已存在的 oneof 类型是不安全的

如果将一个字段移入到已经存在的 oneof 中,这可能会导致数据不兼容,因为现在的 oneof 中可能已经存在其他字段,数据的含义会发生改变。

cpp 复制代码
message OriginalMessage {
  oneof contact {
    string email = 1;
    string phone_number = 2;
  }
  string address = 3;
}

如果你尝试将 address 移入现有的 oneof contact 中:

cpp 复制代码
message UnsafeMessage {
  oneof contact {
    string email = 1;
    string phone_number = 2;
    string address = 3;  // 移动到 oneof 中
  }
}

这样做是不安全的,因为现有的二进制数据可能已经使用了 address 字段,而将其移入 oneof 会导致反序列化时数据被误解。


保留字段

有的时候,需要删除消息中的字段,此时就要用到保留字段

因为protobuf中使用字段编号来标识一个数据,如果说直接删除一个字段,那么此时就可能出现数据错误的问题。

cpp 复制代码
message Person {
    int32 id = 1;
    int32 age = 2;
    string name = 3;
}

删除name字段后,又增加了gender字段:

cpp 复制代码
message Person {
    int32 id = 1;
    int32 age = 2;
    string gender = 3;
}

此时就会导致问题,如果旧版本的客户发送了旧版本的消息,此时根据字段编号,gender性别就会收到name姓名的信息,此时就会发生错误。

也就是说:删除字段后,字段编号不能重复使用

为此,protobuf提供了一个reserved关键字,用于保留字段编号与变量名,被保留的字段编号与变量名就不能再被使用。

cpp 复制代码
message Person {
    reserved 3;
    reserved "name";

    int32 id = 1;
    int32 age = 2;
    // string name = 3;
    string gender = 3;
}

此处通过reserved保留了字段编号3,以及变量名"name",那么这两个内容就不能在该message内部使用,string gender = 3会报错。

reserved还支持多种格式:

一次保留多个字段编号:

cpp 复制代码
reserved x, y, z;

一次保留多个变量名:

cpp 复制代码
reserved "aaa", "bbb", "ccc";

保留一个区间内的字段编号:

cpp 复制代码
reserved x to y;

未知字段

如果一个旧版本的客户端,收到一个新版本的消息,会发生什么?

protobuf接收消息时,如果发现消息内含有自己无法识别的字段,会将该字段放到未知字段中。

现有以下消息类型:

cpp 复制代码
syntax = "proto3";
package test_pkg;

message Person {
    int32 id = 1;
    int32 age = 2;
    string name = 3;
}

编译后,生成对应的Person对象,然后把对象序列化至文件test.txt中:

cpp 复制代码
#include <iostream>
#include <fstream>
#include "test.pb.h"

using namespace std;
using namespace test_pkg;

int main()
{
    Person person;
    person.set_id(1);
    person.set_age(18);
    person.set_name("张三");

    ofstream ofs("test.txt", ios_base::binary);

    string str;
    person.SerializeToString(&str);

    ofs << str;
    ofs.close();

    return 0;
}

序列化成功后,可以通过protoc --decode查看序列化结果,命令格式:

cpp 复制代码
protoc --decode=包名.消息名 源文件.proto < 被解析文件

此处 源文件.proto指定消息所处的文件,包名.消息名是要解析的消息类型,被解析文件内部是序列化后的结果。

可以看到,test.txt内的数据,被正确解析出来了。此处name字段存储的是字符串的编码。

随后修改消息类型:

cpp 复制代码
syntax = "proto3";
package test_pkg;

message Person {
    reserved 2, 3;
    int32 id = 1;
}

删掉了agename字段。

再次通过新的消息类型解析test.txt

可以看到,无法再识别agename字段了,但是它依然可以判断数据存储的内容,以及对应的字段编号。

如果客户端得到反序列化后的消息,不会把无法识别的字段丢弃,而是存储到未知字段中。

protobuf的类架构:

注意后续的用于:Message表示上图的类,message表示用户定义的消息

  • Message
    • 所有message继承Message
    • 提供了GetDescriptorGetReflection接口,用于访问ReflectionDescriptor
  • MessaageLite
    • 该类提供序列化与反序列化的接口,Message继承该类
  • Descriptor
    • message的描述,比如message的名字,所包含的字段等
  • Reflection
    • 提供了messagesetget等基本操作接口
    • 提供GetUnknownFields接口,用于访问未知字段
  • UnknownFieldSet:未知字段集
    • 该类包含了所有无法解析的字段
  • UnknownField:未知字段
    • 用于描述一个具体的未知字段

接下讲解如何操作未知字段。

UnknownFieldSet类中,维护了一个集合,内部存储所有的未知字段,在unknown_field_set.h头文件中,包含了该类的声明:

cpp 复制代码
class PROTOBUF_EXPORT UnknownFieldSet {
 public:
  UnknownFieldSet();
  ~UnknownFieldSet();
  inline void Clear();
  inline bool empty() const;
  inline int field_count() const;
  inline const UnknownField& field(int index) const;
};

这些都是很简单的接口,field_count统计未知字段集中有多少个未知字段,field(int index)获取指定下标的未知字段,返回一个UnknownField类型引用。

`UnknownField声明如下:

cpp 复制代码
// Represents one field in an UnknownFieldSet.
class PROTOBUF_EXPORT UnknownField {
 public:
  enum Type {
    TYPE_VARINT,
    TYPE_FIXED32,
    TYPE_FIXED64,
    TYPE_LENGTH_DELIMITED,
    TYPE_GROUP
  };
  
  inline int number() const;
  inline Type type() const;

  // Accessors -------------------------------------------------------
  // Each method works only for UnknownFields of the corresponding type.

  inline uint64_t varint() const;
  inline uint32_t fixed32() const;
  inline uint64_t fixed64() const;
  inline const std::string& length_delimited() const;
  inline const UnknownFieldSet& group() const;

  inline void set_varint(uint64_t value);
  inline void set_fixed32(uint32_t value);
  inline void set_fixed64(uint64_t value);
  inline void set_length_delimited(const std::string& value);
  inline std::string* mutable_length_delimited();
  inline UnknownFieldSet* mutable_group();
};

第一个枚举用于表示这个未知字段的具体类型,其中TYPE_LENGTH_DELIMITED是字符串string

  • number:返回该未知字段的字段编号
  • type:返回该未知字段的类型

当检测到具体类型后,调用下面的接口来获取与设置值,比如varint()就是获取字段的整型值。

接下来写一个代码,解析刚才的test.txt

cpp 复制代码
#include <iostream>
#include <fstream>
#include <google/protobuf/unknown_field_set.h>
#include "test.pb.h"

using namespace std;
using namespace test_pkg;
using namespace google::protobuf;

int main()
{
    ifstream ifs("test.txt", ios_base::binary);
    Person ps;
    ps.ParseFromIstream(&ifs);

    cout << "Id: " << ps.id() << endl;

    const Reflection* ref = Person::GetReflection();
    const UnknownFieldSet& ufs = ref->GetUnknownFields(ps);

    for (int i = 0; i < ufs.field_count(); i++)
    {
        const UnknownField& uf = ufs.field(i);
        cout << "未知字段" << endl;
        cout << "   字段编号: " << uf.number() << endl;
        cout << "   字段类型: " << uf.type() << endl;

        switch (uf.type())
        {
        case UnknownField::Type::TYPE_LENGTH_DELIMITED: // 字符串
            cout << "   string: " << uf.length_delimited() << endl;
            break;
        case UnknownField::Type::TYPE_VARINT:
            cout << "   varint: " << uf.varint() << endl;
        }
    }
    return 0;
}

首先,如果要使用位置字段,先要包含头文件<google/protobuf/unknown_field_set.h>

一开始通过ifstream读取test.txt文件,然后通过文件流反序列化数据到对象ps中。

由于访问未知字段集,要通过Reflection类,所以通过GetReflection构造一个该类。

再通过ref->GetUnknownFields(ps),获取到ps内的未知字段集。

一层for循环,遍历未知字段集的所有元素,并且输出。

输出结果:

可以得知,protobuf确实是吧无法识别的字段放到未知字段集了,并且给出了接口,让用户可以访问未知字段。


option选项

protobuf中,提供了一个option关键字,其用于指定编译器的处理方式。

语法:

cpp 复制代码
option 选项 = 值;
  • optimize_for:文件选项,设置protoc编译器的优化级别
    • SPEED:高度优化代码,运行效率最高,但是会占用更多空间,是默认选项
    • CODE_SIZE:减少空间占用,但是会降低代码都运行效率,如果proto文件比较多,并且对效率要求不高时,建议启用该选项
    • LITE_RUNTIME:生成的代码效率高,空间占用也少,但是只提供序列化与反序列化接口,也就是只继承MessageLite类,不再提供Reflection接口

示例:

cpp 复制代码
syntax = "proto3";
package test_pkg;

option optimize_for = CODE_SIZE;

message Person {
    int32 id = 1;
}

此时该proto文件就会以CODE_SIZE模式进行编译,此时空间占用降低,但是代码效率也会降低。


相关推荐
likangbinlxa2 小时前
【Oracle11g SQL详解】UPDATE 和 DELETE 操作的正确使用
数据库·sql
Ticnix2 小时前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人2 小时前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl3 小时前
OpenClaw 深度技术解析
前端
崔庆才丨静觅3 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人3 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼3 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端
r i c k3 小时前
数据库系统学习笔记
数据库·笔记·学习
布列瑟农的星空3 小时前
前端都能看懂的Rust入门教程(三)——控制流语句
前端·后端·rust
Mr Xu_3 小时前
Vue 3 中计算属性的最佳实践:提升可读性、可维护性与性能
前端·javascript