Protobuf-07 proto2 与 Protobuf Editions
本文是 Protobuf 系列的第 7 篇,收尾篇。
内容分两半:前半是 proto2 与 proto3 的差异——只在维护老项目、或需要读懂
descriptor.proto这类 proto2 文件时需要;后半是 Protobuf Editions,Google 用来统一 proto2/proto3 的继任方案,值得提前了解但目前不必着急迁移。
1. 差异总览
| 特性 | proto2 | proto3 |
|---|---|---|
| 字段修饰符 | required / optional / repeated,必须显式写一个 |
默认 singular,可选 optional(3.15+)/ repeated,无 required |
| 自定义默认值 | 支持 [default = 10] |
不支持,只能是类型零值 |
| 存在性判断 | 所有 optional 字段都有 Has 语义 |
singular 无 Has,需要则用 optional 或包装类型 |
| 枚举首值 | 任意 | 必须为 0 |
| 枚举开闭性 | closed:未知值进 unknown fields | open:未知值原样留在字段里 |
| repeated 数值 packed | 默认关闭,需 [packed = true] |
默认开启 |
| extensions | 支持 extensions / extend |
仅保留用于自定义 option,业务场景改用 Any |
| group | 支持(早已废弃的语法) | 移除 |
| 未知字段 | 保留 | 3.0–3.4 会丢弃,3.5 起恢复保留 |
| JSON 映射 | 无标准规范 | 有官方标准映射 |
互操作规则(容易记错,单独列出):
- 两者 wire format 兼容,proto3 可以
importproto2 的 message 并使用,反之亦然。 - 但 proto2 定义的 enum 不能直接用作 proto3 消息的字段类型(如果只是「被 import 进来的 proto2 message 内部用了它」则没问题)。根因是下面第 5 节的开闭性差异。绕过办法:把字段声明成
int32(enum 与int32/int64/uint32/uint64wire 兼容),或在 proto3 里重新声明一份等价 enum。 - proto3 不能使用 proto2 的
group和普通扩展字段。
2. required:一个被官方否决的设计
proto2 的字段修饰符必须显式写出:
syntax = "proto2";
message SearchRequest {
required string query = 1;
optional int32 page_number = 2;
repeated string tags = 3;
}
required 表示「一个格式良好的消息一定要含有这个字段」:没赋值则序列化失败,收到缺该字段的数据则反序列化失败。
问题在于 required 是永久性的、无法安全演进:
- 把
required改成optional:旧版本的读端仍然认为不含该字段的消息是不完整的,会直接拒绝解析整条消息。 - 把
optional改成required:所有历史数据里没填这个字段的记录,瞬间全部变成不可解析。
也就是说,这个决定一旦做出就锁死了,而现实中需求总会变。Google 内部实践后认为它得不偿失,proto3 直接移除。
更深一层的原因见第 1 篇:wire format 里根本没有「必填」的表达方式,required 纯粹是运行时校验逻辑。把业务校验规则编码进序列化格式,是层次划分上的错误。
现代做法:校验放在业务层,或用
protoc-gen-validate/buf.validate这类基于自定义 option 的声明式校验——它们生成的是可以随时改的校验代码,而不是刻进 schema 的解析规则。
3. optional 与自定义默认值
proto2 中,未设置的 optional 字段读出来是默认值,而默认值可以在 schema 里指定:
optional int32 result_per_page = 3 [default = 10];
optional Corpus corpus = 4 [default = UNIVERSAL];
不指定时使用类型零值:string 是空串,bool 是 false,数值是 0,enum 是定义中的第一个值。
proto3 移除了 [default = x],默认值只能是类型零值。需要「默认 10」这种语义时:
- 用
optional(3.15+)判断字段是否设置,未设置时在代码里填默认值; - 或用
google.protobuf.Int32Value等包装类型; - 或约定一个哨兵值(不推荐,容易踩坑)。
要注意 proto3 的 optional 与 proto2 的 optional 不是一回事:前者只提供存在性判断(field presence),不带自定义默认值能力。细节见第 3 篇。
为什么 proto3 要砍掉自定义默认值?因为默认值也是不可演进的:改一次默认值,新旧代码对同一份字节流会解读出不同结果,而且从字节流上完全看不出问题。这类分歧极难排查。
4. extensions 扩展
proto2 可以把一段字段号范围声明为「留给第三方扩展」,别人就能在自己的文件里给你的 message 加字段,而不必改你的原始文件:
// 原始定义
message Foo {
// ...
extensions 100 to 199;
}
// 第三方在自己的文件里扩展
extend Foo {
optional int32 bar = 126;
}
访问扩展字段需要专门的扩展 API,与普通字段的读写方式不同。
proto3 移除了对普通业务 message 的扩展能力,需要「装任意类型」的场景改用 google.protobuf.Any:
import "google/protobuf/any.proto";
message ErrorStatus {
string message = 1;
repeated google.protobuf.Any details = 2;
}
Any 内部存的是「类型 URL + 序列化字节」,使用时需要 pack/unpack,比扩展字段更显式,也更笨重。用法见第 5 篇。
重要区分:
google.api.http、buf.validate这类自定义 option(extend google.protobuf.MethodOptions等)在 proto3 里是完全支持的,它们不属于被移除的能力。proto3 砍掉的只是对普通业务 message 做extensions/extend。
5. 开放枚举与闭合枚举
这是 proto2/proto3 之间最隐蔽也最容易出事的差异。
- proto2:closed enum。解析时遇到不在已知集合里的枚举值,该值被放进 unknown field set,字段本身读出来是默认值。从读的角度看,这个值消失了。
- proto3:open enum。未知枚举值被正常存进字段,可以读到原始数字,序列化时原样带回。
为什么要改?看这条链路:服务 A(新版,认识 STATUS_ARCHIVED = 3)→ 服务 B(旧版,只认识 0/1/2)→ 服务 C(新版):
| B 反序列化后再转发 | C 收到 | |
|---|---|---|
| proto3(open) | 值原样留在字段里 | 3,行为正确 |
| proto2(closed) | 值落进 unknown fields,B 若重新构造消息就会丢 | 0,静默数据丢失 |
滚动升级期间「中间节点是旧版本」是常态,closed enum 的这个行为几乎必然造成事故,而且不报错、无日志。
实践建议:
- 永远保留
0 = XXX_UNSPECIFIED,不要把 0 赋予业务含义。 - 消费端处理枚举必须写 default 分支。
- 把老 proto2 枚举迁到 proto3 时,专门检查依赖「未知值 → 默认值」这一行为的代码。
还有个坑:这个行为在各语言实现中存在不一致。官方文档明确提到所有已知的 Java 版本在「proto2 文件 import proto3 定义的 enum」时都不符合规范(Java 会当成 closed 处理)。跨语言系统里不要依赖边界行为。
6. 还会在哪里遇到 proto2
- 存量老项目,尤其是 2016 年之前的 Google 系代码。
- protobuf 自身的元描述文件
descriptor.proto长期以来就是 proto2 语法写的——想读懂FileDescriptorSet、写代码生成器或做反射,绕不开它。 - 需要给已有 message 做第三方扩展、且不能改原始文件的场景。
7. Protobuf Editions
7.1 它想解决什么
proto2 和 proto3 的差异,本质上是一堆互相独立的行为开关被打包成了两个套餐:字段存在性、枚举开闭性、packed 默认值、UTF-8 校验方式……你想要 proto3 的开放枚举,但同时想要 proto2 的显式存在性?在旧模型下做不到,只能整套二选一。
Editions 的思路就是把套餐拆成单点配置:不再写 syntax,而是声明一个 edition 号,它代表「这一年的默认行为组合」,然后你可以逐项覆盖:
edition = "2023";
package example.v1;
// 文件级:把这个文件的枚举默认改成闭合
option features.enum_type = CLOSED;
message Foo {
// 字段级:单独指定隐式存在性
int32 count = 1 [features.field_presence = IMPLICIT];
string name = 2; // 用 edition 2023 的默认值:EXPLICIT
}
常见 feature:
| feature | 取值 | 说明 |
|---|---|---|
field_presence |
EXPLICIT / IMPLICIT / LEGACY_REQUIRED |
edition 2023 默认 EXPLICIT(即 proto2 行为) |
enum_type |
OPEN / CLOSED |
默认 OPEN(proto3 行为) |
repeated_field_encoding |
PACKED / EXPANDED |
默认 PACKED(proto3 行为) |
utf8_validation |
VERIFY / NONE |
feature 可以在文件、message、字段、enum 等各级设置,就近覆盖。
7.2 时间线与现状
- v26 及之前:仅
--experimental_editions实验支持,不可用于生产。 - protoc v27.0(2024-05):edition 2023 正式 GA。
- 此后大致每年一个 edition,2024 已发布。
- 另外注意 protobuf 的版本号方案已经变了:现在是
主版本.修订(如34.1),不再是3.x.y。
两个必须知道的事实:
- edition 2023 不引入任何新功能,也不改变任何已有的 wire format。它只是把 proto2/proto3 的行为统一到一套可配置模型里。
- protoc 支持 ≠ 你的语言插件支持。代码生成器必须在
CodeGeneratorResponse里声明FEATURE_SUPPORTS_EDITIONS,否则 protoc 会直接报错(典型报错:is an editions file, but code generator protoc-gen-xxx hasn't been updated to support editions yet)。
7.3 现在该迁移吗
不用急。 包括 Buf 官方在内的主流建议都是:新项目继续用 proto3,等生态支持和 edition 支持周期明确后再考虑。理由:
- proto3 的支持「实际上会永远存在」,没有被弃用的时间表;
- 各语言插件与第三方工具的 editions 支持进度不齐;
- edition 的官方支持年限尚不明确(Google 提过大约 10 年的说法),而 protoc 目前也没有 LTS 版本。
现阶段你需要知道的就一句话:Editions 是 proto2/proto3 的统一继任者,不带新功能,看到 edition = "2023" 时不用慌。
8. 小结
- proto2 → proto3 的核心变化:移除
required和自定义默认值、枚举首值必须为 0、枚举从 closed 变 open、packed 默认开启、扩展机制收缩到只服务自定义 option。 required和自定义默认值被砍的共同原因是不可安全演进:它们把「一旦定下就改不了」的决定刻进了 schema。- closed → open enum 是最容易造成静默数据丢失的差异,滚动升级时尤其要留意。
- proto2 的 enum 不能直接用作 proto3 字段类型,但 message 可以互相 import。
- Editions 把 proto2/proto3 的行为差异拆成可独立配置的 feature,edition 2023 自 protoc v27.0 起 GA,但目前的最佳选择仍是 proto3。
系列目录
- 从 wire format 开始:一个字节一个字节读懂编码
- proto3 语法完全指南
- 存在性 presence:Protobuf 最难的一章
- schema 演进与兼容性:哪些改动会炸
- Well-Known Types 与 JSON 映射
- Go 工程化实践:buf 工具链与 API 用法
- proto2 与 Protobuf Editions(本篇)
xingliuhua