目录

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+)/ repeatedrequired
自定义默认值 支持 [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 可以 import proto2 的 message 并使用,反之亦然。
  • proto2 定义的 enum 不能直接用作 proto3 消息的字段类型(如果只是「被 import 进来的 proto2 message 内部用了它」则没问题)。根因是下面第 5 节的开闭性差异。绕过办法:把字段声明成 int32(enum 与 int32/int64/uint32/uint64 wire 兼容),或在 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.httpbuf.validate 这类自定义 optionextend 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

两个必须知道的事实:

  1. edition 2023 不引入任何新功能,也不改变任何已有的 wire format。它只是把 proto2/proto3 的行为统一到一套可配置模型里。
  2. 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。

系列目录

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