【Protobuf】Protobuf进阶

发布于:2024-08-22 ⋅ 阅读:(156) ⋅ 点赞:(0)

一、默认值

前面我们说过:对于proto3的语法来说message中的字段默认是用singular来修饰的,被singular修饰的字段在序列化的时候如果没有被设置值,那么protobuf的序列化方法是不会将该字段进行编码的;在反序列化的时候,如果在反序列化序列中没有找到message中某一字段的值,那么protobuf会用该字段的默认值来填充该字段;

不同的类型对应的默认值不同,下面这张表就列举了不同类型的默认值:

以下是将您提供的信息转化为Markdown表格的示例:

字段类型 默认值描述
string 空字符串
bytes 空字节
bool false
数值类型(如 int32, uint32, sint32, fixed32 等) 0
枚举 第一个定义的枚举值,必须为 0
消息字段 未设置时,取值依赖于语言
repeated 字段 空的(通常是相应语言的一个空列表)
消息字段、oneof 字段和 any 字段 (C++ 和 Java) 有 has_ 方法来检测字段是否被设置

二、更新消息

我们在的序列化和反序列化时,除了考虑效率问题,有时还会考虑:字段的更新问题,如果现有的消息类型已经不再满足我们的需求,例如需要扩展一个字段,如何在不破坏任何现有代码的情况下更新消息类型呢?

1、规则介绍

protobuf中为了实现消息的更新,我们要遵循如下规则:

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

protobuf在编码字段的时候实际上是将字段值、字段类型、字段编号一起进行编码的,而在反序列化的时候就是根据反序列化中的字段编号将字段值反序列化到对应字段,如果我们更改了已存在的字段的编号,那么很有可能造成数据错位或者数据丢失;

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

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

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

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

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

  • fixed32sfixed32 兼容, fixed64sfixed64兼容。

  • enumint32,uint32, int64uint64 兼容(注意若值不匹配会被截断)。但要注意当反序列化消息时会根据语言采用不同的处理方案:例如,未识别的 proto3 枚举类型会被保存在消息中,但是当消息反序列化时如何表示是依赖于编程语言的。整型字段总是会保持其的值。

  • oneof

    • 将一个单独的值更改为 新 oneof 类型成员之一是安全和二进制兼容的。
      在这里插入图片描述

    • 若确定没有代码一次性设置多个值那么将多个字段移入一个新 oneof 类型也是可行的。
      在这里插入图片描述

    • 将任何字段移入已存在的 oneof 类型是不安全的。

2、实例代码

  • server.proto 代码
    在这里插入图片描述

  • client.proto代码
    在这里插入图片描述

这里两者是一样的!

  • read.cpp 反序列化代码
#include <iostream>
#include <fstream>
#include <filesystem>
#include "client.pb.h"

using namespace std;
namespace fs = std::filesystem;


void showContact(contact::Contacts& mycontact)
{
	int sz = mycontact.peopleinfo_size();
	for (int i = 0; i < sz; ++i)
	{
		cout << "---------------联系人" << i + 1 << "-----------------\n";
		const ::contact::PeopleInfo& peopleinfo = mycontact.peopleinfo(i);
		cout << "\t姓名 : " << peopleinfo.name() << endl;
		cout << "\t年龄 : " << peopleinfo.age() << endl;
	}
}

int main()
{
	GOOGLE_PROTOBUF_VERIFY_VERSION;
	fs::path file_path("contacts.data");
	if (!fs::exists(file_path))
	{
		cerr << "文件不存在" << endl;
		return 1;
	}
	ifstream ifs(file_path, ios::in | ios::binary);
	if (!ifs.is_open())
	{
		perror("文件打开失败");
		return 1;
	}

	// 反序列化
	contact::Contacts mycontact;
	if (!mycontact.ParseFromIstream(&ifs))
	{
		cerr << "解析失败" << endl;
		return 1;
	}

	showContact(mycontact);
	google::protobuf::ShutdownProtobufLibrary();
	cout << "程序退出" << endl;
	return 0;
}
  • write.cpp 序列化代码
#include <iostream>
#include <fstream>
#include <filesystem>
#include "server.pb.h"

using namespace std;
namespace fs = std::filesystem;


void addPeopleInfo(contact::Contacts& mycontact)
{
	contact::PeopleInfo* people = mycontact.add_peopleinfo();

	cout << "-------------新增联系人-------------" << endl;
	// 添加姓名
	string name;
	cout << "请输入联系人姓名:";
	getline(cin, name);
	people->set_name(name);

	// 添加年龄
	int age;
	cout << "请输入联系人年龄:";
	cin >> age;
	people->set_age(age);
	// 清除age 输入以后的\n
	cin.ignore();
	cout << "\n-----------添加联系人成功-----------" << endl;
}

