📚 系列:C++ 工程实践 第 05 篇 ⏱ 阅读约 9 分钟
📌 一句话总结
本文用「写 .proto → 生成代码 → 读写消息 → 定协议」四步讲透 protobuf,读完后你能:① 10 分钟跑通 C++ 序列化;② 说清字段编号与兼容性铁律;③ 用 Makefile 自动生成代码,而不是手工拷 .pb.cc。
文章目录
-
- [1. 为什么用 protobuf:二进制协议的三个收益](#1. 为什么用 protobuf:二进制协议的三个收益)
- [2. 安装与第一个 .proto:10 分钟跑通](#2. 安装与第一个 .proto:10 分钟跑通)
- [3. .proto 语法速查:字段、编号与容器](#3. .proto 语法速查:字段、编号与容器)
- [4. C++ 侧:序列化、反序列化与大小](#4. C++ 侧:序列化、反序列化与大小)
- [5. 兼容性铁律:协议一旦发出就不能随便改](#5. 兼容性铁律:协议一旦发出就不能随便改)
- [6. Makefile 集成与工程落地](#6. Makefile 集成与工程落地)
- [7. 踩坑与总结](#7. 踩坑与总结)
1. 为什么用 protobuf:二进制协议的三个收益
先说结论:protobuf 用「字段编号 + 二进制编码」换来了更小的体积、更快的解析和一份强制的数据契约,代价是失去「打开就能读」。
和 JSON 放在一起对比最直观:
| 维度 | JSON | protobuf |
|---|---|---|
| 可读性 | 人直接能读 | 二进制,需要工具解码 |
| 体积 | 字段名重复传输 | 只传编号和值,通常小 30%~70% |
| 解析速度 | 中等 | 快,按编号直接定位字段 |
| 契约 | 靠文档和约定 | .proto 文件即契约,还能生成代码 |
| 跨语言 | 好 | 官方支持 C++/Java/Go/Python 等 |
| 调试 | 直接看 | protoc --decode 或转 JSON |
它的核心思路其实就两条:字段名不参与传输,字段编号才是身份 ;每个值按 wire type 选择最省空间的编码 (整数用 varint,字符串用长度前缀)。所以同样一份用户数据,JSON 里要重复写 "name"、"age",protobuf 里只传 0x0A、0x10 这些编号。
⚠️ 什么时候别用
配置文件、对外公开的 API、需要人工编辑的数据,别用 protobuf------二进制不可读,排查问题会多一层解码成本。它适合服务之间高频传输、字段稳定的结构化数据。
2. 安装与第一个 .proto:10 分钟跑通
先说结论:装 protoc、写 .proto、生成 .pb.cc/.pb.h、写十行 C++,就能看到二进制序列化跑通。
| 系统 / 方式 | 命令 |
|---|---|
| Ubuntu / Debian | sudo apt install protobuf-compiler libprotobuf-dev |
| macOS | brew install protobuf |
| Windows | vcpkg install protobuf |
| 源码 | 下载 release 包,按 README 编译安装 |
装完确认版本,protoc 和运行库要同版本:
bash
protoc --version # 输出 libprotoc 3.x.y
dpkg -l | grep protobuf # 确认 libprotobuf-dev 已装
定义第一个消息,person.proto:
proto
syntax = "proto3";
package demo;
message Person {
string name = 1;
int32 age = 2;
repeated string tags = 3;
map<string, int32> scores = 4;
}
生成 C++ 代码:
bash
protoc --cpp_out=. person.proto # 得到 person.pb.h 和 person.pb.cc
然后用它读写:
cpp
// main.cc
#include <iostream>
#include <string>
#include "person.pb.h"
int main() {
demo::Person p;
p.set_name("alice");
p.set_age(18);
p.add_tags("cpp");
p.add_tags("protobuf");
(*p.mutable_scores())["math"] = 90; // map 字段:先拿指针再写
std::string bytes;
if (!p.SerializeToString(&bytes)) {
std::cerr << "serialize failed" << std::endl;
return 1;
}
std::cout << "bytes = " << bytes.size() << std::endl;
demo::Person q;
if (!q.ParseFromString(bytes)) {
std::cerr << "parse failed" << std::endl;
return 1;
}
std::cout << q.name() << " age=" << q.age()
<< " tags=" << q.tags_size() << std::endl;
return 0;
}
编译运行:
bash
g++ -std=c++17 main.cc person.pb.cc -o demo -lprotobuf
./demo
输出:
text
bytes = 34
alice age=18 tags=2
生成代码的链路只有一条:
#mermaid-svg-GBzOo7yXjXnCUjlK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GBzOo7yXjXnCUjlK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GBzOo7yXjXnCUjlK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GBzOo7yXjXnCUjlK .error-icon{fill:#552222;}#mermaid-svg-GBzOo7yXjXnCUjlK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GBzOo7yXjXnCUjlK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GBzOo7yXjXnCUjlK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GBzOo7yXjXnCUjlK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GBzOo7yXjXnCUjlK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GBzOo7yXjXnCUjlK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GBzOo7yXjXnCUjlK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GBzOo7yXjXnCUjlK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GBzOo7yXjXnCUjlK .marker.cross{stroke:#333333;}#mermaid-svg-GBzOo7yXjXnCUjlK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GBzOo7yXjXnCUjlK p{margin:0;}#mermaid-svg-GBzOo7yXjXnCUjlK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-GBzOo7yXjXnCUjlK .cluster-label text{fill:#333;}#mermaid-svg-GBzOo7yXjXnCUjlK .cluster-label span{color:#333;}#mermaid-svg-GBzOo7yXjXnCUjlK .cluster-label span p{background-color:transparent;}#mermaid-svg-GBzOo7yXjXnCUjlK .label text,#mermaid-svg-GBzOo7yXjXnCUjlK span{fill:#333;color:#333;}#mermaid-svg-GBzOo7yXjXnCUjlK .node rect,#mermaid-svg-GBzOo7yXjXnCUjlK .node circle,#mermaid-svg-GBzOo7yXjXnCUjlK .node ellipse,#mermaid-svg-GBzOo7yXjXnCUjlK .node polygon,#mermaid-svg-GBzOo7yXjXnCUjlK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-GBzOo7yXjXnCUjlK .rough-node .label text,#mermaid-svg-GBzOo7yXjXnCUjlK .node .label text,#mermaid-svg-GBzOo7yXjXnCUjlK .image-shape .label,#mermaid-svg-GBzOo7yXjXnCUjlK .icon-shape .label{text-anchor:middle;}#mermaid-svg-GBzOo7yXjXnCUjlK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-GBzOo7yXjXnCUjlK .rough-node .label,#mermaid-svg-GBzOo7yXjXnCUjlK .node .label,#mermaid-svg-GBzOo7yXjXnCUjlK .image-shape .label,#mermaid-svg-GBzOo7yXjXnCUjlK .icon-shape .label{text-align:center;}#mermaid-svg-GBzOo7yXjXnCUjlK .node.clickable{cursor:pointer;}#mermaid-svg-GBzOo7yXjXnCUjlK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-GBzOo7yXjXnCUjlK .arrowheadPath{fill:#333333;}#mermaid-svg-GBzOo7yXjXnCUjlK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-GBzOo7yXjXnCUjlK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-GBzOo7yXjXnCUjlK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GBzOo7yXjXnCUjlK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-GBzOo7yXjXnCUjlK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GBzOo7yXjXnCUjlK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-GBzOo7yXjXnCUjlK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-GBzOo7yXjXnCUjlK .cluster text{fill:#333;}#mermaid-svg-GBzOo7yXjXnCUjlK .cluster span{color:#333;}#mermaid-svg-GBzOo7yXjXnCUjlK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-GBzOo7yXjXnCUjlK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-GBzOo7yXjXnCUjlK rect.text{fill:none;stroke-width:0;}#mermaid-svg-GBzOo7yXjXnCUjlK .icon-shape,#mermaid-svg-GBzOo7yXjXnCUjlK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GBzOo7yXjXnCUjlK .icon-shape p,#mermaid-svg-GBzOo7yXjXnCUjlK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-GBzOo7yXjXnCUjlK .icon-shape .label rect,#mermaid-svg-GBzOo7yXjXnCUjlK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GBzOo7yXjXnCUjlK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-GBzOo7yXjXnCUjlK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-GBzOo7yXjXnCUjlK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} person.proto
protoc
person.pb.h / .pb.cc
业务代码
二进制 bytes
对端 ParseFromString
图 1:从 .proto 到二进制字节的生成与序列化链路
3. .proto 语法速查:字段、编号与容器
先说结论:字段编号决定兼容性,字段类型决定编码方式;这两件事写错,后面的代码再对也没用。
常用标量类型:
| proto 类型 | C++ 类型 | 说明 |
|---|---|---|
int32 / int64 |
int32_t / int64_t |
负数编码效率低,负数多考虑 sint |
sint32 / sint64 |
同上 | 用 zigzag 编码,负数更省空间 |
uint32 / uint64 |
uint32_t / uint64_t |
无符号整数 |
double / float |
double / float |
定长 8 / 4 字节 |
bool |
bool |
varint 编码 |
string |
std::string |
UTF-8 文本 |
bytes |
std::string |
任意二进制 |
容器与特殊字段:
proto
message Demo {
repeated int32 ids = 1; // 数组,保序
map<string, int32> scores = 2; // 字典,遍历顺序不保证
optional string email = 3; // 需要区分「未设置」和「空串」时用
oneof payload { // 同一时刻只有一个字段生效
string text = 4;
bytes raw = 5;
}
enum Status { UNKNOWN = 0; OK = 1; FAILED = 2; }
Status status = 6;
}
字段编号有几条硬规则:
| 规则 | 原因 |
|---|---|
用 1~15 放高频字段 |
tag 只占 1 字节,16 之后要多 1 字节 |
| 编号不能重复、不能随便改 | 编号就是字段身份,改了旧数据就解析错位 |
不要用 19000~19999 |
protobuf 内部保留 |
删除字段要 reserved |
防止后来的人复用这个编号 |
ℹ️ proto2 与 proto3
proto3 去掉了 required,标量字段默认没有存在性(值为 0 时无法区分「没设置」);需要存在性就用 optional(protoc 3.15+ 默认支持,更老的版本要加实验开关)。新项目直接用 proto3。
4. C++ 侧:序列化、反序列化与大小
先说结论:主线只有两个函数------SerializeToString 和 ParseFromString,其余都是围绕它们的便利封装。
cpp
demo::Person p;
p.set_name("alice"); // 标量:set_ 前缀
p.add_tags("cpp"); // repeated:add_ 前缀
(*p.mutable_scores())["math"] = 90; // map:mutable_ 前缀,拿指针再用 [] 写
std::string bytes;
p.SerializeToString(&bytes); // 序列化到字符串
p.SerializeToOstream(&std::cout); // 序列化到流(前面要 #include <ostream>)
demo::Person q;
q.ParseFromString(bytes); // 反序列化:会先 Clear 再填充
q.ParseFromIstream(&std::cin); // 从流解析
std::cout << p.ByteSizeLong() << std::endl; // 序列化后字节数,别用 sizeof
几个容易忽略的点:
sizeof(demo::Person)不是数据大小 ,对象里有内部指针和缓存,真实大小用ByteSizeLong()。ParseFromString会覆盖原消息内容,复用同一个对象反复解析是安全的,也比重建更省内存。- 序列化结果不是稳定排序:字段顺序、map 顺序都可能变化,别把二进制字节做哈希当 ID。
需要人读的场景,把消息转成 JSON 或 TextFormat:
cpp
#include <google/protobuf/util/json_util.h>
std::string json;
auto status = google::protobuf::util::MessageToJsonString(p, &json);
if (!status.ok()) {
std::cerr << status.ToString() << std::endl;
}
std::cout << json << std::endl; // 适合日志、调试接口
5. 兼容性铁律:协议一旦发出就不能随便改
先说结论:字段编号不改、类型不换、删除用 reserved,这是 protobuf 能长期演进的全部秘密。
| 操作 | 是否安全 | 说明 |
|---|---|---|
| 新增字段 | ✅ 安全 | 旧代码解析时忽略未知字段,新代码读到默认值 |
| 删除字段 | ✅ 但要 reserved |
保留编号和名字,防止被复用 |
| 改字段编号 | ❌ 禁止 | 等同于把旧字段删了又加一个新字段 |
| 改字段类型 | ❌ 禁止 | wire type 变了,旧数据解析会错乱 |
改 repeated 与单值 |
❌ 禁止 | 编码结构不同 |
新增 oneof 分支 |
⚠️ 谨慎 | 旧代码不认识的 oneof 分支会被忽略 |
删除字段的标准写法:
proto
message User {
reserved 6, 7; // 这两个编号以后不能再用
reserved "old_phone"; // 字段名也一起保留
string name = 1;
int32 age = 2;
}
另外两条经验:
- 给每个字段写注释 ,
.proto是团队共享的契约文件,注释就是接口文档。 - 用
optional表达「有没有设置」 ,用oneof表达「互斥的几种情况」,不要用魔法值(比如age = -1表示未设置)。
6. Makefile 集成与工程落地
先说结论:让 Makefile 管 protoc 生成,.pb.cc / .pb.h 不进版本库,protoc 版本在 CI 里锁死。
目录这样摆:
text
protobuf_demo/
├── Makefile
├── proto/
│ └── person.proto
└── src/
└── main.cc
📄 Makefile · protoc 写成依赖,改 .proto 自动重生成
makefile
PROTO := proto/person.proto
SRCS := src/main.cc person.pb.cc
demo: $(SRCS)
g++ -std=c++17 -I. -o $@ $(SRCS) -lprotobuf
person.pb.cc person.pb.h: $(PROTO)
protoc --cpp_out=. $(PROTO)
clean:
rm -f demo person.pb.cc person.pb.h
.PHONY: clean
make 会先根据依赖生成 .pb.cc / .pb.h,再编译链接;改了 .proto 重新 make 即可。三个落地建议:
.gitignore里加上*.pb.cc/*.pb.h,生成物不入库,避免「手改生成文件」的坏习惯。- protoc 版本写进 CI 镜像或工具链脚本 ,
protoc与libprotobuf大版本必须一致,否则会报This file was generated by a newer version of protoc。 - 和测试框架配合 :消息的编解码最适合用 gtest 做往返测试------
SerializeToString再ParseFromString,断言字段值相等,一条用例覆盖整条链路。
这套「声明式数据结构 + 生成代码 + 往返测试」的组合,是工程化里的标准做法;CMake 的具体写法放在系列里的《CMake 工程化》一期。
7. 踩坑与总结
先说结论:坑集中在版本、存在性、线程安全和「拿 protobuf 当 JSON 用」四类。
| 坑 | 现象 | 正确做法 |
|---|---|---|
| protoc 与库版本不一致 | 编译报 generated by newer version | 锁定同一版本,必要时源码编译 |
| proto3 标量没有存在性 | 0 和「未设置」分不清 | 用 optional 或 wrapper 类型 |
手工改 .pb.cc |
下次生成被覆盖 | 生成物不入库,改 .proto |
| 多线程同时写同一个消息 | 数据错乱或崩溃 | 每线程独立消息,或加锁 |
| 依赖 map 遍历顺序 | 输出顺序每次不同 | map 无序,需要顺序用 repeated |
| 用二进制当缓存 key | 同内容两次序列化字节不同 | 用字段拼接或哈希逻辑字段 |
✅ 最小可用清单
一个 `.proto`、一次 `protoc --cpp_out`、十行 C++、一条 `SerializeToString`/`ParseFromString`,就够验证链路。先把这条链路跑通,再谈兼容性和工程化。
📝 小结
① 先跑通:`protoc` 生成代码,`SerializeToString` / `ParseFromString` 就是全部主线。
② 再定协议:字段编号不改、类型不换、删除用 `reserved`,兼容性靠纪律不靠运气。
③ 后进工程:Makefile 自动生成、生成物不入库、版本锁死,再配一条往返测试。
📦 项目源码
本文示例所在的 C++ 微服务脚手架(点播系统):https://github.com/syagain-zsx/cpp-microservice-scaffold-vod-system
🔜 下一篇
《cpp-httplib 实战:单头文件写 HTTP 服务与客户端》------把 protobuf 消息通过 HTTP 接口暴露出去。关注专栏不迷路。
🔗 参考
1 protobuf 官方文档:https://protobuf.dev/
2 protobuf GitHub:https://github.com/protocolbuffers/protobuf
3 GNU Make 手册:https://www.gnu.org/software/make/manual/