一、导读与范围
1)本文说明如何使用 Protocol Buffers(Protobuf) 的 proto3 版本来组织数据(.proto 文件语法)并从 .proto 生成各语言的数据访问类。
2)若需 editions 语法,请参见 Protobuf Editions Language Guide ;若需 proto2 ,请参见 Proto2 Language Guide 。
3)本文是参考指南;若需"从零上手"的分步示例,请参阅你所选语言的官方教程。
二、定义消息类型(Message)
(1)最小示例(搜索请求):
proto
syntax = "proto3";
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 results_per_page = 3;
}
(2)syntax/edition 必须是文件中的第一行非空、非注释 语句;若二者都未指定,编译器默认 proto2 。
(3)一个消息由若干字段(name/value)组成,每个字段有名称 与类型。
三、字段类型(Type)
(1)可用标量类型 (如 int32、string)与复合类型 (如其他 message、enum)。
(2)后文"标量值类型"给出跨语言类型映射与编码要点。
四、字段编号(Field Numbers)
(1)范围:1 ~ 536,870,911 ;19,000 ~ 19,999 为实现保留,不可使用 。
(2)在同一消息内唯一 ;不可使用已保留或扩展(extensions)占用的编号。
(3)一经发布不可更改 :编号决定线格式中的字段标识;"改编号"≈"删字段+新建编号"。
(4)永不复用 :删除字段后,应将其编号与名称 标记为 reserved(见第十节)。
(5)空间优化 :1~15 的编号更省字节(标签编码更短);16~2047 次之。详见《Protocol Buffer Encoding》。
(6)额外说明:字段编号限制为 29 位 (另外 3 位用于 wire type)。
4.1、复用编号的后果(务必避免)
- 解码歧义、调试时间浪费、解析/合并错误、PII/SPII 泄露 、数据损坏。
- 常见诱因:
1)"重新排号"追求美观;
2)删除字段但未保留其编号/名称,导致被他人复用。
五、字段基数(Cardinality)与存在性(Presence)
(1)Singular(单值)
-
optional(推荐):
- 两种状态:已设置 (会序列化)或未设置(读默认值,不序列化);
- 可检测"是否显式设置";与 proto2/editions 兼容性更好。
-
implicit(不推荐):
-
若是消息类型 ,行为与
optional一致; -
若是非消息标量:
- 非默认值:会序列化;
- 默认值(零值) :不序列化,且无法区分"显式设为默认值"与"未提供"。
-
(2)repeated(可重复) :0 次或多次,保持顺序 。
(3)map(键值对):见第二十节。
5.1、数值型 repeated 默认打包(packed)
- 在 proto3 中,数值标量 的
repeated字段默认 使用 packed 编码(更紧凑)。
5.2、消息字段总是有存在性
- 消息类型 字段天然具备 presence;给消息字段加
optional不会改变 存在性或编码。下面两个定义在所有语言的生成代码与二进制/JSON/TextFormat 表现相同:
proto
syntax="proto3";
package foo.bar;
message Message1 {}
message Message2 {
Message1 foo = 1;
}
message Message3 {
optional Message1 bar = 1;
}
六、良构消息与"最后一次获胜"
(1)"良构(well-formed)"指序列化/反序列化的字节 合法;protoc 仅保证 .proto 可被解析。
(2)单值字段 在字节流中可出现多次------解析器接受,但只保留最后一次 出现的值(Last One Wins)。
七、同文件多消息与依赖膨胀
(1)可以在同一 .proto 中定义多个相关消息(如 SearchRequest 与 SearchResponse)。
(2)但过多 类型(message/enum/service)集中在同一文件会引起依赖膨胀 ;建议尽量精简 每个 .proto 的类型数量。
八、注释规范
(1)优先使用 // 放在代码元素前一行;(2)支持 /* ... */ 多行注释;(3)多行推荐 /** ... */ 风格:
proto
/**
* SearchRequest 表示一个搜索查询,并带分页选项。
*/
message SearchRequest {
string query = 1; // 查询词
int32 page_number = 2; // 页码
int32 results_per_page = 3; // 每页条数
}
九、删除字段(Deleting Fields)
(1)删除前提:客户端代码不再引用 该字段。
(2)删除后务必:
- 保留编号 (
reserved <numbers>),防止未来复用; - 建议保留名称 (
reserved "<names>"),便于 JSON/TextFormat 解析旧内容。
(3)也可选择保留但重命名(如加OBSOLETE_前缀)。
十、Reserved:保留编号与名称
(1)保留编号:
proto
message Foo {
reserved 2, 15, 9 to 11; // 含端点
}
(2)保留名称:
proto
message Foo {
reserved 2, 15, 9 to 11;
reserved "foo", "bar";
}
(3)注意:编号 与名称 不可在同一个 reserved 语句中混用。
(4)TextProto 的特殊性:部分实现(C++/Go)在解析时可能静默丢弃"保留名称的未知字段";JSON 运行时解析不受影响。
十一、代码生成
(1)C++ :每个 .proto 生成 .h 与 .cc,每个消息一个类。
(2)Java :每个 .proto 生成 .java,每个消息一个类,并有 Builder 。
(3)Kotlin :在 Java 生成基础上,每个消息生成一个 .kt,提供更友好的 Kotlin API(DSL、可空访问器、copy)。
(4)Python :生成模块,包含静态描述符;运行时通过元类创建访问类。
(5)Go :每个 .proto 生成一个 .pb.go,每个消息对应一个类型。
(6)Ruby :生成 .rb,包含你的消息类型。
(7)Objective-C :每个 .proto 生成 pbobjc.h / pbobjc.m。
(8)C# :每个 .proto 生成 .cs,每个消息一个类。
(9)PHP :每个消息生成 .php 文件,另为每个 .proto 生成 .php 元数据文件(用于将有效类型加载进描述符池)。
(10)Dart :每个 .proto 生成 .pb.dart。
十二、标量值类型与跨语言映射(含编码提示)
编码提示:
int32/int64为变长编码,对负数低效 → 负数多用sint32/sint64(zigzag)。fixed32/fixed64恒定 4/8 字节,大数更高效。string为 UTF-8/7-bit ASCII;bytes任意字节。- 数值型
repeated在 proto3 默认 packed。
类型映射(摘要表):
| Proto Type | C++ | Java/Kotlin1 | Python3 | Go | Ruby | C# | PHP | Dart | Rust |
|---|---|---|---|---|---|---|---|---|---|
| double | double | double | float | float64 | Float | double | float | double | f64 |
| float | float | float | float | float32 | Float | float | float | double | f32 |
| int32 | int32_t | int | int | int32 | Fixnum/Bignum | int | integer | int | i32 |
| int64 | int64_t | long | int/long4 | int64 | Bignum | long | integer/string6 | Int64 | i64 |
| uint32 | uint32_t | int2 | int/long4 | uint32 | Fixnum/Bignum | uint | integer | int | u32 |
| uint64 | uint64_t | long2 | int/long4 | uint64 | Bignum | ulong | integer/string6 | Int64 | u64 |
| sint32 | int32_t | int | int | int32 | Fixnum/Bignum | int | integer | int | i32 |
| sint64 | int64_t | long | int/long4 | int64 | Bignum | long | integer/string6 | Int64 | i64 |
| fixed32 | uint32_t | int2 | int/long4 | uint32 | Fixnum/Bignum | uint | integer | int | u32 |
| fixed64 | uint64_t | long2 | int/long4 | uint64 | Bignum | ulong | integer/string6 | Int64 | u64 |
| sfixed32 | int32_t | int | int | int32 | Fixnum/Bignum | int | integer | int | i32 |
| sfixed64 | int64_t | long | int/long4 | int64 | Bignum | long | integer/string6 | Int64 | i64 |
| bool | bool | boolean | bool | bool | True/False | bool | boolean | bool | bool |
| string | std::string | String | str/unicode5 | string | String(UTF-8) | string | string | String | ProtoString |
| bytes | std::string | ByteString | bytes | \[\]byte | ASCII-8BIT | ByteString | string/List | ProtoBytes | ProtoBytes |
脚注:
1 Kotlin 复用 Java 对应类型(含无符号),保证混合代码兼容。
2 Java 的无符号整型以有符号类型承载,最高位占用符号位。
3 设值会做类型检查。
4 解码时 64 位或无符号 32 位总以 long 表示;若设值给 int 且能容纳,也可为 int。
5 Python 解码为 unicode;若给定 ASCII 字符串,也可能是 str(细节可能变动)。
6 64 位机为 integer,32 位机为 string。
十三、默认值(Default Field Values)
(1)若字节中不存在某字段:
string→"";bytes→ 空;bool→false;数值 →0;- 消息 字段:未设置(具体取值与语言相关);
- 枚举 :默认是第一个枚举值(必须为 0,见第十四节"枚举默认 0 值");
repeated/map:空集合。
(2)隐式存在性标量 解析后,无法判断 默认值是显式设置还是未提供;设计布尔开关时请用optional bool并定义合理默认。
(3)设为默认值的标量不序列化 ;+0不写出,-0与+0不同,会写出。
十四、枚举(Enum)
(1)第一个值必须为 0 ,且推荐命名为 *_UNSPECIFIED/UNKNOWN(仅表示"未指定"):
proto
enum Corpus {
CORPUS_UNSPECIFIED = 0;
CORPUS_UNIVERSAL = 1;
CORPUS_WEB = 2;
CORPUS_IMAGES = 3;
CORPUS_LOCAL = 4;
CORPUS_NEWS = 5;
CORPUS_PRODUCTS = 6;
CORPUS_VIDEO = 7;
}
(2)默认值 即第一个值(示例中为 CORPUS_UNSPECIFIED)。
(3)别名(同一数值多个名称)需显式开启:
proto
enum EnumAllowingAlias {
option allow_alias = true;
EAA_UNSPECIFIED = 0;
EAA_STARTED = 1;
EAA_RUNNING = 1; // 别名
EAA_FINISHED = 2;
}
(4)枚举值应在 32 位整数范围内;不推荐负数 (varint 对负数低效)。
(5)删除枚举值后保留 其编号与名称 ,可用 max 指到上界:
proto
enum Foo {
reserved 2, 15, 9 to 11, 40 to max;
reserved "FOO", "BAR";
}
(6)反序列化未知枚举值 会被保留 ;在开放枚举(C++/Go)用底层整数保存,在封闭枚举(Java)用"未识别"分支暴露,并可取到底层整数。
(7)重要:不同语言的"理想行为"与"现状"可能有差异(详见官方 Enum Behavior 说明)
十五、复用其他消息类型 / 跨文件引用
(1)在消息中引用其他消息:
proto
message SearchResponse {
repeated Result results = 1;
}
message Result {
string url = 1;
string title = 2;
repeated string snippets = 3;
}
十六、导入与 import public
(1)导入其他 .proto:
proto
import "myproject/other_protos.proto";
编译器在 -I/--proto_path 指定的目录中解析导入路径;建议将其指向包含所有 proto 的最高级目录 。
(2)迁移路径 :当移动 .proto 的位置时,可在旧位置放一个"占位文件",用 import public 转发到新位置,平滑迁移:
proto
// old.proto(客户端继续导入它)
import public "new.proto";
import "other.proto";
// client 导入 old.proto 后可用 old/new 的定义,但不能透传 other.proto
(3)注意:Java 中 import public 在迁移整文件 或 java_multiple_files = true 时更稳;Kotlin/TS/JS/GCL 与使用静态反射的 C++ 不支持该功能。
十七、与 proto2 混用
(1)proto3 可以导入并使用 proto2 的消息类型 (反之也可)。
(2)但proto2 的枚举 不可直接在 proto3 语法中作为字段类型(若只在导入的 proto2 消息内部使用则可)。
十八、嵌套类型(Nested Types)
(1)可在消息内部定义消息,并以 Parent.Type 在外部引用;不同父级下同名子类型互不影响 。
(2)嵌套深度不限制。
十九、模式演进(更新消息类型)
(1)若需扩展消息格式,同时保持旧代码可用,遵循二进制线格式 的演进规则。
(2)Wire-unsafe(不安全)(除非保证所有读写端同时升级):
- 修改已有字段编号(等价"删+新");
- 将字段移入已存在 的
oneof。
(3)Wire-safe(安全): - 新增字段;
- 删除字段 (并
reserved编号/名称); - 枚举新增值;
- 显式存在性字段/扩展 ↔ 新
oneof成员(受限场景); - 仅含一个字段的
oneof↔ 显式存在性字段; - 字段与"同号同类型的 extension"互换。
(4)Wire-compatible(条件兼容): int32/uint32/int64/uint64/bool之间兼容 (可能截断/溢出,需灰度控制写入范围);sint32 ↔ sint64兼容,但与其他整数类型不兼容(zigzag 差异);string ↔ bytes(bytes必须是有效 UTF-8);message ↔ bytes(bytes为该消息的编码);singular ↔ repeated(数值型不安全 ,因 repeated 数值默认 packed 与 singular 不兼容;非数值:单值取最后一个 ,消息会merge);map<K,V> ↔ repeated Entry(语义兼容但map可能重排或去重,应用相关)。
二十、未知字段(Unknown Fields)
(1)旧二进制解析新数据时,新字段在旧解析器中成为未知字段 。
(2)在 proto3 中,未知字段会被保留 (与 proto2 一致),并在再次序列化时写回。
(3)会丢失未知字段的操作:
- 序列化到 JSON;
- 逐字段拷贝(遍历所有字段构造新消息)。
(4)避免丢失的建议: - 使用二进制,避免文本格式交换;
- 使用面向消息 的 API(如
CopyFrom()/MergeFrom()),不要逐字段复制。
(5)TextFormat 特殊性:序列化会按编号 打印未知字段;但再解析回二进制时,如果仍使用编号表示,可能解析失败。
二十一、Any(任意消息容器)
(1)Any 允许在未知类型场景下嵌入任意消息(包含消息字节与类型 URL);需导入:
proto
import "google/protobuf/any.proto";
message ErrorStatus {
string message = 1;
repeated google.protobuf.Any details = 2;
}
(2)默认类型 URL:type.googleapis.com/<package>.<Message>。
(3)各语言提供 pack()/unpack()(或等价方法)进行类型安全的打包/解包。
二十二、oneof(互斥字段)
(1)当"同时最多一个 字段被设置"时,使用 oneof 可节省内存并强制互斥:
proto
message SampleMessage {
oneof test_oneof {
string name = 4;
SubMessage sub_message = 9;
}
}
(2)特性与解析规则:
- 设置
oneof的任意成员会清空其他成员; - 解析时同一
oneof多成员出现,只保留最后出现的那个; - 基元值会覆盖 ;消息会merge;
- 将
oneof的成员设为默认值 也会占用case并序列化; map与repeated不能 直接放入oneof(可包在消息里)。
(3)C++ 注意 :避免对已被清理的子消息操作;交换swap两个含oneof的消息会交换其oneof case。
(4)向后兼容与标签复用问题:- 在单值字段与
oneof间移动,往返序列化可能丢失信息(被清空); - 删除后再添加回、拆分/合并
oneof存在相似风险; - 检查
oneof的值为None/NOT_SET时,无法区分"未设置"与"被设置为另一版本中的不同成员"。
二十三、map<K,V>(键值对)
(1)语法:
proto
map<key_type, value_type> map_field = N;
key_type:整型或string(不能 是浮点、bytes、enum、message);value_type:除map外任意类型。
(2)特性:map字段不能 是repeated;- 线格式与遍历顺序未定义 (不要依赖顺序);TextFormat 输出按键排序(数值键按数值);
- 解析/合并遇到重复键 ,以最后一次为准(TextFormat 可能解析失败);
- 若仅提供键 没有值,序列化行为与语言相关(C++/Java/Kotlin/Python 会序列化默认值;其他语言可能不序列化);
- 与
map foo同一作用域禁止出现符号名FooEntry(被实现占用)。
(3)向后兼容:线格式等价于
proto
message MapFieldEntry { key_type key = 1; value_type value = 2; }
repeated MapFieldEntry map_field = N;
支持 map 的实现必须能读写这两种格式。
二十四、包(Packages)与命名
(1)使用 package 防止类型重名:
proto
package foo.bar;
message Open { ... }
message Foo {
foo.bar.Open open = 1;
}
(2)语言层影响(简述):
- C++ → 命名空间
foo::bar; - Java/Kotlin → 作为 Java 包,除非显式
java_package; - Python → 忽略
package(仍建议写,以免描述符冲突); - Go → 忽略
package,实际由go_package或构建规则决定(开源必须提供go_package或 -M); - Ruby → 嵌套命名空间(首字母大写,非字母开头加
PB_); - PHP/C# → 转 PascalCase 作为命名空间,或受
php_namespace/csharp_namespace覆盖。
(3)名称解析 类似 C++:先最内层再向外;以.前缀(如.foo.bar.Baz)从最外层开始。
二十五、服务定义(Services)与 gRPC
(1)在 .proto 中定义 RPC 接口,编译器生成服务接口与桩代码:
proto
service SearchService {
rpc Search(SearchRequest) returns (SearchResponse);
}
(2)gRPC 与 Protobuf 深度集成,可直接用插件自 .proto 生成 RPC 代码。
(3)也可使用自研 RPC 或第三方实现(详见官方第三方插件列表/维基)。
二十六、JSON 映射(ProtoJSON)
(1)标准二进制线格式是 Protobuf 之间通信的首选 。
(2)与仅支持 JSON 的系统通信时,可使用规范的 JSON 编码 。
(3)注意:转 JSON 会丢失未知字段(见第二十节)。
二十七、Options(选项)
(1)选项不改动语义,但会影响生成或运行时行为;完整列表见 /google/protobuf/descriptor.proto。
(2)按作用域可分为文件级 、消息级 、字段级 、枚举/枚举值级 、oneof 级 、服务/方法级 (但有些层级目前暂无实用选项)。
(3)常用选项(节选):
-
文件级
①
java_package:生成 Java/Kotlin 包名(不生成 Java/Kotlin 时无效)protooption java_package = "com.example.foo";②
java_outer_classname:外层包装类名(java_multiple_files=false时其他类型作为内部类)protooption java_outer_classname = "Ponycopter";③
java_multiple_files:true时顶级类型分别生成 .java 文件(推荐)protooption java_multiple_files = true;④
optimize_for:SPEED(默认)/CODE_SIZE/LITE_RUNTIMEprotooption optimize_for = CODE_SIZE;⑤
cc_generic_services/java_generic_services/py_generic_services:已弃用(默认历史原因为 true,建议禁用,改用 RPC 插件)protooption cc_generic_services = false; option java_generic_services = false; option py_generic_services = false;⑥
cc_enable_arenas:启用 C++ arena 分配。⑦
objc_class_prefix:Objective-C 类前缀(推荐 3--5 个大写字母;2 字母前缀保留给 Apple)。 -
字段级
①
packed:数值型repeated在 proto3 默认 packed;与旧解析器兼容可设falseprotorepeated int32 samples = 4 [packed = false];②
deprecated:标记字段不建议使用;多数语言仅产生注解/警告;C++ 可触发 clang-tidy 警告protoint32 old_field = 6 [deprecated = true]; -
枚举值选项(含自定义扩展):
proto
import "google/protobuf/descriptor.proto";
extend google.protobuf.EnumValueOptions {
optional string string_name = 123456789;
}
enum Data {
DATA_UNSPECIFIED = 0;
DATA_SEARCH = 1 [deprecated = true];
DATA_DISPLAY = 2 [(string_name) = "display_value"];
}
(4)自定义选项 :高级特性,依赖 extensions (在 proto3 中仅 允许用于自定义选项本身)。
(5)选项保留(Option Retention):
- 默认 runtime(运行时保留,描述符可见);
- 可设
retention = RETENTION_SOURCE,仅源级 保留,不进入运行时代码(降体积),对protoc/插件仍可见:
proto
extend google.protobuf.FileOptions {
optional int32 source_retention_option = 1234 [retention = RETENTION_SOURCE];
}
message OptionsMessage {
int32 source_retention_field = 1 [retention = RETENTION_SOURCE];
}
截至 Protobuf 22.0:C++/Java 支持;Go 自 1.29.0 起支持;Python 实现已完成但尚未入发行版。
(6)选项目标(Targets):限制某选项字段能应用到哪些实体(文件/消息/枚举等):
proto
message MyOptions {
string file_only_option = 1 [targets = TARGET_TYPE_FILE];
int32 message_and_enum_option = 2 [
targets = TARGET_TYPE_MESSAGE, targets = TARGET_TYPE_ENUM
];
}
extend google.protobuf.FileOptions { optional MyOptions file_options = 50000; }
extend google.protobuf.MessageOptions { optional MyOptions message_options = 50000; }
extend google.protobuf.EnumOptions { optional MyOptions enum_options = 50000; }
// OK
option (file_options).file_only_option = "abc";
message MyMessage {
// OK
option (message_options).message_and_enum_option = 42;
}
enum MyEnum {
MY_ENUM_UNSPECIFIED = 0;
// Error:file_only_option 不能用于 enum
option (enum_options).file_only_option = "xyz";
}
二十八、代码生成命令(protoc)
(1)基本形式:
bash
protoc --proto_path=IMPORT_PATH \
--cpp_out=DST_DIR --java_out=DST_DIR --kotlin_out=DST_DIR \
--python_out=DST_DIR --go_out=DST_DIR --ruby_out=DST_DIR \
--objc_out=DST_DIR --csharp_out=DST_DIR --php_out=DST_DIR \
path/to/file.proto
(2)IMPORT_PATH:解析 import 的查找路径,可多次指定;-I=... 是简写。
(3)全局规范名唯一 :相对各自 proto_path 的文件名必须全局唯一。不要在不同 -I 目录下放置同名 data.proto 并期望 import "data.proto" 能区分;应统一以更高层 -I 指向公共根,使全局名(如 lib1/data.proto、lib2/data.proto)唯一。
(4)发布库时,建议在 proto 路径中包含唯一库名 ,避免与他人冲突。
(5)输出归档 :若 DST_DIR 以 .zip 或 .jar 结尾,输出将打包到单一归档;.jar 还会生成 manifest。已存在 会被覆盖 。
(6)必须提供一个或多个 .proto 输入;这些文件必须位于 IMPORT_PATH 下,以便编译器确定其规范名。
二十九、.proto 文件放置建议
(1)不要 把 .proto 与其他语言源码混在同一目录;建议在项目根包下创建语言无关 的 proto/ 子包。
(2)例外 :仅在明确只用于 Java(如测试)的场景下可以共置。
(3)对于多语言项目,统一在项目根 或 protos/ 下管理所有 .proto,并统一 --proto_path。
三十、支持平台(概览入口)
(1)关于 操作系统、编译器、构建系统与 C++ 版本 :参见 Foundational C++ Support Policy 。
(2)关于 PHP 支持版本 :参见 Supported PHP versions。
三十一、实践清单与常见陷阱
(一)发布前清单
1)新增字段编号与历史/保留不冲突;
2)删除字段已 reserved 编号与名称 ;
3)枚举新增值可能导致下游"穷尽 switch"编译告警,需先处理;
4)若做兼容(Compatible)变更,先升级读端 ,再放开写入扩大值域;
5)双向解析与未知字段透传的集成测试已覆盖。
(二)常见陷阱
1)"重新排号 "追求美观 → 等价"删+新",严禁 ;
2)删除字段不 reserved → 复用编号 引发数据损坏/隐私泄露 ;
3)把隐式标量 当"业务开关",false 不可与"未提供"区分 → 用 optional bool;
4)随意在单值与 oneof 间迁移 → 往返序列化丢值 ;
5)指望 map 有序 → 顺序未定义;需要顺序改用 repeated + 显式排序键;
6)想透传未知字段却转 JSON → 未知字段丢失;请用二进制与消息级拷贝 API。
三十二、完整小结
1)编号不可变、不可复用 ;删除后 reserved 编号与名称。
2)optional 优先 ,避免隐式标量的"默认值不可区分"。
3)常用字段放 1--15 ;数值 repeated 在 proto3 默认 packed 。
4)枚举首值为 0 ,命名 *_UNSPECIFIED/UNKNOWN。
5)oneof 互斥共享存储,注意迁移风险与 C++ 指针生命周期。
6)未知字段仅在二进制 保留;JSON/Text 会丢失。
7)模块化 .proto、合理 package 与 import public,控制"每文件类型数量"。
8)演进尽量选择 Wire-safe ;Compatible 需强约束"写入时机与范围"。