Protocol Buffers 概况
本文档以 Protobuf Editions(当前最新版本edition = "2023")为基础,系统介绍.proto文件的语法、结构、使用方法及兼容性维护策略。Editions 统一并取代了旧的syntax = "proto2"和syntax = "proto3",通过features提供更加一致且精细的行为控制。
.proto 文件是 Protocol Buffers 的接口定义文件(Interface Definition Language,IDL)。
它用于描述:
- 数据结构(message)
- 数据字段
- 服务接口(service)
- 枚举类型(enum)
- 文件之间依赖关系
通过 protoc 编译器,可以根据 .proto 文件生成 C++、Java、Go、Python 等语言代码。
.proto 文件
├── 1. 长什么样(语法)
│ ├── message、字段、编号、类型、基数
│ └── 枚举、嵌套、导入
├── 2. 怎么组织(结构)
│ ├── package、service、oneof、map、Any
│ └── 扩展(老特性,了解即可)
├── 3. 怎么用(行为)
│ ├── options(生成指令)、JSON转换
│ └── 编译生成代码
└── 4. 怎么改(兼容性)
├── 哪些修改安全 / 不安全
├── 保留字段(reserved)
└── 未知字段、默认值一、长什么样(语法)
文件声明
Editions 文件不再使用 syntax,而是在文件头部声明版本:
edition = "2023";
// 告诉 protoc:“用 2023 年定义的 Protobuf 语言标准来编译这个文件”message、字段、编号、类型、基数
message User {
// 字段规则:类型 名称 = 编号;
int64 id = 1;
string name = 2;
// repeated(数组)
repeated string tags = 4;
// map
map<string, int32> scores = 5;
}message:就是用来定义数据结构(字段名和类型)的模板,作用类似于 C 语言中的struct,不包含任何可执行逻辑。repeated:表示字段可以包含零个或多个值,就像数组(列表)。map是键值对类型的语法糖,内部自动展开成repeated键值消息,用法就像字典(映射)。编号:是字段在二进制格式中的唯一标识(不是字段名),编译后靠它做序列化/反序列化,高频字段应使用 1-15(只占 1 字节),且 19000-19999 为保留编号不可用。
字段存在性(Field Presence)
optional关键字已经被官方移除。如果你在.proto文件里写optional string email = 1;,编译器会直接报错。因此,在 Edition 2023 中,不存在“使用 optional”和“不使用 optional”这两种写法。控制开关只有features.field_presence特性。
features.field_presence 特性决定程序能否区分“字段没传”和“传了默认值(0 / "" / false)”
EXPLICIT(显式):能区分 ➔ 有 has_xxx() 方法(Edition 2023 默认)IMPLICIT(隐式):不能区分 ➔ 无 has_xxx() 方法 (Proto3 旧行为)features.field_presence的作用域:文件级(全局)、消息级(message) 和字段级。features.field_presence的优先级:字段级最高、消息级(message)中等 、文件级(全局)最低。
示例:edition = "2023"; // 1. 文件级:全局默认无 has_ option features.field_presence = IMPLICIT; // 2. 消息级:覆盖文件级,该消息内字段默认有 has_ message User { option features.field_presence = EXPLICIT; string email = 1; // 继承消息级 -> 有 has_email() string phone = 2; // 继承消息级 -> 有 has_phone() // 3. 字段级:单独覆盖消息级 int32 age = 3 [features.field_presence = IMPLICIT]; // 无 has_age() } // 继承文件级全局设置(无消息级覆盖) message Product { string name = 1; // 继承文件级 -> 无 has_name() string code = 2 [features.field_presence = EXPLICIT]; // 字段级 -> 有 has_code() }
操作示例
edition = "2023";
message User {
string email = 1; // 默认 EXPLICIT
}.proto 文件生成的 CPP 代码 具有那些操作:
- 设置值:
set_email("a@b.com") - 取值:
email() - 判断是否有值:
has_email() 清除(取消设置):
clear_email()#include "user.pb.h" void Demo() { User user; // 1. 初始状态:未设置 if (!user.has_email()) { std::cout << "email 未传" << std::endl; // 输出这行 } // 2. 设置值 user.set_email("a@b.com"); // 3. 判断是否有值(显式存在) if (user.has_email()) { std::cout << "email = " << user.email() << std::endl; // 输出 a@b.com } // 4. 重点:设置空字符串 vs 未设置 user.set_email(""); // 此时 has_email() 返回 true,email() 返回 ""(空串) // ✅ 这就是“显式存在”的精髓——能区分“传了空串”和“没传” // 5. 清除(取消设置) user.clear_email(); // 此时 has_email() 返回 false,彻底回到“未传”状态 }
类型
更多类型可以查阅相关文档:https://protobuf.com.cn/programming-guides/editions/#scalar
| Proto 类型 | 说明 |
|---|---|
| double | |
| float | |
| int32 | 使用可变长度编码。对于编码负数效率低下——如果你的字段可能包含负值,请改用 sint32。 |
| int64 | 使用可变长度编码。对于编码负数效率低下——如果你的字段可能包含负值,请改用 sint64。 |
| uint32 | 使用可变长度编码。 |
| uint64 | 使用可变长度编码。 |
| sint32 | 使用可变长度编码。有符号整数值。这些比常规的 int32 更高效地编码负数。 |
| sint64 | 使用可变长度编码。有符号整数值。这些比常规的 int64 更高效地编码负数。 |
| fixed32 | 总是四个字节。如果值经常大于 228,比 uint32 更高效。 |
| fixed64 | 总是八个字节。如果值经常大于 256,比 uint64 更高效。 |
| sfixed32 | 总是四个字节。 |
| sfixed64 | 总是八个字节。 |
| bool | |
| string | 字符串必须始终包含 UTF-8 编码或 7 位 ASCII 文本,且长度不能超过 232。 |
| bytes | 可包含任意字节序列,长度不超过 232。 |
对应的CPP 类型:
.... 还是到时候自己看官网文档吧。https://protobuf.com.cn/programming-guides/editions/#scalar
字段默认值
| 类型 | 默认值 |
|---|---|
string | ""(空字符串) |
bytes | 空字节 |
bool | false |
数值类型(int32、float 等) | 0 |
消息字段(message) | 未设置,取决于语言 |
枚举(enum) | 第一个枚举值(必须为 0) |
repeated 字段 | 空列表 |
map 字段 | 空 map |
- 在
edition = "2023"想要设置字段默认值,这个是不可以的,因为在edition = "2023"中是推荐在业务逻辑中实现默认值。 - 当然 如果是 proto2 版本是可以通过
[default = 10]来设置默认值。
枚举、嵌套、导入
1.枚举
edition = "2023";
import "google/protobuf/descriptor.proto"; // 使用 features 必须导入
enum PhoneType {
MOBILE = 0; // 首值必须是 0,作为默认值
HOME = 1;
WORK = 2;
}
// 默认就是开放枚举,能接收未知值(如 99),这是和 proto3 一致的行为
PhoneType type = 1; // 值为 99 不会报错
// 如果想要封闭枚举(拒绝未知值),用 features 设置
enum ClosedEnum {
option features.enum_type = CLOSED;
A = 0;
B = 1;
}
// 别名还是要显式打开
enum Status {
option allow_alias = true;
UNKNOWN = 0;
STARTED = 1;
RUNNING = 1; // 合法的别名
}- 开放/封闭通过
features.enum_type控制,可以写在文件、消息或枚举本身。外层设了,里面所有嵌套枚举都会继承,除非内部再覆盖。 - 记住:只要用到
features,就必须 import 那个 descriptor 文件。
2.嵌套
edition = "2023";
import "google/protobuf/descriptor.proto";
message Outer {
option features.enum_type = CLOSED; // 这个设置会被嵌套枚举继承
message Inner {
int32 value = 1;
}
enum Kind {
A = 0;
B = 1;
}
Inner inner = 1;
Kind kind = 2;
}
// 外部引用用 父.子 的方式
message Wrapper {
Outer.Inner inner = 1;
Outer.Kind kind = 2; // Kind 由于继承了 CLOSED,不能接收未知值
}Kind没写 features,自动继承外层Outer的CLOSED,所以它会拒绝 A、B 以外的值。如果想让某个嵌套枚举单独开放,在它自己的定义里覆盖成OPEN就行。- 嵌套层级可以继续往下,但别弄太深,影响可读性。
3.导入
edition = "2023";
import "common/status.proto"; // 普通导入
import "google/protobuf/timestamp.proto";
message Event {
common.Status status = 1;
google.protobuf.Timestamp ts = 2;
}- 路径永远相对于 protoc 的 --proto_path 根目录。
想把自己的依赖传递给下游,用 import public:
// base.proto edition = "2023"; import public "common/types.proto";这样别人只要
import "base.proto"就能用types.proto里的东西。但是注意:public 不会传递 features 设置,被公共导入的文件里定义的 features 不会影响导入它的文件,你必须自己在当前文件里再设一次。
注意点:
- edition 2023 需要 protoc 22.0+(即 v4.22.0 起),21.x 系列不支持
- 跨版本引用:如果你的 edition 2023 文件导入了一个 proto2 文件,那么 proto2 里定义的枚举默认是封闭的,字段会有 has 方法;proto3 导入的则默认开放。各是各的语义,混用时心里要有数。
- features 继承只对嵌套有效,跨文件不影响。
二、怎么组织(结构)
package、service、oneof、map、Any
package
定义命名空间,避免消息类型重名,同时决定生成代码的路径(C++ 的 namespace、Java 的 package 等)。
edition = "2023";
package mycompany.users;
message Profile {
string name = 1;
int32 age = 2;
}生成的 C++ 代码里这样使用:
#include "users.pb.h"
// 通过包名限定访问
mycompany::users::Profile p;
p.set_name("Alice");
p.set_age(30);- 多个文件声明同一个
package,它们会合并到同一命名空间。
service
定义 RPC 服务接口,edition 2023 下行为不变。支持四种方法:
一元(Unary)
- 客户端发送一个请求,服务端返回一个响应。
适合普通查询,调用方等待结果。
rpc GetUser (GetUserReq) returns (User);
服务端流(Server streaming)
- 客户端发送一个请求,服务端持续推送多条消息。
适合全量导出、订阅推送:客户端发一次请求,服务端逐步返回结果。
rpc ListUsers (ListReq) returns (stream User);
客户端流(Client streaming)
- 客户端持续发送多条消息,全部发送完毕后服务端返回一个响应。
适合大文件上传、批量写入:客户端分块推数据,最后获取汇总结果。
rpc UploadUsers (stream UserChunk) returns (UploadSummary);
双向流(Bidirectional streaming)
- 客户端和服务端可以独立、交替地读写消息,无固定顺序。
适合聊天、实时协作:两端随时发送和接收。
rpc Chat (stream Message) returns (stream Message);
总结
edition = "2023";
service UserService {
rpc GetUser (GetUserReq) returns (User); // 一元
rpc ListUsers (ListReq) returns (stream User); // 服务端流
rpc UploadUsers (stream User) returns (Summary); // 客户端流
rpc Chat (stream Message) returns (stream Message); // 双向流
}oneof
多个字段共享同一块内存,同时最多一个被设置。设置一个字段,其余自动清空。
message Contact {
oneof address {
string email = 1;
string phone = 2;
}
}- 判断哪个被设置:使用生成的
address_case()或which_oneof()方法。 - oneof 内不能放
repeated字段。 - edition 2023 中,oneof 字段没有独立的
has_xxx();若要单独判断存在性,就别放进 oneof,改用optional。
map
键值对容器,底层是 repeated 一个包含 key 和 value 的消息。
message Scores {
map<string, int32> scores = 1;
}限制:
- key 只能是整数、字符串、bool,不能用 float、double、bytes、enum。
- value 不能是另一个 map(不能嵌套 map)。
- map 字段本身不能
repeated。 - 迭代顺序不保证。
Any
打包任意 Protobuf 消息,常用于泛型容器或多态场景。
C++ 里操作 Any 字段常用以下方法:
PackFrom(msg)
把消息序列化,填好type_url和value,塞进 Any。UnpackTo(&msg)
类型匹配就把 Any 里的数据解到msg里。Is<MessageType>()
检查 Any 里是否装着MessageType类型的消息。type_url()
返回类型标识字符串,如"type.googleapis.com/mypkg.MyMsg"。value()
返回序列化后的原始字节串。mutable_xxx_field()
拿到字段的可修改指针,后面才能调PackFrom。SerializeAsString()/ParseFromString()
对整个 Any 对象做序列化 / 反序列化。CopyFrom(other_any)
拷贝另一个 Any。Clear()
清空 Any,回到未设置状态。ByteSizeLong()/SpaceUsed()
查占用空间。
实操片段
example.proto
edition = "2023";
import "google/protobuf/any.proto";
package demo;
message Container {
google.protobuf.Any payload = 1;
}
message MyMessage {
string content = 1;
}main.cpp
#include "example.pb.h" // 对应上面的 proto
#include <google/protobuf/any.pb.h>
#include <iostream>
#include <string>
int main() {
// 1. 构造具体消息
demo::MyMessage msg;
msg.set_content("hello any");
// 2. 塞进 Container 的 Any 字段
demo::Container container;
container.mutable_payload()->PackFrom(msg); // Container.payload 对应 proto 中的 Any 字段
// 3. 序列化整个 Container
std::string data;
container.SerializeToString(&data);
// 4. 从字节流恢复新 Container
demo::Container parsed;
parsed.ParseFromString(data);
// 5. 从 Any 中解出具体消息
if (parsed.payload().Is<demo::MyMessage>()) { // 类型检查对应 MyMessage
demo::MyMessage unpacked;
parsed.payload().UnpackTo(&unpacked);
std::cout << unpacked.content() << std::endl; // 输出: hello any
}
return 0;
}扩展(老特性,了解即可)
源自 proto2,允许在不修改原消息定义的情况下添加字段。
message Foo {
extensions 100 to 200; // 预留编号范围
}
extend Foo {
optional int32 extra = 100; // 只有此范围能用于扩展
}- 现在更推荐用
google.protobuf.Any或组合包装消息,扩展不利于封装且阅读维护困难。 - edition 2023 仍兼容,但新项目别主动用。
注意事项
- package 不是必须,但强烈建议——特别多文件协作时能明显减少冲突。
- oneof 中如果添加的是消息类型字段,该消息内部增删字段完全不影响 oneof 的互斥行为。
- map 因为本质是 repeated,不能在 oneof 里,也不能直接作为另一个 map 的 value。
- Any 依赖类型 URL,跨语言交互时必须确保消息的完整路径一致(受 package 影响)。
- 扩展字段尽管在 proto3 被移除,edition 2023 又放出来了,但只是为了兼容遗留系统,新设计请用 Any 或包装字段。
三、怎么用(行为)
options(生成指令)
文件级选项(写入
.proto,影响代码生成行为)option cc_enable_arenas = true;– 启用 Arena 分配器(减少碎片,适合高频创建/销毁)option optimize_for = SPEED;– 生成最优化代码(默认),也可用CODE_SIZE或LITE_RUNTIMEoption deprecated = true;– 标记整个文件已弃用,生成[[deprecated]]属性
字段级选项
json_name = "myField";– 显式指定 JSON 键名(覆盖自动驼峰转换)[packed = true]– 标量 repeated 字段默认已紧凑编码,可省略;若显式设为false则取消紧凑deprecated = true– 字段弃用,生成编译器警告
C++ 生成代码常用选项(通过
--cpp_opt传递或直接写入.proto)--cpp_opt=dllexport_decl=MY_API:控制 DLL 导出符号(Windows)内部选项如
(google.api.http)与 C++ 无关,忽略JSON 转换
- 使用
google::protobuf::util::JsonPrintOptions/JsonParseOptions
#include <google/protobuf/util/json_util.h>
std::string json;
google::protobuf::util::JsonPrintOptions opts;
opts.add_whitespace = true;
opts.always_print_primitive_fields = true; // 打印默认值
opts.preserve_proto_field_names = true; // 保持原始字段名(不转驼峰)
MessageToJsonString(my_message, &json, opts);映射规则(edition "2023" 默认行为)
- 字段名 → 驼峰式 JSON 键,
int32_value→int32Value(可用json_name覆盖) - 枚举 → 字符串名称(非数字)
bytes→ Base64 字符串google.protobuf.Timestamp→ RFC 3339 格式"1970-01-01T00:00:00Z"- 默认值字段:默认不输出,设置
always_print_primitive_fields可强制输出
- 字段名 → 驼峰式 JSON 键,
- 解析 JSON 时忽略未知字段,并可通过
ignore_unknown_fields控制
编译生成代码
环境与依赖
编译
.proto需安装protoc编译器及 C++ 运行时库- Ubuntu:
apt install protobuf-compiler libprotobuf-dev - macOS:
brew install protobuf
- Ubuntu:
运行时库提供序列化、反序列化等底层实现
libprotobuf:完整功能,含反射、描述符libprotobuf-lite:无反射,体积更小
- edition 2023 要求 protobuf ≥ 4.22.0
编译命令语法
protoc -I./proto --cpp_out=./gen proto/user.proto-I/--proto_path:import 搜索路径--cpp_out:生成 C++ 代码的输出目录- 产出:
user.pb.h+user.pb.cc
CppOut选项
| 选项 | 写法 | 效果 |
|---|---|---|
| 默认(完整) | --cpp_out=./gen | 生成含反射的完整代码 |
| Lite 模式 | --cpp_out=lite:./gen | 生成轻量代码,无反射,链接 libprotobuf-lite |
| Arena 启用 | --cpp_out=enable_arenas=true:./gen | 启用 Arena 分配器(也可在 proto 内声明) |
- 多个选项以逗号分隔:
--cpp_out=lite,enable_arenas=true:./gen - 建议将
option cc_enable_arenas = true;、option optimize_for = LITE_RUNTIME;等直接写入.proto,无需每次在命令行传递。
生成文件结构
- 命名:
<name>.pb.h/<name>.pb.cc - 类归属:根据
package声明进入对应命名空间,如demo::User - 每个 message / enum 生成一个 C++ 类
- 自动包含依赖 proto 生成的头文件(当有
import时)
编译与链接
- 编译生成的
.pb.cc时需包含 protobuf 头文件路径 链接时需连接对应库:
- 完整模式:
-lprotobuf - Lite 模式:
-lprotobuf-lite
- 完整模式:
CMake 集成
方式一:使用 FindProtobuf(简洁)
find_package(Protobuf REQUIRED)
set(PROTO_FILES proto/user.proto proto/order.proto)
protobuf_generate_cpp(PROTO_SRCS PROTO_HDRS ${PROTO_FILES})
add_executable(app main.cpp ${PROTO_SRCS} ${PROTO_HDRS})
target_include_directories(app PRIVATE ${PROTOBUF_INCLUDE_DIRS})
target_link_libraries(app ${PROTOBUF_LIBRARIES})- 默认生成完整模式代码
- 若要 Lite 模式,需手动指定生成选项或自定义命令
方式二:手动调用 protoc(精确控制 Lite/Arena)
find_package(Protobuf REQUIRED)
set(PROTO_FILES proto/user.proto)
foreach(proto ${PROTO_FILES})
get_filename_component(name ${proto} NAME_WE)
set(hdr ${CMAKE_CURRENT_BINARY_DIR}/${name}.pb.h)
set(src ${CMAKE_CURRENT_BINARY_DIR}/${name}.pb.cc)
add_custom_command(
OUTPUT ${hdr} ${src}
COMMAND protobuf::protoc
--cpp_out=lite:${CMAKE_CURRENT_BINARY_DIR}
--proto_path=${CMAKE_CURRENT_SOURCE_DIR}
${proto}
DEPENDS ${proto}
)
list(APPEND PROTO_SRCS ${src})
list(APPEND PROTO_HDRS ${hdr})
endforeach()
add_executable(app main.cpp ${PROTO_SRCS} ${PROTO_HDRS})
target_link_libraries(app protobuf::libprotobuf-lite)- 按需替换
lite为其他选项,或改用protobuf::libprotobuf - 适用于 CMake 3.12+ 且 Protobuf 以 CONFIG 模式提供时
四、怎么改(兼容性)
哪些修改安全 / 不安全
安全(向后兼容)
- 新增字段(使用未占用的编号)
- 删除字段,并立即用
reserved声明其编号和名称 - 将标量字段改为
optional(添加has_xxx(),二进制布局不变,在 edition 2023 中字段默认隐式存在,改为显式存在安全) - 将单一字段移动到新定义的
oneof(二进制兼容,但需注意默认值语义变化)
不安全(破坏兼容)
- 修改字段编号
- 修改字段类型(如
int32→int64,会导致数据截断或解析异常) - 修改字段名同时依赖 JSON 序列化(二进制兼容但 JSON 键变化)
- 重用已废弃的编号(除非明确从
reserved移除并确认无历史数据) - 修改
oneof内部字段的类型或增删oneof成员(旧方可能丢失部分数据)
保留字段(reserved)
防止重用已删除字段的编号或名称
message Foo { reserved 2, 15, 9 to 11; reserved "old_field", "deprecated_name"; }- 编号和名称不能在同一行声明,需分开
- 删除字段后必须立刻加入
reserved,否则未来新增字段可能复用编号,造成数据错乱
未知字段、默认值
未知字段处理
- 解析时保留在
UnknownFieldsSet中,重新序列化时原样写回 - C++ 可通过反射访问:
message.GetReflection()->GetUnknownFields(message) - 转发/代理场景下保证数据不丢失
- 解析时保留在
默认值与存在性(edition 2023 核心变化)
隐式存在(默认):标量字段无
has_xxx()方法,无法区分“未设置”和“设置为默认值(0/空串/false)”- 序列化时默认值字段被省略(节省空间)
显式存在:需显式声明为
optional或文件级设置option features.field_presence = EXPLICIT;edition = "2023"; message Bar { int32 count = 1; // 隐式存在,无 has_count() optional int32 level = 2; // 显式存在,有 has_level() 和 clear_level() }
- 包装类型(google.protobuf.Int32Value 等)仍可用于区分未设置,但显式 optional 更轻量
- 枚举默认值始终为列表中第一个定义的常量(值必须为 0)
