Protocol Buffers C++ 进阶数据类型与应用逻辑深度解析

目录

    • 前言
    • 一、枚举类型(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 文件中,通过引入枚举类型来标识电话号码的类别(移动电话或固定电话)。同时,该文件通过 importpackage 指令构建了完整的协议结构。

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.hcontacts.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.ccAddPeopleInfo 函数中,首先创建并填充 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 组,包含 qqwechat 两个字段。

cpp 复制代码
    oneof other_contact

    {

        string qq =5;

        string wechat=6;

    }

这种定义意味着 qqwechat 共享内存空间(逻辑上),设置其中一个会自动清除另一个。下图展示了 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 应用程序的基础。

相关推荐
宴之敖者、2 小时前
Linux——权限
linux·运维·服务器
黎雁·泠崖2 小时前
Java面向对象:对象内存图+成员与局部变量
java·开发语言
窗边鸟2 小时前
小白日记之java方法(java复习)
java·学习
jiunian_cn2 小时前
【C++】IO流
开发语言·c++
txinyu的博客2 小时前
MAC 地址
服务器·网络·macos
砚边数影3 小时前
AI数学基础(一):线性代数核心,向量/矩阵运算的Java实现
java·数据库·人工智能·线性代数·矩阵·ai编程·金仓数据库
豆沙沙包?3 小时前
2026年--Lc343-1926. 迷宫中离入口最近的出口(图 - 广度优先搜索)--java版
java·算法·宽度优先
CoderCodingNo3 小时前
【GESP】C++六级考试大纲知识点梳理, (7) 栈与队列
开发语言·c++
超级大福宝3 小时前
【力扣200. 岛屿数量】的一种错误解法(BFS)
数据结构·c++·算法·leetcode·广度优先