Protobuf-02 proto3 语法完全指南
本文是 Protobuf 系列的第 2 篇,作为 proto3 的语法参考使用。
上一篇从 wire format 开始讲了二进制编码,本篇讲怎么写
.proto。遇到「为什么这条规则是这样」时,答案基本都在上一篇。
1. 一个完整的例子
先看一个用到大部分语法的文件,后面逐项拆解:
syntax = "proto3";
package example.search.v1;
import "google/protobuf/timestamp.proto";
option go_package = "example.com/demo/gen/search/v1;searchv1";
option java_package = "com.example.demo.search.v1";
// 搜索请求
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
enum Corpus {
CORPUS_UNSPECIFIED = 0;
CORPUS_WEB = 1;
CORPUS_IMAGES = 2;
}
Corpus corpus = 4;
optional bool safe_search = 5; // 显式存在性
repeated string tags = 6;
map<string, string> filters = 7;
google.protobuf.Timestamp created_at = 8;
reserved 9, 10;
reserved "deprecated_field";
}
message SearchResponse {
repeated Result results = 1;
message Result {
string url = 1;
string title = 2;
repeated string snippets = 3;
}
}
service SearchService {
rpc Search(SearchRequest) returns (SearchResponse);
}
2. 文件级声明
2.1 syntax
syntax = "proto3";
必须是文件的第一行非空非注释内容。不写默认按 proto2 处理,所以 proto3 文件必须显式声明。
2.2 package
package 是 protobuf 层面的命名空间,用于避免 message、service 定义冲突。
假设 a.proto 和 b.proto 都定义了 UserInfo,互相引用时没有 package 就无法区分;有了 package 就可以用 example.search.v1.UserInfo 这样的全限定名。
推荐的命名惯例是 <公司/项目>.<领域>.<版本>,例如 example.search.v1。把版本放进 package 是很值得的实践:将来需要做破坏性变更时,可以新建 v2 包并行运行,而不是原地改坏 v1。
2.3 go_package 与 java_package
package 管的是 protobuf 命名空间,生成代码的包名是另一回事,由各语言的 xx_package 选项决定:
// 分号前是 Go 的导入路径,分号后是生成文件的包名
option go_package = "example.com/demo/gen/search/v1;searchv1";
option java_package = "com.example.demo.search.v1";
对 Java,若不写 java_package,会直接拿 package 当 Java 包名。
⚠️ 在新版 protoc-gen-go(APIv2)中
go_package几乎是必填的——要么写在 proto 文件里,要么在命令行用--go_opt=M<file>=<import_path>指定,否则直接报错。这与早年「可有可无」的时代不同。
2.4 import
import "google/protobuf/timestamp.proto";
import "myproject/other_protos.proto";
import public "legacy/old.proto"; // 转发导入:导入我的人也能看到 old.proto 的定义
import weak "optional_dep.proto"; // 弱导入,仅 Google 内部使用,不要用
关键点:导入路径是相对于 --proto_path(-I)指定的搜索根,不是相对于当前文件的相对路径。这是新手最常见的报错来源。
3. 字段
3.1 字段修饰符
proto3 有三种:
- singular(不写修饰符):0 个或 1 个。这是默认规则,属于隐式存在性——字段等于零值时无法区分「没设置」和「设置成了零值」,且序列化时不会出现在字节流里。
- optional:显式存在性,可以区分「未设置」和「设置为零值」。proto3 早期移除了这个关键字,3.12 起作为实验特性回归,3.15 起默认可用。
- repeated:可重复任意多次(含 0 次),顺序被保留,对应 Go 的 slice、Java 的 List。
required 在 proto3 中已被彻底移除,原因见第 1 篇的推论 ⑫。
存在性(presence)是 Protobuf 里最容易出 bug 的地方,值得单独一篇:见第 3 篇。
repeated 的数值类型字段(含 bool、enum)在 proto3 中默认使用 packed 编码,这会影响 wire 兼容性,细节见第 1 篇 4.3 节。
3.2 标量类型映射
| .proto 类型 | 说明 | Go | Java |
|---|---|---|---|
double |
8 字节浮点 | float64 |
double |
float |
4 字节浮点 | float32 |
float |
int32 |
变长编码,负数固定占 10 字节 | int32 |
int |
int64 |
变长编码,负数低效 | int64 |
long |
uint32 |
变长无符号 | uint32 |
int |
uint64 |
变长无符号 | uint64 |
long |
sint32 |
ZigZag + 变长,适合有负数的场景 | int32 |
int |
sint64 |
ZigZag + 变长 | int64 |
long |
fixed32 |
固定 4 字节,值常大于 2^28 时比 uint32 划算 |
uint32 |
int |
fixed64 |
固定 8 字节,值常大于 2^56 时比 uint64 划算 |
uint64 |
long |
sfixed32 |
固定 4 字节有符号 | int32 |
int |
sfixed64 |
固定 8 字节有符号 | int64 |
long |
bool |
bool |
boolean |
|
string |
必须是 UTF-8 或 7-bit ASCII | string |
String |
bytes |
任意字节序列,不超过 2^32 | []byte |
ByteString |
选型速查:默认 int64;确定非负且值域小用 int32/uint32;可能为负用 sint64;随机大数(ID、哈希)用 fixed64。
3.3 字段号
每个字段都有唯一的数字编号,它在二进制格式中充当字段的唯一身份,一旦使用就不能再改。
[1, 15]的 tag 只占 1 字节,[16, 2047]占 2 字节 → 把高频字段放在 1–15。- 范围是
1到2^29 - 1(536,870,911)。 [19000, 19999]是 protobuf 实现预留区间,使用会编译报错。- 字段号不必连续,也不必有序,留出空档反而方便将来插入。
3.4 reserved
删除字段后,后来人很可能重用这个编号,而旧数据里该编号还残留着旧类型的值——这会造成数据错乱甚至隐私事故(把 A 用户的字段读成 B 字段)。正确做法是用 reserved 把编号和名字都占住,编译器会拦下后来的复用尝试:
message Foo {
reserved 2, 15, 9 to 11; // to 表示连续区间
reserved "foo", "bar"; // 名字也要保留,避免 JSON / text format 出问题
}
注意不要在同一条 reserved 语句里混写编号和名字。
删除字段的标准动作是三步:删掉字段 → reserved 编号 → reserved 名字。少一步都留隐患。
3.5 注释
支持 C/C++ 风格:// 单行、/* */ 多行。紧贴在 message、字段、rpc 上方的注释会被带进生成代码作为文档注释,所以这里写的东西是给下游看的,值得认真写。
4. 枚举 enum
enum Corpus {
CORPUS_UNSPECIFIED = 0; // proto3 强制:第一个值必须是 0
CORPUS_WEB = 1;
CORPUS_IMAGES = 2;
CORPUS_LOCAL = 3;
}
要点:
- 第一个值必须是 0,它同时是该字段的默认值。官方风格指南建议命名为
XXX_UNSPECIFIED,语义是「未设置」,不要给它赋予业务含义。 - 枚举值必须在 32 位整型范围内。enum 走 varint,负数编码低效,不建议用负值。
- 枚举常量位于父作用域(沿用 C++ 作用域规则):同一层里两个 enum 不能有同名常量。这就是为什么要给常量加类型名前缀——
CORPUS_WEB而不是WEB。 - proto3 的枚举是开放的(open enum):收到当前代码不认识的值时,它会被照常存进字段,序列化时原样带回。proto2 的枚举是封闭的(closed),未知值会落进 unknown fields,读出来是默认值。这个差异是灰度发布时的经典事故源,详见第 7 篇。
- 因为 open enum,消费端处理枚举必须写 default 分支——你随时可能收到一个「未来才定义」的值。
要给同一个数值起多个名字,需显式开启别名:
enum Status {
option allow_alias = true;
STATUS_UNKNOWN = 0;
STATUS_STARTED = 1;
STATUS_RUNNING = 1; // 与 STARTED 是别名
}
enum 同样支持 reserved:
enum Corpus {
reserved 3, 5 to 7;
reserved "CORPUS_LOCAL";
CORPUS_UNSPECIFIED = 0;
}
5. oneof
如果一个 message 里有多个字段,但同一时刻至多一个会被设置,用 oneof 可以让它们共享内存:
message SampleMessage {
oneof test_oneof {
string name = 4;
SubMessage sub_message = 9;
}
}
- 设置其中一个成员会自动清除其它成员。
- oneof 成员不能加
required/optional/repeated。需要重复语义时,把 repeated 字段包一层 message 再放进 oneof。 - oneof 内成员的字段号与外层字段共用同一套编号空间,不能冲突。
- oneof 天然具备存在性语义(可以判断「哪个成员被设置了 / 一个都没设」)。
- 不要把已有的多个字段事后合并进一个 oneof,这是破坏性变更(见第 4 篇)。
Go 里 oneof 生成一个接口字段,用类型断言消费:
switch v := msg.TestOneof.(type) {
case *pb.SampleMessage_Name:
fmt.Println("name:", v.Name)
case *pb.SampleMessage_SubMessage:
fmt.Println("sub:", v.SubMessage)
case nil:
fmt.Println("一个都没设置")
}
6. map
map<string, Project> projects = 3;
限制清单:
key_type只能是整型或string(不能是float/double/bytes/enum/message)。value_type可以是任意类型,但不能是另一个 map。- map 字段不能加
repeated/optional修饰。 - 迭代顺序和序列化顺序都不确定,不要依赖顺序。
- map 不能作为 oneof 的成员。
map 本质是语法糖,编译器展开成:
message ProjectsEntry {
string key = 1;
Project value = 2;
}
repeated ProjectsEntry projects = 3;
两者 wire format 完全一致,所以 map 与手写的 repeated entry 之间可以互相改写而不破坏已有数据。
7. 嵌套与复用
message SearchResponse {
message Result {
string url = 1;
string title = 2;
}
repeated Result results = 1;
}
- 在外部引用嵌套类型要写
SearchResponse.Result。 - Go 生成的类型名会被扁平化成
SearchResponse_Result。 - 嵌套只是命名组织手段,对 wire format 没有影响。
- 嵌套超过两层就该考虑拆平,否则生成代码里的类型名会变得很难读。
8. service
service SearchService {
rpc Search (SearchRequest) returns (SearchResponse); // 一元
rpc Watch (SearchRequest) returns (stream SearchResponse); // 服务端流
rpc BatchSearch (stream SearchRequest) returns (SearchResponse); // 客户端流
rpc Chat (stream SearchRequest) returns (stream SearchResponse); // 双向流
}
- 请求和响应必须是 message 类型,不能是标量。
- 即使当前只需要一个字段,也请用专门的 Request/Response message 包一层,而不要直接复用别的 message——将来加字段时不会牵连别人。
- 不需要参数或返回值时,用
google.protobuf.Empty(见第 5 篇)。 - protobuf 本身只描述接口,代码生成与调用由 RPC 框架完成。gRPC 四种通信模式的实现细节见《Go-24 RPC与gRPC实践》。
9. 官方命名风格
风格统一在多人协作时很省事,官方 Style Guide 的要点:
| 对象 | 规范 | 示例 |
|---|---|---|
| 文件名 | lower_snake_case.proto |
search_service.proto |
| package | 全小写,点分,带版本 | example.search.v1 |
| message / enum / service | PascalCase |
SearchRequest |
| 字段名 | lower_snake_case |
result_per_page |
| repeated 字段名 | 用复数 | tags、results |
| 枚举常量 | TYPENAME_SCREAMING_SNAKE |
CORPUS_WEB |
| rpc 方法 | PascalCase |
SearchDocuments |
| 缩进 | 2 空格 |
字段名用 snake_case 是有实际意义的:生成代码时各语言按自己的惯例转换(Go 变 ResultPerPage,JSON 变 resultPerPage),你写驼峰反而会得到奇怪的结果。
10. 小结
syntax必须写在第一行;package建议带上版本号,为将来的破坏性变更留后路。- proto3 三种修饰符:singular(隐式存在性)、
optional(显式存在性,3.15+)、repeated(数值型默认 packed)。 - 字段号是字段的真实身份,不可改;删字段必须
reserved编号和名字。 - 枚举首值必须为 0 且建议叫
XXX_UNSPECIFIED;proto3 枚举是开放的,消费端必须写 default 分支。 - oneof 共享内存、成员不能 repeated;map 是
repeated entry的语法糖、顺序不保证。 - service 的请求响应各自独占一个 message,别图省事复用。
下一篇讲存在性(presence)——proto3 里最容易写出 bug 的地方。
系列目录
xingliuhua