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;
}
这就是一个合法的类型修改。
int32
、uint32
、int64
、uint64
、bool
这几个类型之间相互兼容,可以进行转化。
变长整型家族的类型,都是基于VarInt
编码 ,编码方式是一样的,因此可以进行转化。如果从64
位整型转到32
位整型,此时就会发生截断,与C/C++
的整型处理策略一致。
sint32
与sint64
相互兼容
这两个整型使用VarInt
编码 + ZigZag
编码,因为相比于上面四个类型,引入了ZigZag
编码来提高负数的存储效率,所以无法与之前的整型兼容,这两个整型自成一派。
-
string
与bytes
在采用UTF-8
对字符进行编码的情况下,可以相互兼容 -
fixed32
与sfixed32
兼容 -
fixed64
与sfixed64
兼容
这些采用的都是定长编码,所以可以相互兼容。但是不能跨位数进行兼容,32
位之间相互兼容,64
位之间相互兼容。
- 将一个单独的值更改为新
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;
}
- 若确定没有代码一次性设置多个值,那么将多个字段移入一个新
oneof
类型也是可行的
如果确保在所有使用该消息的代码中,多个字段不会同时被设置,那么可以将这些字段一起移入一个新的 oneof
,以实现更严格的数据约束。
cpp
message OriginalMessage {
string first_name = 1;
string last_name = 2;
}
假设确定 first_name
和 last_name
从未同时被设置,可以重构为:
cpp
message UpdatedMessage {
oneof name {
string first_name = 1;
string last_name = 2;
}
}
- 将任何字段移入已存在的
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;
}
删掉了age
和name
字段。
再次通过新的消息类型解析test.txt
:
可以看到,无法再识别age
和name
字段了,但是它依然可以判断数据存储的内容,以及对应的字段编号。
如果客户端得到反序列化后的消息,不会把无法识别的字段丢弃,而是存储到未知字段
中。
protobuf
的类架构:
注意后续的用于:Message
表示上图的类,message
表示用户定义的消息
Message
:- 所有
message
继承Message
类 - 提供了
GetDescriptor
和GetReflection
接口,用于访问Reflection
和Descriptor
类
- 所有
MessaageLite
:- 该类提供序列化与反序列化的接口,
Message
继承该类
- 该类提供序列化与反序列化的接口,
Descriptor
:- 对
message
的描述,比如message
的名字,所包含的字段等
- 对
Reflection
:- 提供了
message
的set
、get
等基本操作接口 - 提供
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
模式进行编译,此时空间占用降低,但是代码效率也会降低。