全网最详细的ProtoBuf (C) 学习与使用教程文章目录全网最详细的ProtoBuf (C) 学习与使用教程文章摘要第一部分初识ProtoBuf1.1 序列化的概念与现实意义1.2 ProtoBuf是什么1.3 ProtoBuf的工作流程第二部分ProtoBuf 快速上手通讯录1.0步骤1编写第一个.proto文件步骤2编译.proto文件生成C代码步骤3在C程序中使用第三部分proto3 语法深度解析通讯录升级之路3.1 字段规则singular 与 repeated3.2 消息类型的定义与使用3.3 枚举 enum3.4 Any 类型3.5 oneof 类型3.6 map 类型3.7 默认值3.8 更新消息与前后兼容性第四部分通讯录4.0——网络版实战4.1 环境搭建与接口约定4.2 定义所有的Request和Response消息4.3 客户端实现 (核心逻辑)4.4 服务端实现 (核心逻辑)第五部分性能对决——ProtoBuf vs. JSON第六部分总结与展望6.1 ProtoBuf vs. XML vs. JSON6.2 学习心得6.3 未来学习方向总结文章摘要本文将带你全面掌握ProtoBufProtocol Buffers在C环境下的使用。从序列化的基本概念讲起通过一个“通讯录”项目的持续演进手把手教你编写.proto文件、编译生成C代码、以及进行数据的序列化与反序列化操作。文章深入讲解了proto3语法的方方面面包括核心字段规则、消息类型定义与嵌套、枚举、Any类型、oneof类型、map类型、默认值、消息更新与前后兼容性含保留字段、未知字段详解、自定义option等。最后通过一个基于cpp-httplib的网络版通讯录实战将ProtoBuf应用于真实的C/S架构中并对ProtoBuf与JSON的性能进行了对比测试用数据证明了ProtoBuf的高效。无论你是初学者还是进阶开发者这篇保姆级教程都能让你彻底掌握ProtoBuf的精髓。第一部分初识ProtoBuf1.1 序列化的概念与现实意义在计算机科学中**序列化Serialization**是指将数据结构或对象状态转换为可以存储或传输的格式例如二进制流或文本字符串的过程。与之相对**反序列化Deserialization**则是将这种格式恢复为原始对象的过程。为什么需要序列化简单来说主要有两大驱动力持久化存储程序运行时对象数据存在于内存中一旦断电就会消失。我们经常需要将内存中的对象状态保存到硬盘文件或数据库中以便下次程序启动时能恢复。序列化就是实现这一目标的关键技术。网络传输网络通信的基本单位是字节。当两个进程可能位于不同机器需要交换复杂对象时无法直接发送内存对象。发送方必须先将对象序列化为字节序列通过网络发送接收方收到后再反序列化还原成对象。我们学习的Socket编程本质上就是在发送和接收字节流。常见的序列化方案在ProtoBuf出现之前XML和JSON是最主流的数据交换格式。它们各有千秋XML (Extensible Markup Language)可扩展标记语言出现最早功能强大强调数据结构和可读性。但它的标签成对出现导致数据冗余大传输效率低。JSON (JavaScript Object Notation)轻量级的数据交换格式易于人阅读和编写也易于机器解析和生成。它比XML更简洁是Web开发的事实标准。然而在高性能、低延迟的场景下XML和JSON的效率和空间占用仍有优化空间。于是Google推出了自己的解决方案——Protocol Buffers。1.2 ProtoBuf是什么根据Google官方的定义Protocol buffers are Google’s language-neutral, platform-neutral, extensible mechanism for serializing structured data – think XML, but smaller, faster, and simpler.翻译过来核心要点如下语言无关、平台无关你使用一种定义语言.proto文件来描述数据结构ProtoBuf的编译器protoc可以为你生成Java、C、Python、Go等多种语言的代码。生成的代码可以在Windows、Linux、macOS等任何平台上编译和运行。高效它比XML更小序列化后的数据体积小3-10倍、更快序列化/反序列化速度快20-100倍、更简单。可扩展、兼容性好你可以更新数据结构例如增加或删除字段而不会破坏旧程序。这是构建和维护大型系统的关键特性。简单来讲ProtoBuf就是一种“将结构化数据进行高效序列化的方法”。1.3 ProtoBuf的工作流程使用ProtoBuf你只需要做三件事定义在一个.proto文件中用简单的语法定义好你需要处理的结构化数据称为message。生成使用ProtoBuf编译器protoc编译.proto文件自动生成目标语言比如C的代码包含对每个字段的读写接口以及序列化/反序列化的方法。使用在你的C代码中包含生成的头文件然后就可以像使用普通C对象一样设置和获取字段值并调用SerializeToString()、ParseFromString()等方法来完成序列化和反序列化。这种代码生成机制让开发者从繁琐、易错的底层协议解析代码中解放出来可以专注于业务逻辑的实现。第二部分ProtoBuf 快速上手通讯录1.0“纸上得来终觉浅绝知此事要躬行。”我们将通过一个经典的“通讯录”项目来实践ProtoBuf的完整流程。通讯录1.0目标定义一个联系人包含“姓名”和“年龄”两个字段。将该联系人的信息序列化并打印出二进制结果。再将二进制结果反序列化恢复出联系人信息并打印。步骤1编写第一个.proto文件创建一个名为contacts.proto的文件。// 指定使用proto3语法必须在文件第一行注释除外 syntax proto3; // 定义包名用于防止命名冲突类似于C的namespace package contacts; // 定义一个“消息”来描述联系人信息 // 命名规范使用驼峰命名法首字母大写 message PeopleInfo { // 字段定义格式 字段类型 字段名 字段唯一编号; // 字段命名规范全小写字母单词之间用下划线连接 string name 1; // 姓名 int32 age 2; // 年龄 }关键点详解syntax proto3;声明使用proto3语法。如果你不写编译器默认使用proto2。这是必须的。package可选但强烈推荐使用。它相当于C的namespace可以避免不同项目间的消息类型发生名字冲突。message这是ProtoBuf的核心。一个message就是一个包含了一系列类型化字段的聚合体你可以把它理解成C中的struct或class。字段定义每个字段由三部分组成类型、名称、编号。类型可以是标量类型如int32、string、bool等也可以是复合类型其他message类型或枚举。名称字段的标识符。编号这是最重要的部分每个字段在二进制编码中都有一个唯一的编号tag。一旦该消息被使用字段编号就不能更改。1-15的编号编码时需要1个字节16-2047需要2个字节因此应该将频繁使用的字段的编号设为1-15。步骤2编译.proto文件生成C代码使用protoc编译器对contacts.proto进行编译。protoc--cpp_out. contacts.proto--cpp_out.指定生成C代码的输出目录为当前目录.。contacts.proto指定要编译的源文件。执行成功后在当前目录下会生成两个文件contacts.pb.h包含生成类的声明。contacts.pb.cc包含生成类的实现。步骤3在C程序中使用创建一个main.cc文件编写我们的测试代码。#includeiostream#includestring#includecontacts.pb.hintmain(){// 1. 创建一个PeopleInfo对象并设置其字段contacts::PeopleInfo people_src;people_src.set_name(张三);people_src.set_age(20);// 2. 序列化将对象序列化为字符串std::string people_str;if(!people_src.SerializeToString(people_str)){std::cerr序列化失败std::endl;return-1;}std::cout序列化后的二进制数据: people_strstd::endl;std::cout数据大小: people_str.size() bytesstd::endl;// 3. 反序列化从字符串中恢复出对象contacts::PeopleInfo people_dst;if(!people_dst.ParseFromString(people_str)){std::cerr反序列化失败std::endl;return-1;}// 4. 使用反序列化后的对象std::cout反序列化后得到: std::endl;std::cout姓名: people_dst.name()std::endl;std::cout年龄: people_dst.age()std::endl;// 清理ProtoBuf的全局资源可选google::protobuf::ShutdownProtobufLibrary();return0;}编译并运行g main.cc contacts.pb.cc-otest_pb-stdc11-lprotobuf./test_pb注意编译时必须链接protobuf库-lprotobuf并且需要支持C11或更高标准。小结通过这个简单的例子我们完整地体验了ProtoBuf的“定义-编译-使用”三大步骤。虽然序列化后的数据在控制台输出时显示为乱码因为它是二进制格式但这正是ProtoBuf高效和安全不易直接阅读增加了破解成本的体现。第三部分proto3 语法深度解析通讯录升级之路现在我们来深入探索proto3的更高级特性。我们将通过不断升级通讯录项目来学习。3.1 字段规则singular与repeated在proto3中字段默认是singular表示该字段在消息中只能出现0次或1次。如果需要存储一个列表比如一个联系人有多个电话号码我们需要使用repeated关键字修饰。升级通讯录至2.0雏形在contacts.proto的PeopleInfo中添加repeated字段message PeopleInfo { string name 1; int32 age 2; repeated string phone_numbers 3; // 一个联系人有多个电话 }repeated字段可以理解为动态数组。在生成的C代码中它会提供add_phone_numbers()、phone_numbers_size()、phone_numbers(int index)等方法。3.2 消息类型的定义与使用phone_number只是一个字符串我们无法给它附加类型如“手机”、“家庭”。更好的做法是将电话抽象成一个独立的message。升级通讯录至2.0完整版syntax proto3; package contacts; // 定义联系人消息 message PeopleInfo { string name 1; int32 age 2; // 定义嵌套消息 Phone message Phone { string number 1; } // 使用嵌套消息类型作为字段 repeated Phone phone 3; } // 定义通讯录消息包含一个联系人列表 message Contacts { repeated PeopleInfo contacts 1; }编译后观察编译器会为嵌套的Phone生成一个名为PeopleInfo_Phone的类。在PeopleInfo类内部会为Phone类型定义一个typedef PeopleInfo_Phone Phone;方便使用。对于repeated Phone phone字段会生成add_phone()方法返回一个可用的PeopleInfo_Phone*指针和phone(int index)等访问方法。导入其他.proto文件你也可以将Phone消息定义在一个单独的文件phone.proto中然后在contacts.proto中导入使用。// phone.proto syntax proto3; package phone; message Phone { string number 1; }// contacts.proto syntax proto3; package contacts; import phone.proto; // 导入 message PeopleInfo { // ... repeated phone.Phone phone 3; // 使用 包名.消息名 访问 }3.3 枚举enum当字段的值是有限集合中的一个时应使用枚举。例如电话类型移动电话、固定电话。升级通讯录至2.1message PeopleInfo { // ... message Phone { string number 1; // 定义枚举 PhoneType enum PhoneType { // 第一个枚举值必须为0作为默认值 MP 0; // Mobile Phone TEL 1; // Telephone } PhoneType type 2; // 使用枚举类型 } repeated Phone phone 3; }关键规则枚举的第一个常量值必须为0。这用于兼容proto2也作为默认值。枚举常量值必须在32位整型范围内且不建议使用负数影响编码效率。不同枚举内的常量名不能相同在同一作用域下。可以使用option allow_alias true;来允许别名。编译后C代码中会生成对应的枚举PeopleInfo_Phone_PhoneType以及辅助函数如PhoneType_Name()将枚举值转为字符串。3.4 Any 类型Any是Google提供的一个特殊消息类型可以代表任何Any其他消息类型。它类似于C中的void*或C17的std::any但在ProtoBuf中更安全。使用前需要导入google/protobuf/any.proto。升级通讯录至2.2添加地址信息import google/protobuf/any.proto; message Address { string home_address 1; string unit_address 2; } message PeopleInfo { // ... google.protobuf.Any data 4; // 使用Any类型存储地址 }C代码中的使用#includegoogle/protobuf/any.pb.h// ... 在设置联系人信息时Address address;address.set_home_address(北京市朝阳区);address.set_unit_address(某某大厦);google::protobuf::Any*any_datapeople_info_ptr-mutable_data();any_data-PackFrom(address);// 将Address打包进Any// ... 在读取联系人信息时if(people_info.has_data()people_info.data().IsAddress()){Address address;people_info.data().UnpackTo(address);// 使用address...}Any提供了PackFrom(),UnpackTo(),IsT()三个核心方法使用起来非常方便。3.5oneof类型如果你的消息中有多个可选字段且最多只会设置其中一个可以使用oneof来节省内存。升级通讯录至2.3添加QQ或微信message PeopleInfo { // ... oneof other_contact { string qq 5; string weixin 6; } }规则与用法oneof内的字段共享内存同时只能有一个字段被设置。设置oneof中的一个成员会自动清除其他所有成员。你可以用has_qq()和has_weixin()来判断哪个被设置了或者使用other_contact_case()方法获取一个枚举值表明当前是哪个字段被设置。3.6map类型当需要存储键值对时可以使用map。语法是mapkey_type, value_type map_field N;。升级通讯录至2.4添加任意备注信息message PeopleInfo { // ... mapstring, string remark 7; }生成的C代码会提供mutable_remark()方法返回一个google::protobuf::Map对象的指针你可以像使用标准std::map一样操作它。3.7 默认值当反序列化一个消息时如果二进制数据中不包含某个字段那么该字段在对象中会被设置为默认值string空字符串bytes空字节序列boolfalse数值类型0枚举第一个枚举值必须为0消息字段未设置取决于语言C中为NULL或默认构造的实例的指针实际上对于singular消息字段生成的是指针默认为空对于repeated、map等默认为空容器。repeated空列表3.8 更新消息与前后兼容性这是ProtoBuf最强大的特性之一。你可以在不破坏旧代码的前提下更新消息的定义。核心规则不要修改任何已有字段的编号。这是铁律。如果要移除一个字段应使用reserved关键字保留其编号和/或名称防止未来被误用。int32,uint32,int64,uint64,bool是兼容的。sint32和sint64互相兼容但与其他整型不兼容。string和bytes在数据是有效UTF-8的情况下兼容。enum与int32,uint32,int64,uint64在数值上兼容。实战通讯录3.0错误与正确的更新方式错误示例服务端删除了age字段编号2并新增了birthday字段也使用编号2。客户端未更新。结果客户端会将服务端发来的birthday数据错误地解析为age导致数据错乱。正确做法使用reserved。message PeopleInfo { // 保留字段编号2以及字段名 age reserved 2, age; string name 1; // int32 age 2; // 已删除 int32 birthday 4; // 使用新的编号4 // ... }这样做如果以后有人想再用2作为字段编号编译器会直接报错。未知字段从proto3.5开始未知字段即新版本添加但老版本不识别的字段在反序列化时会被保留并在序列化时再次写出。这就保证了“向前兼容”。你可以通过Reflection和UnknownFieldSetAPI来访问这些未知字段实现更灵活的逻辑。第四部分通讯录4.0——网络版实战理论部分告一段落让我们将ProtoBuf应用到一个真实的网络通信场景中。我们将使用cpp-httplib库一个轻量级的C HTTP库来搭建客户端和服务端并使用ProtoBuf来封装所有的请求和响应数据。4.1 环境搭建与接口约定HTTP库cpp-httplib仅头文件非常方便。通信协议HTTP Protobuf。数据格式Content-Type: application/protobuf。约定接口功能方法URL请求Body类型响应Body类型新增联系人POST/contacts/addAddContactRequestAddContactResponse删除联系人POST/contacts/delDelContactRequestDelContactResponse查询所有联系人GET/contacts/find-all无FindAllContactsResponse查询单个联系人POST/contacts/find-oneFindOneContactRequestFindOneContactResponse4.2 定义所有的Request和Response消息我们需要为每个接口定义专门的.proto文件。这里只列举几个关键的。base_response.proto定义一个通用的响应基类。syntax proto3; package base_response; message BaseResponse { bool success 1; string error_desc 2; }add_contact_request.protosyntax proto3; package add_contact_req; message AddContactRequest { string name 1; int32 age 2; message Phone { string number 1; enum PhoneType { MP 0; TEL 1; } PhoneType type 2; } repeated Phone phone 3; mapstring, string remark 4; }add_contact_response.protosyntax proto3; package add_contact_resp; import base_response.proto; message AddContactResponse { base_response.BaseResponse base_resp 1; string uid 2; // 新创建的联系人ID }其他接口的定义模式类似这里不再赘述。使用protoc编译所有这些.proto文件生成C代码。4.3 客户端实现 (核心逻辑)客户端的主要工作是与用户交互将用户输入封装成对应的Request消息通过HTTP发送给服务端并解析服务端返回的Response消息。以“新增联系人”为例// ContactsServer.cc 片段voidContactsServer::addContact(){// 1. 创建HTTP客户端httplib::Clientcli(服务器IP,8123);// 2. 构建ProtoBuf Request对象add_contact_req::AddContactRequest req;buildAddContactRequest(req);// 从用户输入填充req// 3. 序列化Requeststd::string req_str;req.SerializeToString(req_str);// 4. 发送HTTP POST请求autorescli.Post(/contacts/add,req_str,application/protobuf);// 5. 检查HTTP响应if(!res){/* 处理网络错误 */}if(res-status!200){/* 处理HTTP错误 */}// 6. 反序列化Responseadd_contact_resp::AddContactResponse resp;if(!resp.ParseFromString(res-body)){/* 处理解析错误 */}// 7. 处理业务逻辑if(!resp.base_resp().success()){std::cout新增失败: resp.base_resp().error_desc()std::endl;}else{std::cout新增成功! UID: resp.uid()std::endl;}}4.4 服务端实现 (核心逻辑)服务端需要基于cpp-httplib创建HTTP服务为每个接口注册处理函数。在处理函数中反序列化Request调用业务逻辑最后序列化Response并返回。服务端主函数#includehttplib.h#includecontacts.pb.h// 服务端内部存储用的消息定义// ... 引入所有Request/Response头文件intmain(){httplib::Server svr;ContactsServer contactsServer;// 业务逻辑处理类// 注册 /contacts/add 路由svr.Post(/contacts/add,[](consthttplib::Requestreq,httplib::Responseres){add_contact_req::AddContactRequest request;add_contact_resp::AddContactResponse response;// 1. 反序列化请求if(!request.ParseFromString(req.body)){// 处理错误...}// 2. 调用业务逻辑contactsServer.AddContact(request,response);// 3. 序列化响应std::string response_str;response.SerializeToString(response_str);// 4. 设置HTTP响应res.set_content(response_str,application/protobuf);});// 注册其他路由...// 启动服务监听在8123端口svr.listen(0.0.0.0,8123);}在业务逻辑中如ContactsServer::AddContact服务端会操作一个本地的contacts::Contacts对象并负责将其持久化到文件或数据库中。第五部分性能对决——ProtoBuf vs. JSON最后让我们用数据说话。我们将对同一份复杂的结构化数据包含嵌套、列表、映射等分别用ProtoBuf和JSON进行多次序列化/反序列化操作比较它们的耗时和最终大小。测试数据一个联系人信息包含姓名、年龄、QQ、5个电话、一个嵌套地址、5条备注。测试代码核心逻辑// 对序列化和反序列化各进行 TEST_COUNT 次循环// 使用 gettimeofday 进行高精度计时测试结果在特定硬件和软件环境下操作次数指标ProtoBufJSON优势100次序列化耗时0.342ms1.306msPB快3.8倍序列化后大小278 bytes567 bytesPB小2倍反序列化耗时0.435ms0.926msPB快2.1倍1000次序列化耗时3.59ms11.582msPB快3.2倍反序列化耗时5.069ms9.289msPB快1.8倍10000次序列化耗时34.386ms115.76msPB快3.4倍反序列化耗时45.96ms91.046msPB快2倍100000次序列化耗时349.937ms1150.54msPB快3.3倍反序列化耗时428.366ms904.58msPB快2.1倍结论性能ProtoBuf的序列化和反序列化速度约是JSON的2-4倍。体积ProtoBuf生成的二进制数据体积仅为JSON的一半左右。可读性JSON文本格式可直接阅读和调试ProtoBuf二进制格式不可读但更“安全”。总结ProtoBuf在效率和空间上具有压倒性优势非常适合对性能要求高的内部系统或服务间通信。JSON则以其极佳的可读性和通用性在Web前端、配置文件等领域占据主导地位。没有最好的技术只有最合适的技术。第六部分总结与展望6.1 ProtoBuf vs. XML vs. JSON特性XMLJSONProtoBuf数据格式文本文本二进制可读性好很好差可扩展性好好好序列化性能低中高数据体积大中小语言/平台支持所有所有主流语言官方第三方主要应用场景配置文件、SOAP、Office文档Web API、浏览器-服务器通信高性能RPC、分布式系统、数据存储6.2 学习心得ProtoBuf的学习曲线并不陡峭。只要理解了其核心的“定义语言代码生成序列化运行时”的工作模式并熟悉proto3的基本语法很快就能上手。它的前后兼容性特性更是为大型系统的长期维护和演进提供了坚实的基础。6.3 未来学习方向gRPC这是Google基于HTTP/2和ProtoBuf开发的高性能RPC框架。掌握了ProtoBuf学习gRPC将事半功倍。更深层的编码原理了解Varint、ZigZag编码理解为什么repeated字段的编号策略会影响性能等有助于写出更高效的.proto文件。动态消息学习Descriptor和Reflection类可以在运行时动态地创建、访问和修改消息这对于一些通用框架的编写非常有用。希望这篇详尽的教程能帮你打开ProtoBuf的大门。现在就开始在你的项目中使用这个强大的工具吧总结这篇文章是作者搜集大量面经和资料这里出来的。感谢你的支持作者wkm是一名中国矿业大学(北京) 大一的新生希望得到你的关注如果可以的话记得一键三联