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 应用程序的基础。

相关推荐
侠客行031710 小时前
Mybatis连接池实现及池化模式
java·mybatis·源码阅读
蛇皮划水怪10 小时前
深入浅出LangChain4J
java·langchain·llm
较劲男子汉12 小时前
CANN Runtime零拷贝传输技术源码实战 彻底打通Host与Device的数据传输壁垒
运维·服务器·数据库·cann
老毛肚12 小时前
MyBatis体系结构与工作原理 上篇
java·mybatis
wypywyp12 小时前
8. ubuntu 虚拟机 linux 服务器 TCP/IP 概念辨析
linux·服务器·ubuntu
风流倜傥唐伯虎12 小时前
Spring Boot Jar包生产级启停脚本
java·运维·spring boot
Doro再努力12 小时前
【Linux操作系统10】Makefile深度解析:从依赖推导到有效编译
android·linux·运维·服务器·编辑器·vim
senijusene13 小时前
Linux软件编程:IO编程,标准IO(1)
linux·运维·服务器
不像程序员的程序媛13 小时前
Nginx日志切分
服务器·前端·nginx
Yvonne爱编码13 小时前
JAVA数据结构 DAY6-栈和队列
java·开发语言·数据结构·python