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模式进行编译,此时空间占用降低,但是代码效率也会降低。


相关推荐
轻口味28 分钟前
命名空间与模块化概述
开发语言·前端·javascript
前端小小王1 小时前
React Hooks
前端·javascript·react.js
迷途小码农零零发1 小时前
react中使用ResizeObserver来观察元素的size变化
前端·javascript·react.js
了一li2 小时前
Qt中的QProcess与Boost.Interprocess:实现多进程编程
服务器·数据库·qt
娃哈哈哈哈呀2 小时前
vue中的css深度选择器v-deep 配合!important
前端·css·vue.js
码农君莫笑2 小时前
信管通低代码信息管理系统应用平台
linux·数据库·windows·低代码·c#·.net·visual studio
旭东怪2 小时前
EasyPoi 使用$fe:模板语法生成Word动态行
java·前端·word
别致的影分身2 小时前
使用C语言连接MySQL
数据库·mysql
ekskef_sef4 小时前
32岁前端干了8年,是继续做前端开发,还是转其它工作
前端