一、导读与范围
1)本文说明如何使用 Protocol Buffers(Protobuf) 的 proto3 版本来组织数据(.proto
文件语法)并从 .proto
生成各语言的数据访问类。
2)若需 editions 语法,请参见 Protobuf Editions Language Guide;若需 proto2,请参见 Proto2 Language Guide。
3)本文是参考指南;若需“从零上手”的分步示例,请参阅你所选语言的官方教程。
二、定义消息类型(Message)
(1)最小示例(搜索请求):
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 表现相同:
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)多行推荐 /** ... */
风格:
/**
* 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)保留编号:
message Foo {
reserved 2, 15, 9 to 11; // 含端点
}
(2)保留名称:
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/Kotlin[1] | Python[3] | 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/long[4] | int64 | Bignum | long | integer/string[6] | Int64 | i64 |
uint32 | uint32_t | int[2] | int/long[4] | uint32 | Fixnum/Bignum | uint | integer | int | u32 |
uint64 | uint64_t | long[2] | int/long[4] | uint64 | Bignum | ulong | integer/string[6] | Int64 | u64 |
sint32 | int32_t | int | int | int32 | Fixnum/Bignum | int | integer | int | i32 |
sint64 | int64_t | long | int/long[4] | int64 | Bignum | long | integer/string[6] | Int64 | i64 |
fixed32 | uint32_t | int[2] | int/long[4] | uint32 | Fixnum/Bignum | uint | integer | int | u32 |
fixed64 | uint64_t | long[2] | int/long[4] | uint64 | Bignum | ulong | integer/string[6] | Int64 | u64 |
sfixed32 | int32_t | int | int | int32 | Fixnum/Bignum | int | integer | int | i32 |
sfixed64 | int64_t | long | int/long[4] | int64 | Bignum | long | integer/string[6] | Int64 | i64 |
bool | bool | boolean | bool | bool | True/False | bool | boolean | bool | bool |
string | std::string | String | str/unicode[5] | 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
(仅表示“未指定”):
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)别名(同一数值多个名称)需显式开启:
enum EnumAllowingAlias {
option allow_alias = true;
EAA_UNSPECIFIED = 0;
EAA_STARTED = 1;
EAA_RUNNING = 1; // 别名
EAA_FINISHED = 2;
}
(4)枚举值应在 32 位整数范围内;不推荐负数(varint 对负数低效)。
(5)删除枚举值后保留其编号与名称,可用 max
指到上界:
enum Foo {
reserved 2, 15, 9 to 11, 40 to max;
reserved "FOO", "BAR";
}
(6)反序列化未知枚举值会被保留;在开放枚举(C++/Go)用底层整数保存,在封闭枚举(Java)用“未识别”分支暴露,并可取到底层整数。
(7)重要:不同语言的“理想行为”与“现状”可能有差异(详见官方 Enum Behavior 说明)
十五、复用其他消息类型 / 跨文件引用
(1)在消息中引用其他消息:
message SearchResponse {
repeated Result results = 1;
}
message Result {
string url = 1;
string title = 2;
repeated string snippets = 3;
}
十六、导入与 import public
(1)导入其他 .proto
:
import "myproject/other_protos.proto";
编译器在 -I/--proto_path
指定的目录中解析导入路径;建议将其指向包含所有 proto 的最高级目录。
(2)迁移路径:当移动 .proto
的位置时,可在旧位置放一个“占位文件”,用 import public
转发到新位置,平滑迁移:
// 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);需导入:
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
可节省内存并强制互斥:
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)语法:
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)向后兼容:线格式等价于
message MapFieldEntry { key_type key = 1; value_type value = 2; }
repeated MapFieldEntry map_field = N;
支持 map
的实现必须能读写这两种格式。
二十四、包(Packages)与命名
(1)使用 package
防止类型重名:
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 接口,编译器生成服务接口与桩代码:
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 时无效)option java_package = "com.example.foo";
②
java_outer_classname
:外层包装类名(java_multiple_files=false
时其他类型作为内部类)option java_outer_classname = "Ponycopter";
③
java_multiple_files
:true
时顶级类型分别生成 .java 文件(推荐)option java_multiple_files = true;
④
optimize_for
:SPEED
(默认)/CODE_SIZE
/LITE_RUNTIME
option optimize_for = CODE_SIZE;
⑤
cc_generic_services
/java_generic_services
/py_generic_services
:已弃用(默认历史原因为 true,建议禁用,改用 RPC 插件)option 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;与旧解析器兼容可设false
repeated int32 samples = 4 [packed = false];
②
deprecated
:标记字段不建议使用;多数语言仅产生注解/警告;C++ 可触发 clang-tidy 警告int32 old_field = 6 [deprecated = true];
枚举值选项(含自定义扩展):
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
/插件仍可见:
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):限制某选项字段能应用到哪些实体(文件/消息/枚举等):
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)基本形式:
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 需强约束“写入时机与范围”。