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空字节
boolfalse
数值类型(int32float 等)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_RUNTIME
    • option 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 时忽略未知字段,并可通过 ignore_unknown_fields 控制

编译生成代码

环境与依赖

  • 编译 .proto 需安装 protoc 编译器及 C++ 运行时库

    • Ubuntu:apt install protobuf-compiler libprotobuf-dev
    • macOS:brew install protobuf
  • 运行时库提供序列化、反序列化等底层实现

    • 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)