一、导读与范围
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/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`(仅表示"未指定"):
```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