int main()
{
	GOOGLE_PROTOBUF_VERIFY_VERSION;

	fs::path file_path("contacts.data");
	ofstream ofs;
	if (!fs::exists(file_path))
	{
		// 文件不存在创建文件
		ofs.open(file_path, ios::out | ios::binary);
	}
	else
	{
		// 文件存在打开文件
		ofs.open(file_path, ios::app | ios::binary);
	}

	if (!ofs.is_open())
	{
		perror("文件打开失败!");
		return 1;
	}

	//  到这里文件肯定是已经存在的并且可以正常打开

	// 创建通讯录
	contact::Contacts mycontact;
	ifstream ifs(file_path, ios::in | ios::binary);

	// 文件不为空文件
	if (ifs.peek() != EOF)
	{
		// 读取文件
		if (!mycontact.ParseFromIstream(&ifs))
		{
			cerr << "反序列化失败" << endl;
			return 1;
		}
		// 清空文件
		ofstream ofstmp("contacts.data", ios::trunc);
		ofstmp.close();
	}

	// 添加新的联系人
	addPeopleInfo(mycontact);
	// 将通讯录中的数据持久化到文件中
	mycontact.SerializeToOstream(&ofs);

	ofs.close();
	google::protobuf::ShutdownProtobufLibrary();
	cout << "程序退出" << endl;
	return 0;
}

编译我们的代码以后,运行程序:

在这里插入图片描述

没有问题!

  • 服务端更新消息,客户端不更新消息
    接下来,我们修改server.proto 文件新增字段int64 birthday= 2; 并且注释原来的年龄字段,同时修改write.cpp 中的代码。

    • server.proto在这里插入图片描述
    • write.cpp 文件
void addPeopleInfo(contact::Contacts& mycontact)
{
	contact::PeopleInfo* people = mycontact.add_peopleinfo();

	cout << "-------------新增联系人-------------" << endl;
	// 添加姓名
	string name;
	cout << "请输入联系人姓名:";
	getline(cin, name);
	people->set_name(name);

	// 添加年龄
	// int age;
	// cout << "请输入联系人年龄:";
	// cin >> age;
	// people->set_age(age);

	int64_t birthday;
	cout << "请输入联系人生日 : ";
	cin >> birthday;
	people->set_birthday(birthday);

	// 清除输入以后的\n
	cin.ignore();
	cout << "\n-----------添加联系人成功-----------" << endl;
}

重新编译,运行程序:

在这里插入图片描述

可以看到数据还是正常解析了,这证明了我们前面说的兼容规制。


同时这里也有一个一个新问题,就是数据的认知不一致的问题,我么期望将这个数据解释为生日,而客户端却将这个数据解释为了年龄这个问题,我们需要去解决。

出现这个问题的原因是因为:我们设置的birthday与废弃的age使用了同一个编号。

在服务端,我们可以将birthday 这个字段设置为3,但是这个已经存在的2编号,我们也不能给后续的字段使用,否则还是会出现数据不一致的问题。

所有我们需要保留字段,设置这个字段不能再被使用!

三、保留字段

如果通过 删除注释掉字段来更新消息类型,未来的用户在添加新字段时,有可能会使用以前已经存在,但已经被删除或注释掉的字段编号。将来使用该 .proto 的旧版本时的程序会引发很多问题:数据损坏、隐私错误等等。

确保不会发生这种情况的一种方法是:使用 reserved 将指定字段的编号或名称设置为保留项 。当我们再使用这些编号或名称时,protocol buffer 的编译器将会警告这些编号或名称不可用。

举个例子,对于上面出现的问题我们使用reversed进行解决 :

syntax = "proto3";
package contact;

// 联系人信息
message PeopleInfo
{
	// 表示 2号编号不可用
	reserved 2;
	// 姓名
	string name = 1;
	// 年龄
	// int32 age = 2;
	int64 birthday = 2;
}

// 通讯录信息
message Contacts
{
	repeated PeopleInfo peopleinfo = 1;
}

同时,我们也可以使用来指定多个保留的编号,使用to 关键字来设置一批编号不可以使用。

reserved 2, 100, 300 to 400;

编译.proto文件
在这里插入图片描述

可以看到protoc出现了错误提示!

我们将编号设置为3, int64 birthday = 3; 再次进行编译,运行我们的程序:

在这里插入图片描述

可以看到数据正常识别了。

  • client中由于找不到年龄对应的值,使用了默认值进行反序列化,这是正常的
  • 对于字段编号为3birthday 由于客户端无法识别,没有显示,这也是正常的,总之这次没有再将birthday 的值解析为age的值了。

