目录
-
- 前言
- 一、枚举类型(Enum)的定义与命名空间管理
-
- [1.1 枚举冲突与命名空间隔离](#1.1 枚举冲突与命名空间隔离)
- [1.2 通讯录中的枚举实践](#1.2 通讯录中的枚举实践)
- [1.3 写入与读取枚举类型](#1.3 写入与读取枚举类型)
- [二、Any 类型的应用机制](#二、Any 类型的应用机制)
-
- [2.1 引入 Any 类型与定义地址信息](#2.1 引入 Any 类型与定义地址信息)
- [2.2 Any 类型数据的封装(Pack)与解包(Unpack)](#2.2 Any 类型数据的封装(Pack)与解包(Unpack))
- [三、Oneof 类型的排他性字段](#三、Oneof 类型的排他性字段)
-
- [3.1 定义 Oneof 字段](#3.1 定义 Oneof 字段)
- [3.2 Oneof 字段的设置与读取](#3.2 Oneof 字段的设置与读取)
- [四、Map 类型的键值对存储](#四、Map 类型的键值对存储)
-
- [4.1 定义 Map 字段](#4.1 定义 Map 字段)
- [4.2 Map 数据的插入与遍历](#4.2 Map 数据的插入与遍历)
前言
在构建高效、可扩展的数据序列化系统时,Protocol Buffers 提供了丰富的数据类型以应对复杂的业务需求。除了基础的整型和字符串类型外,掌握枚举、泛型、联合体及哈希映射的使用对于设计健壮的通信协议至关重要。本文将通过构建一个功能完善的通讯录系统,逐步引入并解析这些高级特性的实现细节。
一、枚举类型(Enum)的定义与命名空间管理
在定义协议文件时,枚举类型用于表示一组预定义的常量。然而,在大型项目中,不同模块可能会定义同名的枚举常量,导致 C++ 编译层面的符号冲突。
1.1 枚举冲突与命名空间隔离
首先创建一个名为 test_enum.proto 的文件进行测试。在此阶段,若未指定包名(Package),直接定义枚举可能会引发全局命名空间的污染。
cpp
使用 Protocol Buffers 编译器对该文件进行编译:
bash
protoc --cpp_out=. test_enum.proto
在 Protobuf 的语义中,如果在同一个 .proto 文件内定义的两个 message 中包含完全相同的枚举元素名称,或者在不同的 .proto 文件中定义了重复的枚举常量(例如均为 MP),编译器会抛出重定义错误。这是因为生成的 C++ 代码会将枚举常量映射到外层作用域,导致符号冲突。
下图展示了当两个 message 中存在相同元素时出现的编译报错信息,提示符号被重复定义:

此外,若在另一个独立的 proto 文件中也定义了相同的枚举常量 MP,当当前文件使用 import 引入该文件时,同样会触发跨文件的符号冲突,导致编译失败。下图展示了引入外部文件导致重复定义的错误场景:

解决此类冲突的标准方案是使用 package 关键字声明包名。包名在生成的 C++ 代码中会被映射为命名空间(namespace),从而实现符号隔离。
bash
package test_enum;
通过指定 package test_enum;,生成的枚举类型将被封装在 test_enum 命名空间下,有效避免了全局冲突。下图展示了添加 package 声明后,编译顺利通过的结果:

1.2 通讯录中的枚举实践
在 contacts.proto 文件中,通过引入枚举类型来标识电话号码的类别(移动电话或固定电话)。同时,该文件通过 import 和 package 指令构建了完整的协议结构。
cpp
syntax="proto3";
//首行:语法指定行
package contacts2;//命令空间
//联系人message的定义
message PeopleInfo//定义一个联系人的message
{
//定义一个message的变量一定要加上编号
//姓名
string name=1;
//年龄
int32 age=2;
message Phone
{ //电话号码的类
string number=1;
enum PhoneType//枚举类型
{
//移动电话类型
MP=0;
//固定电话类型
TEL=1;
}
PhoneType type=2;
}
//repeated string phone_numbers=3;//repeated这个关键字可以达到修饰数组的操作
repeated Phone phone=3;//电话信息
}
//定义一个通讯录的message
message Contacts2
{
repeated PeopleInfo contacts=1;//类型是PeopleInfo
}
执行编译命令生成对应的 C++ 源文件与头文件:
bash
protoc --cpp_out=. contacts.proto
编译操作顺利完成,生成了 contacts.pb.h 和 contacts.pb.cc 文件。

打开生成的 .h 头文件,可以观察到 PhoneType 枚举类型的定义及其相关的辅助函数。Protobuf 自动生成了枚举值到字符串名称的映射方法(如 PhoneType_Name)以及有效性检查方法(如 PhoneType_IsValid)。下图展示了头文件中生成的枚举类型相关代码结构:

由于在 Phone 消息中新增了 PhoneType type=2; 字段,生成的类中也相应增加了 type()(获取值)和 set_type()(设置值)等成员函数。
1.3 写入与读取枚举类型
在数据写入阶段(write.cc),通过 AddPeopleInfo 函数实现联系人信息的录入。对于枚举类型,通常结合 switch 语句将用户的输入(整数)映射为具体的枚举常量。
cpp
void AddPeopleInfo(contacts2::PeopleInfo*people)
{
cout<<"--------------新增联系人--------------";
cout<<"请输入联系人姓名";
string name;
getline(cin,name);
people->set_name(name);
cout<<"请输入联系人年龄";
int age;
cin>>age;
people->set_age(age);
cin.ignore(256,'\n');//清空输入缓冲区的内容,
for(int i=0;;i++)//死循环
{
cout<<"请输入联系人电话"<<i+1<<"(只输入回车完成电话新增):";
string number;
getline(cin,number);
if(number.empty())
{
break;
}
contacts2::PeopleInfo_Phone * phone=people->add_phone();//给这个数组新增一个电话信息
phone->set_number(number);
cout<<"请输入该电话类型(1、移动电话 2、固定电话)";
int type;
cin>>type;
cin.ignore(256,'\n');//忽略回车
switch (type)
{
case 1:
phone->set_type(contacts2::PeopleInfo_Phone_PhoneType::PeopleInfo_Phone_PhoneType_MP);
break;
case 2:
phone->set_type(contacts2::PeopleInfo_Phone_PhoneType::PeopleInfo_Phone_PhoneType_TEL);
break;
default:
cout<<"选择有误"<<endl;
break;
}
}
cout<<"------------添加联系人成功------------";
}
在读取阶段(read.cc),为了直观显示枚举值的含义,利用 Protobuf 提供的反射机制或辅助函数 PhoneType_Name 将枚举的整数值转换为对应的字符串标识(如 "MP" 或 "TEL")。
cpp
void PrintContacts( contacts2::Contacts2&contacts)
{
for(int i=0;i<contacts.contacts_size();i++)
{
cout<<"-------------联系人"<<i+1<<"-------------"<<endl;
//取出对应下标的元素
const ::contacts2::PeopleInfo& people=contacts.contacts(i);//获取到这个联系人
cout<<"联系人的姓名"<<people.name()<<endl;
cout<<"联系人的年龄"<<people.age()<<endl;
for(int j=0;j<people.phone_size();j++)
{
const contacts2::PeopleInfo_Phone& phone= people.phone(j);
cout<<"联系人的电话信息"<<j+1<<":"<<phone.number();
cout<<" ("<<phone.PhoneType_Name(phone.type())<<")"<<endl;//phone.PhoneType_Name可以打印出声明的枚举常量的名称
}
}
}
执行 make 编译并运行程序。首先进行联系人新增操作,输入姓名、年龄及电话类型。下图展示了程序运行时的交互界面,用户成功添加了联系人信息:

随后执行读取程序 ./read。可以看到输出结果中,电话类型被正确解析并打印为字符串 "TEL"。需要注意的是,对于未显式设置类型的记录,Protobuf 会使用默认值(枚举的第一个元素,即数值 0),因此序号为 0 的枚举常量(此处为 MP)即为默认类型。下图展示了读取到的通讯录内容,包含了解析后的电话类型:

二、Any 类型的应用机制
Any 类型是 Protobuf 提供的泛型解决方案,允许在 message 字段中嵌入任意类型的其他 message,而无需在定义阶段确定具体的类型。这在处理多态数据或动态内容时极为有用。
2.1 引入 Any 类型与定义地址信息
要使用 Any 类型,必须在 .proto 文件中导入定义文件 google/protobuf/any.proto。Any 类型本质上是一个包含了序列化数据和类型 URL 的 message。
在 contacts.proto 中添加 Any 类型的字段 data,用于存储扩展信息:
bash
import "google/protobuf/any.proto";
cpp
google.protobuf.Any data=4;
下图展示了在 proto 文件中引入 Any 定义及添加 data 字段后的文件结构:

为了演示 Any 的存储能力,定义一个新的 Address message,包含家庭地址和单位地址:
cpp
message Address
{
string home_address=1;//家庭地址
string unit_address=2;//单位地址
}
下图展示了 Address 消息的定义:

再次编译 .proto 文件:
bash
protoc --cpp_out=. contacts.proto
编译完成后,查看生成的头文件,可以确认 Address 类及其相关的方法已经生成。

2.2 Any 类型数据的封装(Pack)与解包(Unpack)
在 C++ 代码中操作 Any 字段时,不能直接赋值,而是需要使用 PackFrom 方法将具体的 message 对象序列化并存入 Any 字段,以及使用 UnpackTo 方法将数据还原。
在 write.cc 的 AddPeopleInfo 函数中,首先创建并填充 Address 对象,随后将其封装进 people 消息的 data 字段。
cpp
//定义一个地址对象
contacts2::Address address;
cout<<"输入联系人家庭地址";
string home_address;
getline(cin,home_address);
address->set_home_address(home_address);
cout<<"输入联系人单位地址";
string unit_address;
getline(cin,unit_address);
address->set_unit_address(unit_address);
//将address对象转换为any
people->mutable_data()->PackFrom(address);//帮我们开辟一块空间,其实就是any的那个空间,我们就拿到了这个any对象了,然后对any对象中的PackFrom方法进行调用,就能将我们的address对象转换为any对象并且存储到data中,这个方法成功返回true,失败返回false
mutable_data() 方法返回指向 Any 字段的指针,调用 PackFrom(address) 会自动处理序列化,并设置正确的 Type URL,以便后续识别。下图展示了写入代码的逻辑片段:

在读取逻辑 read.cc 中,解析 Any 数据前需进行类型检查。使用 Is<T>() 方法判断 data 字段中存储的是否为预期类型,确认为真后,使用 UnpackTo 提取数据。
cpp
if(people.has_data()&&people.data().Is<contacts2::Address>())//查看是否为data字段设置内容,是不是Address类型的
{
contacts2::Address address;
people.data().UnpackTo(&address);//UnpackTo是any中的方法,将我们people中的data数据转换为Addree类型的对象,转换的值就放到address中
//然后现在我们就拿到了我们address中的地址了
if(!address.home_address().empty())//如果家庭地址为空的话
{
cout<<"联系人家庭地址"<<address.home_address()<<endl;
}
if(!address.unit_address().empty())//如果单位地址为空的话
{
cout<<"联系人单位地址"<<address.unit_address()<<endl;
}
}
}
}
这段代码确保了只有当 Any 字段确实包含 Address 类型数据时才进行反序列化,保证了程序的安全性。下图为读取代码的更新内容:

清理旧的构建并重新编译:
bash
make clean
make
运行程序新增联系人,此时会提示输入家庭和单位地址。下图展示了新增联系人时包含地址信息的交互过程:

最后运行 ./read 读取通讯录,可以看到程序成功解析了 Any 字段中的地址信息并打印出来。

三、Oneof 类型的排他性字段
在某些业务场景下,多个字段中同时只能有一个生效。例如,联系人可能通过 QQ 或微信联系,但记录中仅保留其中一种。oneof 关键字提供了这种"联合体"特性,能够节省存储空间并强制字段的互斥性。
3.1 定义 Oneof 字段
在 contacts.proto 中,新增 other_contact 组,包含 qq 和 wechat 两个字段。
cpp
oneof other_contact
{
string qq =5;
string wechat=6;
}
这种定义意味着 qq 和 wechat 共享内存空间(逻辑上),设置其中一个会自动清除另一个。下图展示了 proto 文件中 oneof 字段的定义:

需要特别注意的是,oneof 内部的字段不能使用 repeated 修饰符。编译该文件:
bash
protoc --cpp_out=. contacts.proto
生成的 C++ 代码中包含用于查询当前设置了哪个字段的方法,如 has_qq()、has_wechat() 以及获取当前字段类型的 other_contact_case() 方法。下图展示了头文件中生成的 oneof 相关方法:

3.2 Oneof 字段的设置与读取
在 write.cc 中,根据用户的选择设置 QQ 或微信。由于 oneof 的特性,无需手动清除旧值,最后一次 set 操作将决定最终保留的数据。
cpp
cout<<"请选择要添加的其他联系方式(1、QQ 2、wechat):";
int other_contact;
cin>>other_contact;
cin.ignore(256,'\n');//忽略换行草操作
if(1==other_contact)//如果选择的是1的话
{
cout<<"请输入联系人QQ号码";
string qq;
getline(cin,qq);
people->set_qq(qq);
}
else if(2==other_contact)
{
cout<<"请输入联系人wechat号码";
string wechat;
getline(cin,wechat);
people->set_wechat(wechat);
}
else
{
cout<<"选择有误,未成功设置其他联系方式";
}
下图展示了写入 oneof 字段的代码逻辑:

在 read.cc 中,使用 other_contact_case() 方法判断当前存储的是哪种数据,并据此进行相应的输出。该方法返回一个枚举值,对应 proto 中定义的字段。
cpp
switch(people.other_contact_case())//返回当时设置的到底是QQ还是wechat
{
case contacts2::PeopleInfo::OtherContactCase::kQq:
cout<<"联系人qq:"<<people.qq()<<endl;
break;
case contacts2::PeopleInfo::OtherContactCase::kWechat:
cout<<"联系人wechat:"<<people.wechat()<<endl;
break;
default:
break;
}
重新编译并运行,测试 Oneof 字段的写入与读取。下图展示了程序正确处理了 QQ 或微信的选择逻辑,并在读取时准确显示了结果:

四、Map 类型的键值对存储
Map 类型用于存储键值对数据,非常适合表示属性、备注或配置信息。Protobuf 中的 map 类型是无序的,且不能使用 repeated 修饰。
4.1 定义 Map 字段
在 contacts.proto 中新增 remark 字段,类型为 map<string, string>,用于存储联系人的备注信息。
cpp
map<string,string>remark=7;//备注信息
下图展示了 map 类型字段的定义:

执行编译命令:
bash
protoc --cpp_out=. contacts.proto
查看生成的头文件,Protobuf 为 map 字段生成了类似 C++ STL map 的接口,包括 mutable_remark() 用于获取可修改的 map 对象。

4.2 Map 数据的插入与遍历
在 write.cc 中,通过循环让用户输入多条备注信息。调用 mutable_remark() 获取 map 指针后,使用 insert 方法插入键值对。
cpp
for(int i=0;;i++)//死循环,输入多条备注信息
{
cout<<"请输入备注"<<i+1<<"标题(只输入回车完成备注):";
string remark_key;
getline(cin,remark_key);
if(remark_key.empty())//如果输入为空的话,那么就跳出循环
{
break;
}
cout<<"请输入备注"<<i+1<<"内容";
string remark_value;
getline(cin,remark_value);
people->mutable_remark()->insert({remark_key,remark_value});//mutable_remark会返回map的那一块空间,然后我们进行插入键值对的操作
}
下图展示了 map 数据插入的代码实现:

在 read.cc 中,遍历 map 字段需要使用迭代器。cbegin() 和 cend() 提供了常量迭代器,确保在读取过程中数据不会被意外修改。
cpp
if(people.remark_size())//存在备注信息
{
cout<<"备注信息:"<<endl;
}
for(auto it=people.remark().cbegin();it!=people.remark().cend();it++)//将remark字段拿出来
{
cout<<" "<<it->first<<":"<<it->second<<endl;
}
再次执行 make 并运行程序,测试备注信息的添加与展示。可以看到,系统成功存储并打印了多条备注键值对。

通过以上步骤,通讯录系统集成了 Enum、Any、Oneof 和 Map 等高级数据类型,极大地增强了数据模型的表达能力和系统的灵活性。这些特性的正确使用,是构建高效 Protobuf 应用程序的基础。