protobuf 使用详解
一、protobuf 简介
1、什么是 protobuf
Protocol Buffers,简称 protobuf,是 Google 提供的一种结构化数据序列化机制。
简单来说,protobuf 的作用就是:序列化:把一个 C++ 对象转换成一段二进制数据
反序列化:把一段二进制数据重新恢复成 C++ 对象
protobuf 本身是语言无关、平台无关、可扩展的序列化数据格式,既可以用于网络通信协议,也可以用于本地数据存储。相比 XML、JSON 这类文本格式,protobuf 通常具有体积更小、解析速度更快、结构更明确的特点。
2、为什么需要 protobuf
在网络通信中,客户端和服务器之间传输的本质都是字节流
例如,一个登录请求可能包含:
cpp
user_name = "zhangsan"
password = "123456"
但是在网络中传输时,它不可能直接以 C++ 对象的形式发送,而是要转换成一段字节数据。接收端收到这段字节后,还要知道:
-
这段数据表示什么消息
-
每个字段是什么意思
-
每个字段是什么类型
-
如何从字节流中还原出对象
这就需要一种统一的协议格式
常见的序列化方式有 XML、JSON、protobuf:
text
XML:文本格式,可读性强,但是冗余较多,体积较大。
JSON:文本格式,使用简单,调试方便,常用于 Web 接口。
protobuf:二进制格式,体积小,解析快,适合高性能网络通信。
如果是公网 HTTP 接口,JSON 使用起来很方便;如果是服务器之间通信、RPC 调用、IM 即时通讯、游戏服务器、分布式服务内部通信等场景,protobuf 会更合适
3、protobuf 的基本工作流程
protobuf 的使用流程可以概括为:
text
编写 .proto 文件
↓
使用 protoc 编译生成 .pb.h 和 .pb.cc
↓
C++ 程序引入生成的头文件
↓
调用 set_xxx() 设置字段
↓
调用 SerializeToString / SerializeToOstream 序列化
↓
调用 ParseFromString / ParseFromIstream 反序列化
对于 C++ 来说,protobuf 编译器会根据 .proto 文件生成两个文件:
text
xxx.pb.h
xxx.pb.cc
其中:
text
xxx.pb.h:声明消息类、成员函数、枚举、访问接口等。
xxx.pb.cc:实现具体的序列化、反序列化、字段访问等逻辑。
开发者只需要关心 .proto 中定义的数据结构,以及生成类的使用方式,不需要自己手动编写二进制编码和解码代码
二、protobuf 基本语法
1、syntax 语法版本
.proto 文件的第一行通常是:
protobuf
syntax = "proto3";
这表示当前文件使用 proto3 语法
如果不写这一行,编译器可能会按照 proto2 的语法处理。proto2 和 proto3 在字段规则、默认值、required/optional 等方面存在差异,因此建议明确写出:
protobuf
syntax = "proto3";
2、message 消息类型
message 类似于 C++ 中的 class 或 struct,用来定义一种数据结构
例如:
protobuf
syntax = "proto3";
// 一个搜索请求类
message SearchRequest {
string query = 1; // 查询字符串
int32 page_number = 2; // 页数
int32 result_per_page = 3; // 每页结果数量
}
这个 SearchRequest 消息中有三个字段:
text
query:查询字符串
page_number:页码
result_per_page:每页结果数量
生成 C++ 代码后,就会有一个对应的 C++ 类,可以通过成员函数访问字段
例如:
cpp
SearchRequest request;
request.set_query("protobuf");
request.set_page_number(1);
request.set_result_per_page(10);
3、字段编号
protobuf 中每个字段后面都有一个编号:
protobuf
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
这里的 1、2、3 不是随便写的,它们是字段在二进制数据中的唯一标识
protobuf 序列化后不会保存字段名,例如不会保存 "query"、"page_number" 这些字符串,而是保存字段编号和字段值
这是protobuf和xml/json的一个很大区别,因为后面二者都是通过这样的key-value来保存的
这样做的好处是:
text
减少传输数据大小;
提高解析效率;
方便后续协议升级
字段编号一旦使用,就不应该随意修改。因为旧程序和新程序都是依靠字段编号来识别数据的。如果把原来的字段编号改掉,就可能导致新旧版本之间无法正确解析
一般建议:
text
1 ~ 15:适合留给高频字段,因为编码后占用空间更小。
16 之后:可以留给普通字段或后续扩展字段。
4、常见标量类型
protobuf 中常见的标量和对应到 C++ 中的关系大致如下:
text
double -> double
float -> float
int32 -> int32_t // 变长类型,且编码负数时效率很低
int64 -> int64_t
sint32 -> int32_t // 编码负数效率高
sint64 -> int64_t
uint32 -> uint32_t
uint64 -> uint64_t
bool -> bool
string -> std::string
bytes -> std::string
需要注意的是:
text
string:要求是 UTF-8 或 ASCII 文本。
bytes:表示任意字节数据,可以存储二进制内容。
5、repeated 重复字段
如果一个字段可能出现多次,可以使用 repeated,对应到c++中就是数组
例如,一个人可能有多个电话号码:
protobuf
message Person {
string name = 1;
repeated string phone = 2; // 定义了一个数组类型,vector<string>
}
生成 C++ 代码后,可以这样使用:
cpp
Person person;
person.set_name("zhangsan");
person.add_phone("13800000000");
person.add_phone("13900000000");
for (int i = 0; i < person.phone_size(); ++i) {
std::cout << person.phone(i) << std::endl;
}
常用接口包括:
cpp
add_xxx(); // 添加一个 repeated 元素
xxx_size(); // 获取 repeated 元素个数
xxx(index); // 获取指定下标元素
mutable_xxx(); // 获取可修改对象
6、enum 枚举类型
protobuf 支持枚举类型,例如:
protobuf
message Person {
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
string name = 1;
PhoneType type = 2;
}
proto3 中,枚举的第一个值必须是 0
这是因为 protobuf 中字段如果没有被设置,会使用默认值。对于枚举来说,默认值就是第一个枚举项,所以第一个枚举值必须从 0 开始
7、嵌套 message
一个 message 内部可以继续定义 message,也就是类中嵌套定义类
例如:
protobuf
message Person {
string name = 1;
message PhoneNumber {
string number = 1;
string type = 2;
}
repeated PhoneNumber phones = 2;
}
这表示 PhoneNumber 是 Person 内部定义的消息类型。
C++ 使用时,嵌套类型的名字通常是:
cpp
Person::PhoneNumber
例如:
cpp
Person person;
Person::PhoneNumber* phone = person.add_phones();
phone->set_number("13800000000");
8、package 包名
.proto 文件中可以使用 package 防止命名冲突:
protobuf
package tutorial;
对于 C++ 来说,package 会生成对应的命名空间
例如:
protobuf
package tutorial;
message Person {
string name = 1;
}
在 C++ 中使用时就是:
cpp
tutorial::Person person;
9、import 引入其他 proto 文件
protobuf 支持引入其他 .proto 文件:
protobuf
import "google/protobuf/timestamp.proto";
引入后,就可以使用其中定义好的消息类型:
protobuf
google.protobuf.Timestamp last_updated = 5;
这在工程中很常见,例如时间戳、Any 类型、空消息等,都可以直接复用 protobuf 官方提供的 .proto 文件
10、option 选项
option 可以影响代码生成方式。
例如:
protobuf
option java_package = "com.example.tutorial";
option java_outer_classname = "AddressBookProtos";
option csharp_namespace = "Google.Protobuf.Examples.AddressBook";
这些选项主要影响对应语言生成代码的包名、类名、命名空间等
对于 C++ 来说,更常见的是:
protobuf
option optimize_for = SPEED;
protobuf 支持三种常见优化模式:
text
SPEED:默认选项,生成代码执行效率高,但是代码体积较大。
CODE_SIZE:生成代码体积更小,但是运行效率相对低。
LITE_RUNTIME:生成代码体积小,运行效率也较高,但是会牺牲反射功能,需要链接 libprotobuf-lite。
在服务器开发中,如果没有特殊限制,通常保持默认的 SPEED 即可
三、protobuf 编译和使用
1、安装 protobuf
Linux 下源码安装的一般流程如下:
bash
tar zxf protobuf-cpp-3.19.6.tar.gz
cd protobuf-3.19.6
./configure
make
sudo make install
sudo ldconfig
安装完成后,查看版本:
bash
protoc --version
如果能够正常输出版本号,说明 protoc 工具已经可以使用
2、编写 proto 文件
假设有一个文件:
text
addressbook.proto
内容如下:
protobuf
syntax = "proto3";
package tutorial;
import "google/protobuf/timestamp.proto";
message Person {
string name = 1;
int32 id = 2;
string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
string number = 1;
PhoneType type = 2;
}
repeated PhoneNumber phones = 4;
google.protobuf.Timestamp last_updated = 5;
}
message AddressBook {
repeated Person people = 1;
}
这个文件定义了一个电话簿结构:
text
Person:表示一个联系人
PhoneNumber:表示联系人电话
PhoneType:表示电话类型
AddressBook:表示整个电话簿,里面可以保存多个联系人
3、生成 C++ 代码
使用 protoc 生成 C++ 文件:
-I表示proto文件所在地址,--cpp_out表示要输出的.h和.cc文件的地址
bash
protoc -I=./ --cpp_out=./ addressbook.proto
如果当前目录下有多个 .proto 文件,也可以:
bash
protoc -I=./ --cpp_out=./ *.proto
执行后会生成:
text
addressbook.pb.h
addressbook.pb.cc
4、编译 C++ 程序
假设有两个程序:
text
add_person.cc:向电话簿中添加联系人
list_people.cc:读取电话簿并打印联系人信息
编译命令如下:
bash
g++ -std=c++11 -o add_person add_person.cc addressbook.pb.cc -lprotobuf -lpthread
g++ -std=c++11 -o list_people list_people.cc addressbook.pb.cc -lprotobuf -lpthread
四、protobuf 在 C++ 中的常用接口
1、设置普通字段
对于如下字段:
protobuf
string name = 1;
int32 id = 2;
string email = 3;
生成的c++文件中会为每个字段生成对应的set_xxx函数
cpp
tutorial::Person person;
person.set_name("zhangsan");
person.set_id(1001);
person.set_email("zhangsan@example.com");
读取字段,生成的类中有一个和字段同名的函数用于访问:
cpp
std::cout << person.name() << std::endl;
std::cout << person.id() << std::endl;
std::cout << person.email() << std::endl;
2、mutable_xxx 获取可修改对象
对于 string 类型,可以使用:
cpp
getline(cin, *person->mutable_name());
mutable_name() 返回的是字段内部字符串的指针,可以直接修改内部内容。
例如:
cpp
tutorial::Person person;
*person.mutable_name() = "lisi";
这和下面的写法效果类似:
cpp
person.set_name("lisi");
3、添加 repeated 的字段
对于 repeated 字段:
protobuf
repeated PhoneNumber phones = 4;
可以使用:
cpp
tutorial::Person::PhoneNumber* phone_number = person.add_phones();
phone_number->set_number("13800000000");
phone_number->set_type(tutorial::Person::MOBILE);
add_phones() 会新建一个 PhoneNumber 对象,并返回它的指针
4、遍历 repeated 字段
读取 repeated 字段时,可以使用:
cpp
for (int i = 0; i < person.phones_size(); ++i) {
const tutorial::Person::PhoneNumber& phone_number = person.phones(i);
std::cout << phone_number.number() << std::endl;
}
其中phones_size()表示电话数量,phones(i)表示获取第 i 个电话对象
5、判断字段是否存在
对于 message 类型字段,例如:
protobuf
google.protobuf.Timestamp last_updated = 5;
可以使用:
cpp
if (person.has_last_updated()) {
std::cout << TimeUtil::ToString(person.last_updated()) << std::endl;
}
需要注意:proto3 中普通标量字段默认没有 has_xxx(),因为标量字段如果没有设置,会直接使用默认值
例如:
text
string 默认值是空字符串;
bool 默认值是 false;
数值类型默认值是 0;
repeated 默认是空列表;
enum 默认是第一个枚举值
6、序列化和反序列化
把对象写入文件:
cpp
fstream output("addressbook.data", ios::out | ios::trunc | ios::binary);
if (!address_book.SerializeToOstream(&output)) {
cerr << "Failed to write address book." << endl;
}
这里使用的是SerializeToOstream(),它会把 protobuf 对象序列化成二进制数据,然后写入输出流
从文件读取对象:
cpp
fstream input("addressbook.data", ios::in | ios::binary);
if (!address_book.ParseFromIstream(&input)) {
cerr << "Failed to parse address book." << endl;
}
这里使用的是ParseFromIstream(),它会从输入流中读取二进制数据,并解析成 protobuf 对象
7、序列化到字符串
网络通信中更常用的是序列化到字符串:
cpp
std::string data;
address_book.SerializeToString(&data);
反序列化:
cpp
tutorial::AddressBook address_book;
address_book.ParseFromString(data);
这里的 std::string 不一定是文本字符串,它可以存储任意二进制数据
五、protobuf 编码原理
1、protobuf 为什么比 JSON 更小
JSON 是文本格式,字段名会出现在数据中
例如:
json
{
"name": "zhangsan",
"id": 1001,
"email": "zhangsan@example.com"
}
这里的 "name"、"id"、"email" 都会被传输
protobuf 不会直接传字段名,而是传字段编号
例如:
protobuf
string name = 1;
int32 id = 2;
string email = 3;
序列化后,protobuf 只需要记录字段编号 + 字段类型 + 字段值,这就是 protobuf 数据更紧凑的重要原因
2、wire type
protobuf 序列化后的数据不是简单地把字段值拼接起来,它还会保存字段的 wire type
常见 wire type 包括:
text
0:Varint,用于 int32、int64、uint32、uint64、sint32、sint64、bool、enum
1:64-bit,用于 fixed64、sfixed64、double
2:Length-delimited,用于 string、bytes、嵌套 message、packed repeated
5:32-bit,用于 fixed32、sfixed32、float
字段的 key 由字段编号和 wire type 组成:
text
field_num << 3 | wire_type
例如int32 age = 1;,如果 age 的值为 5,字段编号是 1,wire type 是 0,那么 key 就是1 << 3 | 0 = 8
所以序列化时会先写入字段 key,再写入字段值
这也是为什么接收端必须有 .proto 文件,因为 protobuf 二进制本身不是完全自描述的。它不保存字段名,接收端需要根据 .proto 中的字段编号和类型来解释数据
3、Varints 变长编码
普通 C++ 中,一个 int32_t 固定占 4 个字节
例如:
cpp
int32_t a = 1;
int32_t b = 100000;
虽然 1 很小,但它依然占 4 个字节
Varints 的思想是:
text
数值小,就少占字节;
数值大,才多占字节。
Varints 使用每个字节的最高位作为标志位:
text
最高位为 1:后面还有字节;
最高位为 0:当前字节是最后一个字节;
剩下 7 位:存储有效数据。
所以一个较小的整数可能只需要 1 个字节,而不是固定 4 个字节,这就是 protobuf 对小整数非常友好的原因
4、为什么负数不建议直接用 int32
Varints 对正数很友好,但是对负数不友好
原因是负数在计算机中使用补码表示,高位通常都是 1。Varints 的本质是尽量去掉高位多余的 0,但是负数高位是 1,就无法很好地压缩
因此,如果使用 int32 表示负数,protobuf 内部可能会把它当成一个很大的无符号数来处理,导致编码后占用较多字节
5、ZigZag 编码
为了解决负数编码效率低的问题,protobuf 提供了:
protobuf
sint32
sint64
它们会使用 ZigZag 编码
ZigZag 的核心思想是:
text
把有符号整数映射成无符号整数
让绝对值小的负数也能变成较小的正数
再使用 Varints 编码
例如:
text
0 -> 0
-1 -> 1
1 -> 2
-2 -> 3
2 -> 4
这样 -1、-2 这类小负数也可以被编码成很小的整数,再配合 Varints 就能减少空间占用
所以:
text
如果字段不会出现负数,可以使用 int32、uint32
如果字段可能出现负数,并且数值通常不大,建议使用 sint32、sint64
如果数值通常很大,接近 32 位或 64 位上限,可以考虑 fixed32、fixed64
6、Length-delimited 编码
对于下面这些类型:
text
string
bytes
嵌套 message
packed repeated
protobuf 会使用 Length-delimited 编码
它的结构大致是:
text
字段 key + 数据长度 + 数据内容
例如:
protobuf
string name = 1;
如果 name 是 "abc",那么 protobuf 需要知道这个字符串有多长,所以会先写入长度,再写入具体内容
六、使用示例:电话簿小程序
下面通过一个电话簿程序完整演示 protobuf 在 C++ 中的使用
这个程序包含三个部分:
text
addressbook.proto:定义电话簿协议结构
add_person.cc:添加联系人并保存到文件
list_people.cc:读取文件并打印联系人
1、定义 addressbook.proto
protobuf
syntax = "proto3";
package tutorial;
import "google/protobuf/timestamp.proto";
message Person {
string name = 1;
int32 id = 2;
string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
string number = 1;
PhoneType type = 2;
}
repeated PhoneNumber phones = 4;
google.protobuf.Timestamp last_updated = 5;
}
message AddressBook {
repeated Person people = 1;
}
这里定义了两个主要 message:
text
Person:单个联系人
AddressBook:电话簿,内部保存多个 Person
Person 中包含:
text
name:姓名
id:联系人 ID
email:邮箱
phones:多个电话号码
last_updated:最后更新时间
2、生成 C++ 文件
执行:
bash
protoc -I=./ --cpp_out=./ addressbook.proto
生成:
text
addressbook.pb.h
addressbook.pb.cc
然后 C++ 代码中引入:
cpp
#include "addressbook.pb.h"
3、添加联系人程序 add_person.cc
核心逻辑如下:
cpp
void PromptForAddress(tutorial::Person* person) {
cout << "Enter person ID number: ";
int id;
cin >> id;
person->set_id(id);
cin.ignore(256, '\n');
cout << "Enter name: ";
getline(cin, *person->mutable_name());
cout << "Enter email address (blank for none): ";
string email;
getline(cin, email);
if (!email.empty()) {
person->set_email(email);
}
while (true) {
cout << "Enter a phone number (or leave blank to finish): ";
string number;
getline(cin, number);
if (number.empty()) {
break;
}
tutorial::Person::PhoneNumber* phone_number = person->add_phones();
phone_number->set_number(number);
cout << "Is this a mobile, home, or work phone? ";
string type;
getline(cin, type);
if (type == "mobile") {
phone_number->set_type(tutorial::Person::MOBILE);
} else if (type == "home") {
phone_number->set_type(tutorial::Person::HOME);
} else if (type == "work") {
phone_number->set_type(tutorial::Person::WORK);
} else {
cout << "Unknown phone type. Using default." << endl;
}
}
*person->mutable_last_updated() = TimeUtil::SecondsToTimestamp(time(NULL));
}
主函数逻辑如下:
cpp
int main(int argc, char* argv[]) {
GOOGLE_PROTOBUF_VERIFY_VERSION;
if (argc != 2) {
cerr << "Usage: " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
return -1;
}
tutorial::AddressBook address_book;
{
fstream input(argv[1], ios::in | ios::binary);
if (!input) {
cout << argv[1] << ": File not found. Creating a new file." << endl;
} else if (!address_book.ParseFromIstream(&input)) {
cerr << "Failed to parse address book." << endl;
return -1;
}
}
PromptForAddress(address_book.add_people());
{
fstream output(argv[1], ios::out | ios::trunc | ios::binary);
if (!address_book.SerializeToOstream(&output)) {
cerr << "Failed to write address book." << endl;
return -1;
}
}
google::protobuf::ShutdownProtobufLibrary();
return 0;
}
流程是:
text
1、打开电话簿文件
2、如果文件存在,就先反序列化读取已有联系人
3、调用 add_people() 新增一个 Person
4、填写联系人信息
5、重新序列化写回文件
其中address_book.ParseFromIstream(&input)表示从文件反序列化,address_book.SerializeToOstream(&output)表示序列化写入文件
4、查看联系人程序 list_people.cc
打印电话簿的核心逻辑如下:
cpp
void ListPeople(const tutorial::AddressBook& address_book) {
for (int i = 0; i < address_book.people_size(); i++) {
const tutorial::Person& person = address_book.people(i);
cout << "Person ID: " << person.id() << endl;
cout << " Name: " << person.name() << endl;
if (person.email() != "") {
cout << " E-mail address: " << person.email() << endl;
}
for (int j = 0; j < person.phones_size(); j++) {
const tutorial::Person::PhoneNumber& phone_number = person.phones(j);
switch (phone_number.type()) {
case tutorial::Person::MOBILE:
cout << " Mobile phone #: ";
break;
case tutorial::Person::HOME:
cout << " Home phone #: ";
break;
case tutorial::Person::WORK:
cout << " Work phone #: ";
break;
default:
cout << " Unknown phone #: ";
break;
}
cout << phone_number.number() << endl;
}
if (person.has_last_updated()) {
cout << " Updated: " << TimeUtil::ToString(person.last_updated()) << endl;
}
}
}
主函数逻辑如下:
cpp
int main(int argc, char* argv[]) {
GOOGLE_PROTOBUF_VERIFY_VERSION;
if (argc != 2) {
cerr << "Usage: " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
return -1;
}
tutorial::AddressBook address_book;
{
fstream input(argv[1], ios::in | ios::binary);
if (!address_book.ParseFromIstream(&input)) {
cerr << "Failed to parse address book." << endl;
return -1;
}
}
ListPeople(address_book);
google::protobuf::ShutdownProtobufLibrary();
return 0;
}
流程是:
text
1、打开电话簿文件
2、使用 ParseFromIstream 反序列化
3、遍历 AddressBook 中的 people
4、打印每个联系人的信息
5、编译运行
生成 C++ 文件:
bash
protoc -I=./ --cpp_out=./ addressbook.proto
编译添加联系人程序:
bash
g++ -std=c++11 -o add_person add_person.cc addressbook.pb.cc -lprotobuf -lpthread
编译查看联系人程序:
bash
g++ -std=c++11 -o list_people list_people.cc addressbook.pb.cc -lprotobuf -lpthread
添加联系人:
bash
./add_person addressbook.data
示例输入:
text
Enter person ID number: 1
Enter name: zhangsan
Enter email address (blank for none): zhangsan@example.com
Enter a phone number (or leave blank to finish): 13800000000
Is this a mobile, home, or work phone? mobile
Enter a phone number (or leave blank to finish):
查看联系人:
bash
./list_people addressbook.data
可能输出:
text
Person ID: 1
Name: zhangsan
E-mail address: zhangsan@example.com
Mobile phone #: 13800000000
Updated: 2026-05-02T08:00:00Z