我们知道客户端解析数据时一定会尝试解析birthday进行反序列化,但是由于客户端的proto文件中没有更新,没有办法识别这个数据,那这个birthday数据客户端是怎么处理的呢?是直接抛弃吗?还是说保留下来,如果保留下来,哪有保留在哪里了呢?

这就和未知字段有关了,我们接下来开始学习未知字段!

四、未知字段

未知字段: 当旧protobuf程序解析带有新字段的数据时,这些新字段就会成为旧程序的未知字段。

本来,proto3 在解析消息时总是会丢弃未知字段,但在 3.5 版本中重新引入了对未知字段的保留机制。所以在 3.5 或更高版本中,未知字段在反序列化时会被保留在反序列化的对象中,同时也会包含在序列化的结果中。

在这里插入图片描述

可以使用下面的命令查看protrobuf的版本:

protoc --version

在这里插入图片描述

1、 如何获取未知字段

获取未知字段的流程很复杂,我们需要先了解一下protobuf内部的结构 :

在这里插入图片描述

看到这里你可能会觉得很复杂,让我们慢慢来了解这些类的关系

  • MessageLite 类

    • MessageLite 从名字看是轻量级的 message,仅仅提供序列化、反序列化功能。
    • 类定义在 google 提供的 message_lite.h 中。
  • Message 类

    • 前面我们已经说过我们自定义的message类,都是继承自Message
    • 类定义在 google 提供的 message.h 中。
    • Message 最重要的两个接口 GetDescriptor/GetReflection,可以获取该类型对应的Descriptor对象指针 和 Reflection 对象指针。
// 常用方法

const Descriptor* GetDescriptor() const;
const Reflection* GetReflection() const;
  • Descriptor 类

    • Descriptor:是对message类型定义的描述,包括message的名字、所有字段的描述、原始的proto文件内容等。
    • 类定义在 google 提供的 descriptor.h 中。
  • Reflection 类

    • Reflection接口类,主要提供了动态读写消息字段的接口,对消息对象的自动读写主要通过该类完成。
    • 提供方法来动态访问/修改message中的字段,对每种类型,Reflection都提供了一个单独的接口用于读写字段对应的值。
    • 针对所有不同的field类型FieldDescriptor::TYPE_* ,需要使用不同的Get*()/Set*()/Add*() 接进行获取或者修改;
    • repeated类型需要使用 GetRepeated*()/SetRepeated*() 接口,不可以和非repeated类型接口混用;
    • message对象只可以被由它自身的reflectionmessage.GetReflection()得到) 来操作;
    • 通过这个反射类中我们可以访问/修改未知字段。
    • 类定义在 google 提供的 message.h 中。
// 常用方法

// 获得反射对象中的未知字段集合
const UnknownFieldSet& GetUnknownFields(const Message& message) const;
  • UnknownFieldSet 类
    • UnknownFieldSet 包含在分析消息时遇到但未由其类型定义的所有字段。
    • 若要将 UnknownFieldSet 附加到任何消息,请调用Reflection::GetUnknownFields()
    • 类定义在 unknown_field_set.h 中。
// 常用的方法

// 获得未知字段的个数
int UnknownFieldSet::field_count() const

// 根据下标获取未知字段的值
const UnknownField& UnknownFieldSet::field(int index) const
  • UnknownField 类介绍
    • 表示未知字段集中的一个字段。
    • 类定义在 unknown_field_set.h 中。
// 常用的方法

// 获取未知字段的类型
UnknownField::Type UnknownField::type() const 

// 以整形的方式进行提取
uint64_t UnknownField::varint() const

// 以定长32类型来提取
uint32_t UnknownField::fixed32() const

// 以定长64类型来提取
uint64_t UnknownField::fixed64() const

// 以字符串类型进行提取
const std::string& UnknownField::length_delimited() const

// 以未知类型类进行继续提取
const UnknownFieldSet& UnknownField::group() const 
  • UnknownField::Type 枚举
  enum Type {
    TYPE_VARINT,						// 表示的是变长整形
    TYPE_FIXED32,						// 表示是定长32类型,float类型
    TYPE_FIXED64,						// 表示定长64类型, double类型
    TYPE_LENGTH_DELIMITED,				// 表示字符串类型
    TYPE_GROUP							// 表示是一个未知字段的集合,需要进一步解析
  };

2、提取未知字段

根据上面类的关系我们来提取未知字段的值:

  1. 使用GetReflection() 得到反射对象
  2. 使用GetUnknownFields 得到未知对象集合
  3. 使用unknownFieldSet.field_count()得到未知字段的个数
  4. 在循环中,通过unknownFieldSet.field(i) 以此得到每一个未知字段
  5. 使用unknownField.type() 确定每一个字段的类型
  6. 根据不同的类型,使用不同的方法进行提取。
  • read.cpp 反序列化代码
