简介:在 VS2019 下使用 protobuf 3.8.0 的 C++ 案例资源包,面向需要掌握谷歌 Protobuf 序列化方案的开发者,重点解决跨平台数据交换、网络传输和持久化存储中的二进制编码问题。资源共 688 个文件,压缩包约 4.72MB,以 cc 源文件、h 头文件和 proto 定义文件为主,同时提供 VS2019 工程文件、链接库与可执行工具,层级清楚,便于直接打开工程编译运行,也适合作为教学演示和项目改造起点。目前已有 1817 人学习。资源内置了完整可用的 Person 示例,覆盖从 proto 定义、编译生成、工程集成到序列化和反序列化调用的主要流程,并给出了相对路径引用和环境变量配置等可移植性建议;包内大量 proto 及测试用例文件,也有助于进阶读者对比理解字段规则、默认值与边界场景。整体内容兼顾入门演示和深入排查,适合需要在 Visual Studio 环境落地 protobuf 的 C++ 开发者系统学习与直接参考。 如果你跟我一样,手里还压着老项目,代码库停留在VS2019,又不想为了对接一个序列化方案把整个构建链重做一遍,那protobuf 3.8.0在C++里的接入方式就值得好好捋一遍。这个版本不算新,但胜在稳定——网上能搜到的大多数教程和旧代码片段都是围着它转的,遇到问题也容易找到现成答案。
这篇文章不是把官方文档复述一遍,而是把我自己在VS2019下从编译、写.proto、接进工程、到跑通序列化全过程的操作和踩坑记录整理出来,给准备在同一环境下手的人一条能直接照着走的路。内容包括完整的CMake构建命令、工程属性配置清单、序列化反序列化示例代码,以及几个我实际调试过很久才搞明白的坑。
1. 为什么选protobuf 3.8.0,以及VS2019下绕不开的编译准备
1.1 三个理由:跨语言、体积小、字段兼容
先聊个很多人会问的问题:C++项目做序列化,选择不少,JSON、XML、Msgpack、Boost.Serialization,怎么就到了protobuf头上?
我的场景是这样的:服务端负责数据下发,客户端这边既有C++写的桌面工具,也有Java和Python写的辅助脚本。如果我用JSON,调试确实方便,但数据量一上来,JSON的文本冗余和解析耗时就很扎眼。如果我用某个语言绑定的私有序列化方案,别人对接就变成灾难。protobuf 3.8.0在这几个方向上都对得上:一份.proto文件描述结构,protoc自动生成各语言代码,编译期就保证字段匹配,不用像JSON那样运行时才知道缺了哪个字段。
另一个优势是体积。protobuf采用varint编码,小整数只占一到两个字节,字符串按原始字节存储,整套二进制格式里没有多余的分隔符。同样是1000条记录,JSON可能要几百KB,protobuf压到几十KB很常见。对走TCP或局域网传输的场景,节省的不只是带宽,还有对方反序列化的CPU时间。
3.8.0这个版本还有一层现实考量:它正好是VS2019开始普及时期的稳定版本,对老代码的兼容性好,网上能查到的资料也全。很多内部项目到现在还在用它,足以说明可靠性。
1.2 用CMake生成VS2019工程并编译libprotobuf
protobuf官方Release页面默认给的是源码包,Windows下没有现成编译好的lib可直接拿。这就意味着第一步必须是编译。别嫌麻烦,这一步过了,后面反而省心。
我建议直接用CMake而不是去折腾老旧的autotools脚本。在protobuf-3.8.0根目录下有个cmake子目录,编译命令如下:
mkdir build-vs2019 cd build-vs2019 cmake ../cmake -G "Visual Studio 16 2019" -A x64 ^ -DCMAKE_INSTALL_PREFIX=D:/local/protobuf-3.8.0 ^ -Dprotobuf_BUILD_TESTS=OFF ^ -Dprotobuf_MSVC_STATIC_RUNTIME=OFF cmake --build . --config Release --parallel cmake --build . --config Debug --parallel cmake --install .这里几个参数必须说清楚。protobuf_BUILD_TESTS=OFF一定要关,否则CMake会去拉gtest依赖,编译时间至少翻两倍。protobuf_MSVC_STATIC_RUNTIME=OFF的意思是生成的库采用动态运行库(/MD),这个我单独放到下一节讲,它是新手最容易踩的坑。
编译完成后,D:/local/protobuf-3.8.0目录下会有include、lib、bin三个子目录。bin/protoc.exe是代码生成器,后面写.proto文件全靠它;lib下会有libprotobuf.lib、libprotobufd.lib等静态库,以及对应DLL。建议目录名里带上版本号,因为机器上可能同时存在多个protobuf版本,混用会出大问题。
1.3 运行时MD/MT选项的坑,没处理好直接LNK2038
protobuf_MSVC_STATIC_RUNTIME这个选项,默认情况下是ON还是OFF在不同小版本里不完全一样,所以我的建议是每次配置时都显式写出来,不要靠猜。
这个选项影响的是libprotobuf本身的CRT链接方式。如果编译protobuf时用的是/MT(静态运行库),而你的VS2019项目默认是/MD(动态运行库),那么在链接阶段会出现经典的LNK2038错误,提示“RuntimeLibrary”不匹配。这个错很迷惑人,因为它看起来像库本身坏了,实际上只是运行库方式不一致。
我实际操作中一直是把protobuf_MSVC_STATIC_RUNTIME=OFF作为标准选项,因为VS2019新建项目默认就是/MD,这样编译出来的库和项目用的运行库一致,后面不会再出幺蛾子。如果你因为特殊原因必须用/MT,那就得反过来把项目属性里的运行库也改成/MT,两边对齐才行。
另外一个隐藏问题:CMake版本不能太老。VS2019需要CMake 3.14以上才认Visual Studio 16 2019这个生成器。我一开始用的是系统自带的3.12,结果直接报找不到生成器,最后去官网下了个新版CMake才解决。如果你也是老环境,先检查这个。
2. 设计消息格式:.proto文件中那些影响代码生成的细节
2.1 proto2与proto3的字段语义差异
到这一步,你已经拿到了可用的protoc.exe。接下来要写.proto文件。
第一件事就是选语法版本。文件开头需要声明syntax = "proto2";或syntax = "proto3";。官方现在默认推荐proto3,但如果你是从老项目迁移,或者依赖字段存在性判断,proto2也依然有不少存量代码在用。
这两者最大的区别在于字段语义。proto2里可以用required、optional、repeated三种标签,optional字段会生成has_xxx()方法,用来判断这个字段是否被显式赋值。proto3则把这些都简化了,标量字段全是optional语义,没有required,同时也不再生成has_xxx()方法,字段的判断只能靠与默认值比较。要注意的是,在protobuf 3.8.0的proto3里,optional关键字也还不支持带出has位(那是3.15之后才加入的能力)。所以如果你非常依赖判断“这个字段到底有没有被设置”,要么继续用proto2,要么把字段设计成repeated或者message类型。
以我们的案例来说,我用的是proto2,因为项目中确实需要区分“姓名没传”和“姓名为空字符串”这两种情况。不需要纠结哪个更高级,看需求选,用着顺手才是标准。
2.2 写一个示例消息并用protoc生成C++代码
下面是一个典型的.proto文件,覆盖了常用类型:字符串、整数、repeated列表。
syntax = "proto2"; package demo; message Person { required string name = 1; required int32 id = 2; repeated string emails = 3; }package关键字映射成C++的namespace,所以这里生成的类会放在demo命名空间下。字段后面的数字是字段编号,二进制格式里实际传输的是这个编号而不是字段名,所以它是整个格式兼容性的基础,不能随意改动。repeated在C++里对应一个类似vector的访问接口,后面代码里会看到。
生成代码的命令:
protoc -I=. --cpp_out=./gen person.proto-I指定import查找路径,--cpp_out指定生成目录。执行完会在gen目录下出现person.pb.h和person.pb.cc两个文件。它们体积不小,不要手改,也建议不要直接放进版本库随代码提交,而是通过构建脚本固定生成,避免多人手动执行导致版本漂移。
protobuf的二进制格式设计得很有意思,它是按字段编号紧凑排列的,每个字段由tag和value组成,tag本身通过一个公式把字段号和wire type编码在一起。这样解析器才能快速跳转。字段编号的意义就和这个设计直接挂钩,所以注释里最好说明每个字段的用途,给后来人省点力气。
3. 把生成的代码接进VS2019项目
3.1 选择动态库还是静态库
编译安装完成后,你会同时得到静态库和动态库。以我经验,大部分项目用静态库最省心,因为它不需要你在部署exe时额外带上protobuf.dll。
用静态库时,链接器直接把libprotobuf的代码编进你的exe,运行环境干净,调试也不用担心DLL路径问题。缺点是多出几百KB体积,但对桌面工具和内部服务来说根本不算事。
如果你选动态库,要小心一个编译宏:PROTOBUF_USE_DLLS。使用动态库时生成的C++代码需要通过这个宏来确保正确导入类接口。如果你没定义它,编译可能能过,但链接时会报一堆无法解析的外部符号。这类问题排查起来很烦人,所以我建议,没有特别理由就直接上静态库,别给自己找事。
3.2 属性页配置清单与第一次编译验证
在VS2019里,把person.pb.cc和person.pb.h加入项目后,开始配置工程。别嫌这一步琐碎,配置一次,后面再建其他模块就能照抄了。
右键项目,打开属性页,做三件事:
- 包含目录:在“VC++目录 -> 包含目录”里加上
D:/local/protobuf-3.8.0/include。 - 库目录:在“VC++目录 -> 库目录”里加上
D:/local/protobuf-3.8.0/lib。 - 附加依赖项:在“链接器 -> 输入 -> 附加依赖项”里加上
libprotobuf.lib。
注意Debug和Release要分别配置。Release链接libprotobuf.lib,而Debug要链接libprotobufd.lib。这里的d后缀代表debug版本。很多人忘记切换,导致Debug编译通过,Release编译报链接错误,反过来也一样。
配置完成后,先写个最简main测试一下:
#include <iostream> #include "person.pb.h" int main() { demo::Person p; p.set_name("Alice"); p.set_id(10001); std::cout << p.name() << std::endl; return 0; }如果这一步能编译运行,说明库连接没问题,后面就可以放心展开业务代码了。
4. 序列化与反序列化的核心使用案例
4.1 对象构造:字段写入与repeated字段的两种姿势
protobuf生成的消息类,字段赋值接口非常直观:set_字段名(),字符串字段传入const std::string&或const char*,数字类型传入对应整型。
对于repeated字段,生成的接口有add_字段名()、字段名_size()和字段名(index)。如果之前接触过C++容器,这个模式很好理解:
demo::Person person; person.set_name("Alice"); person.set_id(10001); person.add_emails("alice@example.com"); person.add_emails("support@example.com");如果你想一次性设置repeated字段的内容,也可以用mutable_emails()拿到底层google::protobuf::RepeatedPtrField<std::string>的指针,直接往里放数据。这种方式在批量导入场景更高效,少了一次次add的重复计算。
这里要特别提醒一下内存所有权问题。在proto2里,字符串字段除了set_name()之外还有个set_allocated_name()方法,它接受一个指针并把这个指针的所有权转移给消息对象,消息析构时会delete掉这个指针。如果外部还保留着这个指针,就会发生double free。所以我的建议是:默认只用set_xxx(),只有当你能完全控制生命周期时,才考虑set_allocated_xxx()。
4.2 内存序列化:SerializeToString/ParseFromString
序列化的核心API就是两个方法,一个序列化到内存字符串,一个从内存字符串解析。完整示例代码如下:
#include <iostream> #include <fstream> #include <string> #include "person.pb.h" int main() { demo::Person person; person.set_name("Alice"); person.set_id(10001); person.add_emails("alice@example.com"); person.add_emails("support@example.com"); // 1. 序列化到内存字符串 std::string buffer; bool ok = person.SerializeToString(&buffer); if (!ok) { std::cerr << "serialize failed" << std::endl; return -1; } std::cout << "serialized size: " << buffer.size() << " bytes" << std::endl; // 2. 从内存字符串反序列化 demo::Person other; if (!other.ParseFromString(buffer)) { std::cerr << "parse failed" << std::endl; return -1; } // 3. 读取结果并输出验证 std::cout << "parsed name: " << other.name() << std::endl; std::cout << "parsed id: " << other.id() << std::endl; for (int i = 0; i < other.emails_size(); ++i) { std::cout << "email[" << i << "]: " << other.emails(i) << std::endl; } return 0; }两个方法都返回bool,一定要检查返回值。序列化失败通常是因为required字段未设置,或者是嵌套消息存在循环引用;解析失败通常是buffer内容损坏或版本不匹配。很多时候bug就来自于调用后不检查返回值,直接把序列化结果发出去,接收方解析时白白报错。
4.3 文件读写:SerializeToOstream 与 ParseFromIstream
如果要把消息持久化到文件,直接用SerializeToOstream和ParseFromIstream最省事:
std::ofstream out("person.bin", std::ios::binary); if (!person.SerializeToOstream(&out)) { std::cerr << "write file failed" << std::endl; return -1; } out.close(); demo::Person loaded; std::ifstream in("person.bin", std::ios::binary); if (!loaded.ParseFromIstream(&in)) { std::cerr << "read file failed" << std::endl; return -1; } in.close();注意一定要加std::ios::binary,否则Windows平台下会把\n转成\r\n,导致二进制内容被破坏。这个坑我在写调试工具时踩过,当时查了半天才发现是文件打开模式的问题。
4.4 实测序列化结果与体积观察
在我的机器上跑上面的示例,输出大概是:
serialized size: 48 bytes parsed name: Alice parsed id: 10001 email[0]: alice@example.com email[1]: support@example.com你完全没法从这48字节里直接看出字段内容,但程序之间传递毫无压力。对比一下,如果同样的数据用JSON表示,差不多要150字节左右。这还只是单条数据,如果每秒传几千条,差距就非常可观了。
另外,序列化结果也可以先用SerializeToArray放到固定字节数组里,用于自定义网络协议封装。这个接口和SerializeToString类似,但更贴近底层缓冲区操作,适合在高性能网络模块里使用。
5. 我踩过的几个protobuf相关坑
5.1 LNK2038 与运行库不一致
这个坑在编译阶段最容易遇到。症状是链接时报一堆LNK2038,提示RuntimeLibrary不匹配,甚至还有指针宽度不一致的报错。原因就是我前面说的:编译protobuf的CRT运行库设置和当前项目不一致。
判断方法很简单,确认两件事:protobuf编译时protobuf_MSVC_STATIC_RUNTIME的值,以及项目属性里“C/C++ -> 代码生成 -> 运行库”的选项。要么都是/MD,要么都是/MT。我当时默认项目用的是/MD,而protobuf编译时默认是/MT,一链接就炸。后来重编protobuf改成/MD后,问题彻底消失。
5.2 protoc版本与lib版本不一致导致运行时崩溃
这个坑比LNK2038更隐蔽,它的特征是:编译链接全通过,运行时才崩溃或报错。比如你用新版本protoc生成的代码,链接到老版本libprotobuf,很有可能会在初始化阶段抛异常,或者反序列化时出现“未知字段”之类的错误。
protobuf生成的代码和运行库之间有严格的版本匹配关系,版本不一致时,代码结构定义和运行时实现可能不兼容。我的做法是:protoc.exe和libprotobuf.dll/lib文件全部从同一个编译产物目录获取,确保版本完全一致;同时把.proto文件和生成代码的脚本放在项目里统一管理,别人接手时不会搞混。
5.3 proto3中判断字段是否被赋值的麻烦
如果你用了proto3,就需要接受一个现实:没有has_xxx方法。想判断一个字段是否被设置,基本只能靠if (msg.name().empty())或者if (msg.id() != 0)这类默认值比较。
但这样判断有歧义:如果发送方真的想传一个空字符串,接收方就分不清是“没传”还是“传了空值”。一旦业务上需要区分这两种情况,设计阶段就要想清楚。一种方案是把这类字段改成message类型包一层,另一种是用proto2的optional。这个选择直接影响后续代码复杂度,别等到联调时才发现问题。
5.4 set_allocated_xxx的所有权转移问题
最后讲一个C++环境特有的坑。protobuf为了方便高效赋值,提供了set_allocated_xxx()这类接口,它把指针的所有权转移给消息对象,之后消息对象负责释放。
代码上看起来很美,但实战中很容易出错。比如你写:
std::string* name = new std::string("Alice"); person.set_allocated_name(name); // 如果不小心在别处 delete 了 name,后面析构时就 double free解决办法很简单:只在明确知道所有权的情况下使用这种接口,否则一律用set_name()。我的经验是,99%的业务代码不需要用set_allocated_xxx,它能带来的性能提升微乎其微,风险却很大。
还有一个相关的小经验:如果消息中嵌套了message类型的字段,不先用mutable_sub_msg()拿到子消息指针就直接调用set_allocated_sub_msg(),很容易造成野指针。所有带allocated的接口,都要在注释里写明所有权归属,避免后续维护的人踩雷。
从我实际项目经验看,protobuf真正难的不是API使用,而是版本管理和构建配置的统一。如果你打算在VS2019上长期用它,最好把编译好的库、protoc.exe和.proto文件全部收敛到一个固定的目录结构里,并把版本号写进目录名,避免不同proto和库跨版本混用。
另外一个建议是:把生成代码的操作固化成一个batch脚本,输入.proto文件路径,输出指定目录的.pb.h和.pb.cc。这样每次修改消息结构后双击脚本就能重新生成,换电脑、换同事后整个流程也能快速恢复。等这一套跑顺了,protobuf带来的收益就会很明显——多语言对接省心,序列化体积小,传输效率高,关键是一份proto定义就锁定了所有客户端的字段约定。
本文还有配套的精品资源,点击获取