protobuf 实战:从 .proto 到 C++ 序列化的完整链路

📚 系列: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 即可。三个落地建议:

  1. .gitignore 里加上 *.pb.cc / *.pb.h,生成物不入库,避免「手改生成文件」的坏习惯。
  2. protoc 版本写进 CI 镜像或工具链脚本 ,protoc 与 libprotobuf 大版本必须一致,否则会报 This file was generated by a newer version of protoc。
  3. 和测试框架配合 :消息的编解码最适合用 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/

相关推荐
General_G2 小时前
创建型模式—原型(Prototype)模式
c++·设计模式·原型模式
qq_284274052 小时前
电气控制笔记:常用低压电器符号原理与异步电动机点动、自锁、多地控制电路(含考点)
笔记·学习·自动化·制造
welfare9622 小时前
新号别搞:C++_list
开发语言·c++
xiaoye-duck3 小时前
《C++ 项目:手写大模型接入 SDK》云端 API 调用基础与 Apifox 调试实战
网络·c++·语言模型
坤坤子吖3 小时前
Linux 线程同步与互斥——条件变量与线程同步
linux·开发语言·c++·笔记·学习
杜 硕3 小时前
从零开始的C++生活——第二讲——03
c++·生活
凯凯姜3 小时前
Vue 3 深入学习笔记 16:API 请求封装与统一错误处理
vue.js·笔记·学习
小弥儿3 小时前
GitHub今日热榜 | 2026-10-20:智能体基建七席连庄,生成类项目回流
学习·开源·github
问天_观心4 小时前
模型微调学习(二)
开发语言·人工智能·学习·语言模型·github