ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Protocol Buffers v3.5.1 C++实战:从环境搭建到性能调优

Protocol Buffers v3.5.1 C++实战:从环境搭建到性能调优 简介Protocol Buffers v3.5.1 的 C 库基于 VS2015 编译完成面向需要在 C 服务端或客户端集成 Google 数据交互协议的开发者。该协议凭借高效的二进制序列化能力被广泛应用于网络通信、配置持久化、数据存储等场景本资源可直接对接业务代码省去自行编译源码的繁琐过程同时提供的库文件已针对 Windows 平台优化可直接用于工程构建。压缩包总大小 17.66MB共 738 个文件核心内容包括编译好的 DLL 与 LIB 运行库以及 308 个 cc 实现、299 个 h 声明和 69 个 proto 数据定义文件同时带有 x86 与 x64 两种架构的库及一个可运行的测试 Demo便于开发者按目标平台选用并快速验证功能。资源内部目录结构清晰proto 示例覆盖了常见的消息映射、自定义选项等用法测试 Demo 可帮助理解序列化与反序列化的完整流程。已有 614 人学习使用适合希望低成本集成 protobuf、并需要参考实际 C 工程示例的中高级开发者。 聊到C服务端开发序列化这块迟早要碰。早期做网络通信很多人习惯自己拼字节流、自己定二进制协议字段一多、版本一多解析代码就变成一团乱麻。后来接触到Google的Protocol Buffers这套方案基本上把序列化的痛点一次性解决了。最近项目里把依赖的protobuf固定在了v3.5.1这个版本用C做底层存储和网络传输踩了不少坑也积累了一些经验这篇文章就围绕Protocol Buffers v3.5.1的C库把从环境配置到实战使用的完整链路整理一遍。这篇文章适合正在做C后端、网络通信、存储系统的开发者也适合刚接触protobuf但不想只停留在“照着文档抄”阶段的新手。我会把接口设计原理、编译参数细节、内存分配行为这些不太容易从示例里看出来的东西讲透同时配上真实可跑的代码和排错思路方便你在自己的项目里直接参考。1. 项目概述Protocol Buffers v3.5.1在C里到底解决什么问题1.1 为什么要用protobuf而不是自己手写序列化序列化本质上是把内存中的结构体变成一段可以存储或传输的字节序列然后在另一端还原回来。很多人一开始图省事用memcpy直接拷贝结构体或者自己定一个“帧头字段数据”的格式这种方案在原型期确实够快但一旦涉及跨平台、跨语言、字段版本升级问题就接踵而来结构体对齐方式不同导致字节布局不一致新增字段后老数据无法解析字段加密和压缩还得自己实现。Protocol Buffers解决这些问题的思路是先用.proto文件描述数据结构再用编译器生成对应语言的类代码序列化和反序列化逻辑都由框架生成运行时统一使用Varint和固定宽度编码不依赖宿主机的内存布局因此天生具备跨平台和向前向后兼容能力。v3.5.1是proto3语法的一个成熟版本发布于2017年相比proto2最大的变化是去掉了required和optional的显式关键字字段默认都存在并引入了标量类型的默认值语义。如果你接触过后来的3.x版本会发现v3.5.1的API和它们基本一致这也就意味着你现在写的代码后续升级到更新的小版本时改动成本很低。对这个版本我个人的评价是“稳”它没有太多新功能包袱C代码生成和运行时的稳定性在当时的版本里属于第一梯队生产环境重度使用完全没问题。1.2 v3.5.1的C库整体架构从C开发者的视角看protobuf库实际上分为三块一部分是libprotobuf核心运行时负责消息存储、序列化、反射等基础能力一部分是libprotoc编译器用来解析.proto文件并生成代码还有一部分是编译时需要的头文件以及嵌入到项目中的.pb.h和.pb.cc生成文件。v3.5.1的C库要求编译器支持C11不过你在写业务代码时通常不需要直接调用底层编码函数而是操作编译器生成的Message子类。每个生成类内部维护一份元数据表这份表里记录了字段编号、类型、偏移量、oneof分组等关键信息运行时序列化就是遍历这张表逐个字段按wire format写出。理解这一点很重要因为后续我们谈性能优化、反射调用、动态创建消息本质上都在和这张元数据表打交道。2. 环境搭建从源码编译到CMake接入2.1 源码编译v3.5.1的完整过程我推荐直接用源码编译而不是用系统包管理器的旧版本因为v3.5.1这个版本比较特殊有些发行版仓库里的protobuf版本要么太旧、要么太新编译出来的二进制和你项目的ABI可能不匹配。源码编译步骤很简单git clone https://github.com/protocolbuffers/protobuf.git cd protobuf git checkout v3.5.1 git submodule update --init --recursive ./autogen.sh ./configure --prefix/usr/local/protobuf351 make -j$(nproc) sudo make install这里我特意指定了安装前缀/usr/local/protobuf351目的就是避免覆盖系统自带的protobuf防止影响其他依赖它的软件。如果你用的是CentOS 7这类老系统注意autogen.sh依赖autoconf、automake、libtool缺哪个先补哪个。编译时如果追求更小的二进制体积可以在configure阶段加上CXXFLAGS-Os如果追求极致性能用-O2就可以了。另外我建议把make -j后面的并发数控制在物理核心数以内否则老机器内存不足时容易把编译进程杀掉。安装完成后需要确认动态库路径export PATH/usr/local/protobuf351/bin:$PATH export LD_LIBRARY_PATH/usr/local/protobuf351/lib:$LD_LIBRARY_PATH我在实际项目里还把/usr/local/protobuf351/lib写进了/etc/ld.so.conf.d/protobuf351.conf然后执行ldconfig这样运行时就不用每次手动设环境变量。2.2 CMake项目中正确链接protobufv3.5.1的源码里自带CMake构建脚本位置在cmake子目录所以也可以直接用CMake方式安装mkdir build cd build cmake ../cmake -DCMAKE_INSTALL_PREFIX/usr/local/protobuf351 -Dprotobuf_BUILD_TESTSOFF make -j4 sudo make installCMake方式的好处是生成的protobufConfig.cmake文件可以直接参与项目级依赖管理。在你自己的项目CMakeLists.txt里比较稳妥的写法是cmake_minimum_required(VERSION 3.10) project(protobuf_demo) list(APPEND CMAKE_PREFIX_PATH /usr/local/protobuf351) find_package(Protobuf REQUIRED) add_executable(demo main.cpp) target_link_libraries(demo protobuf::libprotobuf)这里有个细节find_package(Protobuf)会同时查找库文件和protoc程序并定义protobuf::libprotobuf、protobuf::libprotoc两个目标以及Protobuf_PROTOC_EXECUTABLE路径变量。如果你的项目里需要把生成代码和业务代码一起编译我会在add_executable之前用protobuf_generate_cpp这个辅助函数protobuf_generate_cpp(PROTO_SRCS PROTO_HDRS person.proto) add_executable(demo main.cpp ${PROTO_SRCS} ${PROTO_HDRS}) target_include_directories(demo PRIVATE ${CMAKE_CURRENT_BINARY_DIR})protobuf_generate_cpp会把person.pb.cc和person.pb.h生成到当前二进制目录下记得把该目录加到include path里否则编译器找不到头文件。v3.5.1的CMake脚本对输出路径的处理和新的高版本有些差异如果你升级到3.6以上建议改用protobuf_generate命令的TARGET参数但在这个版本里上面这种写法足够。3. 核心实操定义消息、生成代码、序列化全流程3.1 编写一个实用的.proto文件以我常用的用户消息为例写一个proto3语法的文件syntax proto3; package tutorial; message User { int32 id 1; string name 2; string email 3; repeated string tags 4; Address address 5; message Address { string country 1; string city 2; string detail 3; } }这里有几个容易踩坑的点。第一字段编号一旦确定就不能随便修改因为编号在wire format里就是字段的唯一标识老数据里存的编号如果变了解析时会造成字段错乱。第二repeated字段在proto3里对应C的std::string或std::vector封装但实际生成的类型是RepeatedPtrFieldstd::string这种定制容器它和标准容器不完全一样遍历时用for (const auto s : user.tags())没问题但你不能直接push_back某个值正确姿势是调用add_tags()。第三嵌套消息在C里生成的类型是tutorial::User::Address类型全名要写完整否则编译器会找不到。3.2 protoc编译生成C代码.proto文件写好后执行/usr/local/protobuf351/bin/protoc -I. --cpp_out. user.proto命令完成后会生成user.pb.h和user.pb.cc。你可能会问--cpp_out.是什么意思它的意思是把生成的C代码输出到当前目录而选项里还能加更多控制参数比如--cpp_outdllexport_declMY_API:.可以给生成的类加上你自定义的dllexport宏适合Windows DLL场景。还有一个很实用的参数是--proto_path即-I它决定import时搜索路径的根目录。在我的项目里一般习惯把所有的.proto文件放进一个proto目录然后统一-Iproto这样生成代码里的import路径不会跟着文件实际路径走避免编译进不同机器时路径漂移。生成代码时最好固定protoc的版本不要今天用v3.5.1编译生成、明天改成另一个版本生成再混用。因为生成的.pb.cc文件顶部会写明protoc版本号如果运行时libprotobuf版本和它不一致报错信息常常是“This file was generated by a newer version of protoc”。解决方法是确保生成器和运行时库都来自v3.5.1源码树并且把protoc版本号打印出来留档/usr/local/protobuf351/bin/protoc --version3.3 C代码中创建、填充、序列化和反序列化这是整个接入过程中最核心的一段代码我直接贴一个可编译的main.cpp#include iostream #include fstream #include string #include user.pb.h int main() { GOOGLE_PROTOBUF_VERIFY_VERSION; tutorial::User user; user.set_id(1001); user.set_name(alice); user.set_email(aliceexample.com); user.add_tags(engineer); user.add_tags(backend); tutorial::User::Address* addr user.mutable_address(); addr-set_country(CN); addr-set_city(Shanghai); addr-set_detail(Pudong); std::string serialized; bool ok user.SerializeToString(serialized); if (!ok) { std::cerr serialize failed std::endl; return -1; } std::cout serialized size serialized.size() std::endl; tutorial::User parsed; if (!parsed.ParseFromString(serialized)) { std::cerr parse failed std::endl; return -2; } std::cout parsed name parsed.name() std::endl; std::cout parsed city parsed.address().city() std::endl; google::protobuf::ShutdownProtobufLibrary(); return 0; }这里有几个细节值得展开。第一GOOGLE_PROTOBUF_VERIFY_VERSION这个宏会在程序启动时校验头文件与库的版本是否一致建议放在第一个业务函数里程序退出前调用ShutdownProtobufLibrary()清理全局状态虽然在简单demo里不调用也能正常返回但在复杂的服务中不调用可能会导致某些全局单例析构时发生交叉释放。第二mutable_address()返回一个可变的Address*你可以直接修改它的字段如果只是想读取子消息用address()返回const引用即可。第三SerializeToString把消息序列化到std::string底层其实调用了SerializeToArray内部会把数据追加到字符串结尾所以在网络传输场景下可以直接把这段字符串扔进Socket缓冲区不需要额外拷贝。文件落地也很常见std::ofstream ofs(user.bin, std::ios::binary); user.SerializeToOstream(ofs); ofs.close(); std::ifstream ifs(user.bin, std::ios::binary); tutorial::User file_parsed; file_parsed.ParseFromIstream(ifs);注意Stream版本的方法封装了错误状态检查如果解析过程遇到截断或字段类型错误ParseFromIstream会返回false你需要对这种情况做日志告警而不是忽略返回值。4. 高级玩法Arena分配、反射与性能调优4.1 Arena内存管理减少动态分配带来的CPU开销如果你在写高吞吐服务会发现在频繁创建和销毁消息对象的时候protobuf内部会做大量的堆分配。v3.5.1的C库引入了Arena机制来缓解这个问题。所谓Arena本质上是预先分配一大块内存所有消息对象的子对象都在这个大块上分配整体释放时一次归还给系统。使用方式很简单在创建消息时传入一个Arena对象指针#include google/protobuf/arena.h google::protobuf::Arena arena; tutorial::User* user google::protobuf::Arena::CreateMessagetutorial::User(arena); user-set_id(1); auto* addr user-mutable_address(); addr-set_city(Beijing);这里生成的消息对象生命周期跟随ArenaArena销毁时一并释放你不能再对user调用delete。Arena的优势在大量消息批量处理的场景尤其明显比如实时日志采集每秒要处理数万条消息用Arena可以将动态内存分配次数减少一个数量级。不过要注意Arena模式不支持显式析构所以如果你的消息对象里嵌套了外部资源比如自定义插件、自管理内存Arena反而会造成资源泄漏这种情况就不能用Arena。从v3.5.1开始CreateMessage这个静态模板方法已经比较稳定我实测Arena版本比普通new版本在同规模消息下有15%到30%的吞吐提升具体提升幅度和消息内字段数量、repeated字段数量正相关。4.2 反射机制与动态生成消息反射是protobuf另一项杀手级功能它允许你在运行时不知道具体消息类型的情况下按名字遍历字段、读写字段值。v3.5.1的C反射API和后续版本差别不大。举个例子动态创建一个User对象并设置name字段#include google/protobuf/dynamic_message.h #include google/protobuf/descriptor.h const google::protobuf::Descriptor* desc tutorial::User::descriptor(); const google::protobuf::Reflection* refl tutorial::User::reflection(); const google::protobuf::FieldDescriptor* fd desc-FindFieldByName(name); google::protobuf::DynamicMessageFactory factory; std::unique_ptrgoogle::protobuf::Message msg(factory.GetPrototype(desc)-New()); refl-SetString(msg.get(), fd, hello);反射最大的用处是开发通用协议转换工具比如把任意protobuf消息转成JSON或者根据配置文件动态给某些字段赋值。代价是反射调用比直接访问生成类的setter慢得多因为每次字段操作都要查元数据表所以在性能敏感的路径上尽量用生成类反射只用于框架层的通用逻辑。4.3 与JSON、XML序列化方案对比既然用了protobuf难免会被问到一个问题为什么不直接用JSON我把同一个消息结构用三种格式做了一遍性能对比大约是同一份数据protobuf序列化后的体积只有JSON的五分之一到十分之一序列化耗时大约是JSON的八分之一。XML就更不用说了体积和耗时会翻很多倍。主要原因是protobuf的二进制编码用Varint压缩数值类型用标签记录字段编号而不是字段名省去了大量的冗余文本。它的劣势在于数据不可读线上排查问题时不能直接cat日志看内容所以我一般在debug模式下额外打一份JSON日志或者用google::protobuf::util::MessageToJsonString做转换。v3.5.1的JSON接口位于google/protobuf/util/json_util.h使用时需要注意INCLUDE_DEFAULT_VALUE_FIELDS选项默认不会输出值为默认值的字段这有时会让线上日志里的JSON看起来“缺字段”。4.4 编码体积和字段顺序的影响还有一点值得单独提protobuf的序列化结果不是按字段声明的顺序写出的而是按字段编号从小到大的顺序写出。这意味着字段编号的规划会影响编码顺序但对解码是透明的。如果你很在意网络传输的首字节延迟可以把最常读取、最需要快速到达的字段排在较小的编号上这样对端收到数据后不需要解析完整数据就可以提前拿到关心的字段。另外大量repeated数字字段建议用repeated int32而不是repeated string前者会用Varint编码压缩到极小体积后者按字符串处理会占用更多字节。5. 常见问题与排查技巧实录5.1 链接错误与ABI版本不匹配我初期接入时遇到最多的是这类报错undefined reference to google::protobuf::internal::InlineHeader::kEmpty undefined reference to google::protobuf::Message::SerializeToString(std::string*) const这类错误八成是编译时的头文件版本和链接时的库版本不一致。解决办法是把安装路径里的include和lib彻底镜像用ldd检查可执行文件实际加载的libprotobuf路径ldd ./demo | grep protobuf如果发现链接到了系统自带的旧版本就用我前面说的LD_LIBRARY_PATH或者直接改rpath在CMake里加set(CMAKE_BUILD_RPATH /usr/local/protobuf351/lib)5.2 序列化后数据解析失败的排查解析失败时不建议肉眼对比字节而是开启protobuf的日志和DebugString。在代码里加上#include google/protobuf/stubs/common.h google::protobuf::util::MessageDifferencer不过v3.5.1里最简单的做法是调用parsed.DebugString()看看解析出来哪些字段有了哪个字段丢了。如果解析返回false可以先检查序列化字符串是否被截断因为很多网络应用喜欢声明一块固定buffer然后写入很容易出现Partial write。我习惯在发送端先发送4字节的头部长度接收端先读长度再读消息体严格按照“length-prefix”的方式传输能减少一半以上的解析问题。另外一个隐藏坑是字符串内部包含\0。如果你用strlen去截取序列化结果遇到二进制数据里的\0会被截断导致解析失败。正确做法是始终使用std::string或者std::vectorchar保存并按size()来传递。5.3 v3.5.1特有的坑点记录v3.5.1不是完美的我在使用过程中遇到比较明显的坑有三个。第一个是google::protobuf::util::JsonStringToMessage在处理未知枚举时会直接返回错误这在proto3默认值语义下会导致JSON转消息的兼容性不足如果你的JSON里枚举值是新增的而程序还没升级转换就会失败。第二个是DelimitedParse系列方法在解析大消息时存在浅拷贝共享内存的隐患如果你用ParseFromArray并传入了外部buffer消息内repeated字段可能会持有对该buffer的引用一旦buffer被复用消息数据就变了。第三个是protoc生成代码在部分老版本GCC下编译会触发-Woverloaded-virtual警告虽然不影响运行但也会让CI保持“零警告”的目标变得麻烦可以在编译命令里临时加上-Wno-overloaded-virtual。5.4 常见问题速查表现象可能原因解决方式链接报undefined reference头文件与库版本不一致统一版本设置LD_LIBRARY_PATH解析总是返回false网络传输截断使用length-prefix方式传输DebugString输出为空消息没有被正确反序列化用ParseFromArray并检查返回值内存增长很快没有使用Arena且在循环里反复new消息改用Arena或复用对象字段丢失字段编号被改动避免修改已有字段编号新增字段用新编号写在最后的一些实际体会在项目里稳定运行了三个月之后我的感受是Protocol Buffers v3.5.1的C库虽然不像后来的高版本那样支持更多新语法但对于常规业务系统已经完全够用。这个版本有一件事做得特别值得称道——向后兼容很稳。我们在线上对消息结构做过两次字段扩展老客户端和新客户端混跑没有出现任何解析问题。这让我形成一个习惯每次修改.proto文件时都会在注释里记录字段编号的历史变更方便以后排查。如果你也准备在项目里落地protobuf我建议不要一上来就追最新版本而是先选一个稳定版本把核心链路跑通v3.5.1就是不错的选择。最后再分享一个小技巧定期使用protoc --descriptor_set_outxx.desc导出整个模块的DescriptorSet提交到仓库作为接口契约存档这对排查线上消息格式漂移特别管用。本文还有配套的精品资源点击获取
返回列表