void showContact(contact::Contacts& mycontact)
{
	using namespace google::protobuf;
	int sz = mycontact.peopleinfo_size();
	for (int i = 0; i < sz; ++i)
	{
		cout << "---------------联系人" << i + 1 << "-----------------\n";
		const ::contact::PeopleInfo& peopleinfo = mycontact.peopleinfo(i);
		cout << "\t姓名 : " << peopleinfo.name() << endl;
		cout << "\t年龄 : " << peopleinfo.age() << endl;

		// 取得reflection 对象 
		const Reflection* reflection = contact::PeopleInfo::GetReflection();
		const UnknownFieldSet& unknownFieldSet = reflection->GetUnknownFields(peopleinfo);
		// 提取未知字段
		for (int i = 0; i < unknownFieldSet.field_count(); ++i)
		{
			const UnknownField& unknownField = unknownFieldSet.field(i);
			switch (unknownField.type())
			{
			case UnknownField::Type::TYPE_VARINT:
				cout << "未知字段: " << i + 1 <<" : " << unknownField.varint() << endl;;
				break;
			case UnknownField::Type::TYPE_FIXED32:
				cout << "未知字段: " << i + 1 <<" : " << unknownField.fixed32() << endl;
				break;
			case UnknownField::Type::TYPE_FIXED64:
				cout << "未知字段: " << i + 1 <<" : " << unknownField.fixed64() << endl;
				break;
			case UnknownField::Type::TYPE_LENGTH_DELIMITED:
				cout << "未知字段: " << i + 1 <<" : " << unknownField.length_delimited() << endl;
				break;
			case UnknownField::Type::TYPE_GROUP:
				// 递归解析
				break;
			default:
				break;
			}
		}
	}
}

结果:
在这里插入图片描述

可以看到我们客户端得到了未知字段的值

3、意义

根据上述的例子可以得出,pb是具有向前兼容的。为了叙述方便,把增加了“生日”属性的 server称为“新模块”;未做变动的 client 称为 “老模块”。

  • 向前兼容:老模块能够正确识别新模块生成或发出的协议。这时新增加的“生日”属性会被当作未知字段(pb 3.5版本及之后)。
  • 向后兼容:新模块也能够正确识别老模块生成或发出的协议。

前后兼容的作用:当我们维护一个很庞大的分布式系统时,由于你无法同时 升级所有 模块,为了保证在升级过程中,整个系统能够尽可能不受影响,就需要尽量保证通讯协议的“向后兼容”或“向前兼容”。

五、option选项

1、 选项介绍

.proto 文件中可以声明许多选项,使用 option 标注。选项能影响 proto 编译器的某些处理方式。

举个例子:

看下面的proto文件

在这里插入图片描述

编译之后我们打开生成的头文件进行查看:

在这里插入图片描述

可以看到我们自定义的消息的父类变成了MessageLite 类,这就是option选项的作用。

2、 选项分类

选项的完整列表在google/protobuf/descriptor.proto中定义。

ls /usr/local/protobuf/include/google/protobuf/descriptor.proto 

部分代码:

在这里插入图片描述

可以看出 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 消息中

可以看出,这些选项分为文件级消息级字段级*等等, 但并没有一种选项能作用于所有的类型。

3、常用选项列举

  • optimize_for : 该选项为文件选项,可以设置 protoc 编译器的优化级别,分别为 SPEED CODE_SIZELITE_RUNTIME 。受该选项影响,设置不同的优化级别,编译 .proto 文件后生成的代码内容不同。

    • SPEED : protoc 编译器将生成的代码是高度优化的,代码运行效率高,但是由此生成的代码编译后会占用更多的空间。SPEED 是默认选项。

    • CODE_SIZE : proto 编译器将生成最少的类,会占用更少的空间,这种方式是不会包含protobuf 的反射库,但和SPEED 恰恰相反,它的代码运行效率较低。这种方式适合用在包含大量的.proto文件,但并不盲目追求速度的应用中。

    • LITE_RUNTIME : 生成的代码执行效率高,同时生成代码编译后的所占用的空间也是非常少。这是以牺牲Protocol Buffer提供的反射功能为代价的,仅仅提供 encoding + 序列化 功能,所以我们在链接 PB 库时仅需链接libprotobuf-lite,而非libprotobuf。这种模式通常用于资源有限的平台,例如移动手机平台中。

  • allow_alias : 允许将相同的常量值分配给不同的枚举常量,用来定义别名。该选项为枚举选项。

举个例子:

在这里插入图片描述

4、设置自定义选项

Protobuf 允许自定义选项并使用。该功能大部分场景用不到,有兴趣可以参考:https://developers.google.cn/protocol-buffers/docs/proto?hl=zhcn#customoptions


网站公告

今日签到

点亮在社区的每一天
去签到