目录

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.protob.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 的数值类型字段(含 boolenum)在 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。
  • 范围是 12^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;
}

要点:

  1. 第一个值必须是 0,它同时是该字段的默认值。官方风格指南建议命名为 XXX_UNSPECIFIED,语义是「未设置」,不要给它赋予业务含义
  2. 枚举值必须在 32 位整型范围内。enum 走 varint,负数编码低效,不建议用负值。
  3. 枚举常量位于父作用域(沿用 C++ 作用域规则):同一层里两个 enum 不能有同名常量。这就是为什么要给常量加类型名前缀——CORPUS_WEB 而不是 WEB
  4. proto3 的枚举是开放的(open enum):收到当前代码不认识的值时,它会被照常存进字段,序列化时原样带回。proto2 的枚举是封闭的(closed),未知值会落进 unknown fields,读出来是默认值。这个差异是灰度发布时的经典事故源,详见第 7 篇
  5. 因为 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 字段名 用复数 tagsresults
枚举常量 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 的地方。


系列目录

  1. 从 wire format 开始:一个字节一个字节读懂编码
  2. proto3 语法完全指南(本篇)
  3. 存在性 presence:Protobuf 最难的一章
  4. schema 演进与兼容性:哪些改动会炸
  5. Well-Known Types 与 JSON 映射
  6. Go 工程化实践:buf 工具链与 API 用法
  7. proto2 与 Protobuf Editions