首页
/ Protobuf Editions 机制设计解析:Life of an Edition 如何支撑语言的语义级演进

Protobuf Editions 机制设计解析:Life of an Edition 如何支撑语言的语义级演进

2026-09-05 20:07:51作者:盛欣凯Ernestine

本文基于 Protobuf 设计文档 Life of an Edition(作者 @mcy),系统讲解 Protobuf Editions 机制如何被用来组织"大规模变更"(large-scale change):如何定义 feature、如何宣布(proclaim)一个 edition、如何设计不同风险等级的迁移方案,以及配套的迁移工具与开源生态策略。读完后,你将理解 editions 从"特性设计"到"生态落地"的完整生命周期,并能结合仓库源码(如 descriptor.proto 中的 Edition 枚举与 Features 消息)验证设计文档中机制的实际实现现状。

一、Overview:这份设计文档要解决什么问题

Life of an Edition 的主题是:

How to use Protobuf Editions to construct a large-scale change that modifies the semantics of Protobuf in some way.

即:如何使用 Protobuf Editions 机制(edition 本身,以及配套的 features 设计),去设计和执行一类"旨在解决某种语言固有缺陷"的迁移与大规模变更。文档明确覆盖了五个方面:

  • 如何向语言中添加 feature(特性开关);
  • edition 如何被定义和"宣布"(proclaimed);
  • 如何构造不同类型的"大规模变更";
  • protoc 中支撑大规模变更的工具;
  • 面向开源社区(OSS)的整体策略。

要理解这份文档,需要先建立两个基本概念。所谓 edition,是指 protoc 前端及其各后端(backend)所理解的一组"所有 feature 的默认值";所谓 feature,则是把"语义变化点"变成可用 option 显式声明的开关。二者关系在 Protobuf Editions 总览 中有更完整的阐述。

二、Defining Features:特性的两种形态

2.1 全局 feature 与语言级 feature

文档区分了两类 feature:

  • 全局 feature(Global features):即 proto.Features 消息中的字段。文档中记作 features.<name>,例如 features.enum
  • 语言级 feature(Language-scoped features):定义在某个语言的 typed extension field 中。文档中记作 features.(<lang>).name,例如 features.(proto.cpp).legacy_string

两者的工程成本差异显著:

  • 添加一个全局 feature 需要改动 descriptor.h,属于"重量级"操作——因为它还需要在 Descriptor 包装类中提供 helper,避免用户自己去解析 feature 的继承链。又因为它不局限于单一语言,所以必须谨慎决策、充分公开地文档化。
  • 语言级 feature 只需要改动对应后端的 feature 扩展,影响面(blast radius)更小(C++ 和 Java 后端除外,它们承载了更多全局逻辑)。这类 feature 通常只与代码生成(codegen)相关,不需要反射式内省。

一条关键原则:添加 feature 永远不是破坏性变更(never a breaking change)

2.2 源码佐证:Features 消息与扩展机制的实际落地

文档中描述的特性机制,在当前仓库的 descriptor.proto 中已有对应实体:

  • 全局 feature 以 Features 消息的字段形式存在。例如第 1067 行附近的 optional FieldPresence field_presence = 1 [...],正是文档"Large-scale Changes with Features Only"一节讨论的 features.field_presence 的实际字段;utf8_validation 等字段则对应文档 Do's and Don'ts 中提到的 features.utf8features.enum 一类开关。
  • 每个 feature 字段通过 edition_introduced / edition_defaults 注解标明它在哪个 edition 引入、各 edition 的默认值——例如 descriptor.protoedition_introduced: EDITION_2023edition_defaults = { edition: EDITION_2023, value: "EXPLICIT" } 这类条目,正是文档"feature 拥有 original default 与 desired default"原则在 schema 层的直接体现:默认值只会随着新 edition 的到来而翻转。
  • 语言级 feature 通过 descriptor.proto 中为各语言保留的扩展编号实现。features 设计文档 给出了这一扩展布局的原始提案(extensions 1000; // for features_cpp.proto),并展示了 option features.(pb.cpp).string_field_type = STRING_VIEW; 之类的用法。

2.3 Feature 的生命周期:transient(临时性)

文档对 feature 的生命周期有明确约定:

  • 一般来说,feature 应有一个 original default(初始默认值) 和一个 desired default(期望默认值):feature 的意义就在于随着整个生态的迁移推进,从前者逐步翻转到后者。因此大多数 feature 是 bool 或 enum。
  • 任何引入 feature 的迁移,都应规划在多年时间尺度内,最终将该 feature 从内部代码库和开源代码中一并废弃、删除。
  • 删除 feature 是破坏性变更,但不必绑定到某个 edition 上;在 OSS 中,feature 的删除必须合并进一个 breaking release 统一发布。
  • feature 的删除一般应提前一年向 OSS 社区公告。

2.4 Do's and Don'ts:哪些变更适合用 feature 控制

适合用 feature 控制的变更(宽泛列举):

  • 改变任意语法构造的生成 API(名字、行为、签名,甚至是否生成)。例如 features.(proto.cpp).legacy_string
  • 改变字段的序列化编码(前提是不破坏旧读者)。例如 features.packed,以及未来的 features.group_encoding
  • 改变字段的反序列化语义。例如 features.enumfeatures.utf8

虽然几乎任何语义变化都可以用 feature 来控制,但有些事情用 feature 会很别扭:

  • 改变语法本身。引入新的语法构造时,用 feature 门控它并没有实际好处,只是噪音。应避免改变"拼写方式"。在 Protobuf 历史上,这种需求极其罕见。
  • 改变 descriptor 的形状。feature 一般不应导致字段、消息或枚举 descriptor 的出现或消失。
  • 名字与字段编号。feature 不应改变 descriptor 中可见的语法实体名称或字段编号(这与"用 feature 改变生成 API 的名字"是两回事)。
  • 不兼容地改变 wire 编码。用 feature 改变 wire format 有长周期的注意事项,文档在"Group-Encoded Messages"一节专门讨论。

三、Proclaiming an Edition:版本的定义与宣布

3.1 edition 的定义权与默认值归属

edition 是一组 feature 默认值,被 protoc 前端及其后端共同理解。文档划定了清晰的职责边界:

  • Edition 编号由 protobuf-team 宣布(proclaim),但不一定由我们定义。
  • protoc 只定义全局 feature 的 edition 默认值;每个后端各自定义其语言级 feature 的 edition 默认值。
  • 第三方后端负责在自己侧调整跨 edition 的默认值。

3.2 Edition 的全序(Total Ordering)

文档原始设计是:FileDescriptorProto.edition 字段是一个字符串,以避免"一年内需要发放多个 edition"时的命名麻烦——即使发布了 edition = "2022";,必要时也可以追加 edition = "2022.1";

由于第三方后端要自己维护默认值,为最小化同步成本,文档引入了对 edition 的全序规则:edition 字符串按 '.' 切分,各分量按 a.len < b.len && a < b 排序,从而保证 9 < 10 这类直觉正确。按"年份"或"年份.修订号"的命名约定,得到:

2022 < 2022.0 < 2022.1 < ... < 2022.9 < 2022.10 < ... < 2023 < ... < 2024 < ...

有了全序,后端选择默认值时不必关心具体 edition 是什么,只需问:"这个 proto 是否早于我引入该默认值的那个 edition?" 文档举了一个假想的 Haskell 后端例子:若 feature.(haskell).more_monads 在 2023 变为 true,后端只需检查 file.EditionIsLaterThan("2023");若它在 2023.1 又变回 false,则改用 file.EditionIsBetween("2023", "2023.1")。这样后端只需在自己改动默认值时才需要变化。

但文档同时约束:后端不能随意使用 edition——只有在 protobuf-team 宣布下一个 edition 编号之后,后端才能开始"观察"该 edition,且不得使用未被宣布的编号。

关于全序规则的重要更新:文档中已用加粗注记指出,上述排序规则已被 Edition Naming 取代。后者(2023-08-25 批准)的最终方案是:proto 文件中仍写作 edition = "2023";,但解析器会立即将其转换为枚举,此后所有代码都以枚举处理。这一方案在当前源码中已经落地——descriptor.proto 定义了 enum Edition,其中 EDITION_2023 = 1000EDITION_2024 = 1001EDITION_2026 = 1002,取值"任意但永远按时间有序以便比较",另含 EDITION_UNKNOWN = 0EDITION_LEGACY = 900(表示"feature 引入之前的无穷远过去")、EDITION_PROTO2/EDITION_PROTO3(998/999,兼容旧语法的占位 edition)、EDITION_UNSTABLE = 9999 以及若干仅用于测试的占位值。相比之下,edition-naming.md 还记录了被否决的备选方案(枚举化命名、截断修订号、定长 edition、Edition 消息等)及其利弊分析,可以视为"全序如何一步步被简化为整数比较"的完整决策过程。

3.3 宣布(Proclamation)流程

"宣布"是一个两步过程:

  1. 提前数月公告:向 OSS 预告即将到来的 edition,并给出一个大致日期,表示计划在此时发布一个非破坏性版本,使 protoc 开始接受新 edition;
  2. 后端跟进发版:在该版本发布前后,希望改变默认值的后端应发布支持新 edition 的版本。若一个 edition 在发布超过一个月之后其语义才发生变化,属于失礼行为——虽然最终没有强制手段。

文档还承诺:

  • 每日历年度至少宣布一次 edition,即使第一方后端并不使用它;
  • 紧急情况下可宣布 Y.1Y.2 等修订版;得益于全序,只有真正急需新 edition 的后端才需要关注这类公告。第三方申请"计划外" edition 提升的指南尚待制定,目前逐案处理;
  • 需要一种规范方式查询"最新 edition":应在主页显眼位置展示,且 protoc --latest-edition 应输出 protoc 已知的新版 edition。其意图是让希望从外部生成 .proto 模板的工具,可以为新消息选择最新 edition。需要说明的是,从当前仓库 src/google/protobuf/compiler 目录的源码结构看,尚未检索到该标志及下文工具章节所列标志的命令行解析实现,它们更应视为设计文档规划的演进方向,而非当前 protoc 已具备的功能。

四、Large-scale Change Templates:四类变更范式的完整设计

文档给出了四种"大规模变更"的设计草图,按风险从低到高排列,是整个文档最具实操价值的部分。

4.1 Edition Zero:无功能变化的大规模变更

目标是让整个生态进入 "editions" 语法。这次迁移的特殊之处在于:不改变任何行为,只改变一批东西的"拼写"

分两步走:

  1. 人工迁移 syntax 的使用方:需要追查并手工升级所有使用 syntax 值(而非字段本身)的代码。这是一次由专人(文档幽默地称之为"Busy Beavers 或一小群配了合适兴奋剂的 protobuf-team 成员")执行的手工大规模变更。当 95% 的 syntax 调用方完成迁移后,就把各语言中该字段的所有访问器标记为 deprecated。由于此时 syntax 的值变得不可靠,这一步构成破坏性变更
  2. 引入 Edition Zero Features,并实现工具:能把一个 proto2proto3 文件加上 edition = "2023"; 和恰当的 option features.* = ...;,使每个文件保留原有行为。这第二步可以完全自动化,且无需任何破坏性变更

4.2 Immolation of required:仅用 feature 的大规模变更

目标是把字段从 features.field_presence = LEGACY_REQUIRED(即 edition 中 required 的拼写)迁移到 features.field_presence = EXPLICIT_PRESENCE。设计为两个阶段:

  • 阶段一:为 features.field_presence 引入新值 ALWAYS_SERIALIZE——行为像 EXPLICIT_PRESENCE,但当 has-bit 未设置时仍会序列化默认值(相当于 required 与 proto3 无标签字段的"杂交")。从 LEGACY_REQUIRED 转向 ALWAYS_SERIALIZE 永远安全,因为 required 本质上是初始化检查的约束(值必须存在);只要"总是提供某个值",旧读者就不会坏。required 字段本来就不设置 has-bit,所以这不算行为变化,但它允许写端逐渐偏离"真正设置该值"。
  • 阶段二:经过足够的构建时间(build horizon)后,可以假设所有读者都能容忍"值可能缺失"(虽然实际上没有写端会真的省略)。此时再从 ALWAYS_SERIALIZE 迁到 EXPLICIT_PRESENCE——若读者看不到该字段的记录,访问时得到默认值;而调用方实际上很少会去检查 required 字段的存在性,尽管技术上可以。
  • 收尾:当所有 required 字段走完两步后,LEGACY_REQUIREDALWAYS_SERIALIZE 两个枚举值即可作为变体删除(破坏性变更)。

4.3 带 edition 翻转的大规模变更:absl::string_view Accessors

背景是 C++ 中 string/bytes 类型字段的访问器返回 const std::string&,其错失的优化众所周知(文档不重复讨论),目标是全部迁移为返回 absl::string_view(类比 ctype = STRING_PIECE)。

具体设计(文档脚注 [^1] 解释了命名选择:ctype 这个名字历史包袱太重故被忽略;feature 叫 legacy_string 是因为"给 string 加 view 访问器"大概率不是唯一要做的——mutator 也要一并改):

  1. 引入 features.(proto.cpp).legacy_string,默认 true 的布尔 feature;对合适的字段设为 false 后,访问器变为"表示上不透明"(representationally opaque)。
  2. 该 feature 可在文件级或字段级设置;配合下文工具可以最小化 diff 影响。改变字段可能连带要求修改依赖 std::string x = proto.string_field(); 这类写法的代码,存在通常的"unspooling string"迁移注意事项。
  3. 当内部 95% 的改动落地后,在下一个 edition 中把 C++ 后端的 legacy_string 默认值翻转为 false。
  4. 再次用工具在内部代码库中自动删除该 feature 的显式设置,作为第二次大规模变更;这与"收尾最后 5% 内部改动"可以并行推进。
  5. 当所有 legacy 访问器都消失后,删除该 feature(破坏性变更)。

4.4 带 wire format 破坏的大规模变更:Group-Encoded Messages

背景:group(以 end-marker 分隔的子消息)的编解码比 length-prefixed 消息更廉价,把消息切换为 group 编码可能有 CPU 与内存收益。但不幸的是,这是 wire-breaking 变更——旧读者无法解析新消息。

文档给出参照 packed 先例的方案:

  1. 先放宽解析:修改 parser,使其接受"以 group 或 message 编码的消息字段"(即在 deserializer 中让 TYPE_MESSAGETYPE_GROUP 成为同义词)。浸泡(soak)三年[脚注 2 指出:三年的"预算"本身是任意的,源于"印度廉价手机"问题——现实中只能选一个数、开始迁移、出问题就停手;很难有比"指望"更好的策略,但 packed 是这一做法可行的存在性证明,只是代价高昂]。
  2. 三年之后,开始大规模变更:为内部代码库的消息字段添加 features.group_encoded(注意:editions 中其实不存在 group——它们只是带 features.group_encoded 的消息)。得益于漫长的等待期,旧读者" hopefully "不会措手不及。
  3. 当 95% 完成后,升级 protoc,在新 edition 中默认 features.group_encoded = true,工具照例清理显式设置。
  4. length-prefixed 消息大概率永远不会被彻底淘汰,因此这是罕见的一个"feature 永远存活"的案例。

4.5 四类范式的对照

范式 典型用例 是否破坏 wire 是否必然含 breaking 步骤 feature 结局
无功能变化 Edition Zero(进入 editions 语法) 是(syntax 访问器弃用后) 随 feature 演进
仅 feature required 消亡 是(枚举值删除) 删除
带 edition 翻转 legacy_string 是(feature 删除) 删除
wire format 破坏 group 编码 是(枚举值删除) 永久保留

四条范式共享同一个节奏骨架:先让读者双兼容 → 双写/浸泡 → 翻默认值 → 清理显式设置 → 删除 feature(breaking release)

五、Large-scale Change Tooling:protoc 迁移工具规划

文档提出三类工具来降低迁移成本,且全部计划发布到 OSS(需要强调:如前所述,从当前仓库 src/google/protobuf/compiler 的源码结构看,这些命令行入口尚未见实现,属于规划中的能力):

  1. Features GC(protoc --gc-features foo.proto:对处于 editions 模式的文件,基于文件声明的 edition,计算需要设置的最小(或启发式最小,若精确计算代价太高)feature 集合,输出一个 Protochangifier 的 ProtoChangeSpec,描述如何清理该文件——即删掉那些与 edition 默认值冗余的显式 option features.*
  2. Editions "adopter"(protoc --upgrade-edition -I... file.proto:把一个 proto2/proto3 文件升级到最新 edition,自动添加必要的 feature;隐式地运行 features GC,同样以 ProtoChangeSpec 形式输出改动。
  3. Editions "upgrader":对已经是 editions 模式的文件运行 --upgrade-edition,把它提升到 protoc 已知的最新 edition 并补上必要 feature;输出同样经过 features GC 的 ProtoChangeSpec

文档明确说"这远非我们需要的全部工具",但足以简化机器人和"beavers"(人工迁移团队)的工作,并与内部代码库专属工具配合。

六、The OSS Story:开源生态迁移策略

文档坦承一个前提:如果内部的大规模变更能力不能导出到开源,editions 就有分裂生态的风险。而内部迁移依赖的两大杠杆——全局审批(global approval)和有限的"官僚大棒"——在 OSS 中一个都没有:OSS 手里唯一的大棒是 breaking change,唯一胡萝卜是新 feature,不存在"OSS 版 TAP"。

因此 OSS 策略必须是以下要素的混合:

  • 说服用户:editions 是好事,能让 Protobuf 更易用、部署更便宜、生产更快;
  • 温和引导:通过 protoc 诊断(当旧 edition 将过时/已过时)和开发者工具(编辑器集成、新文件模板),把用户在新定义中引向新 edition;
  • 争取第三方后端厂商(如 Apple 之于 Swift):让他们意识到可以用 editions 修正历史错误,并且要主动设计对他们"有吸引力"的迁移方案;
  • 提供 Google 级迁移工具:包括上文工具,以及尽可能专门的工具;无法提供工具时,给出强调收益的详细迁移指南;
  • 明确的 breaking change 政策:公开承诺会在预公告的时间线(horizon)后定期删除旧 feature,把新改进"锁"在"完成迁移"之后。文档承认这是高风险主张——用户可能以"死扛不升级"回应,因此沟通规划(comms planning)至关重要

共同主题是沟通:把"升级是生态生活的一部分,而非需要躲避的东西"讲清楚——就像使用 Abseil 一样。文档还建议借鉴 Go(go fix 工具)与 Rust(rustfix 工具)的经验;Rust 有类似的 editions/epoch 机制,也有 feature gate,但那与 Protobuf 的 feature 不是同一概念;同时借鉴 Carbon 团队"升级是生活事实"的公开表态,在旁观者眼中形成 Google 的统一口径。

先例研究:Rust Editions

文档明确指出,Protobuf Editions 的设计直接受 Rust 版本(edition)系统启发,并做了详细对比:

  • 节奏:Rust 大约每三年发布一个新 edition,聚焦于"不阻碍互操作"的表面语言变更——不同 edition 的 crate 永远可以互相链接,"edition"是与语言/编译器版本并行的一个棘轮。关键词(如 async)、借用检查器语义、名字解析规则都通过 edition 引入过。
  • 棘轮差异:与 Protobuf 不同,Rust 承诺永久支持所有历史 edition,不存在"整个生态向前棘轮"的压力。
  • rustfix 的强制绑定:Rust 自带 rustfix(Cargo 项目中可经 cargo fix 运行),能把 crate 升级到新 edition,且 edition 变更被强制要求附带可让 rustfix 执行的迁移计划
  • 后果:由于没有 EOL 时间线,crate 升级最新 edition 的动力有限——新 edition 特性更好,但没有淘汰压力,crate 往往停留在旧 edition 以兼容旧编译器。对用户这是好故事(旧代码可以无限期运行),但对编译器是持续维护负担(要确保旧 edition 与新语言特性大体正确协作)。
  • 宏问题与 Protobuf 的对应物:Rust 中,旧 edition 的宏在新 edition 的 crate 中(或反之)可能表现不佳,编译器有缓解手段但无法完美,是"彻底转换"的难点。Protobuf 没有宏,但有富 descriptor(镜像输入文件的结构),文档指出这是需要警惕的同类风险源。
  • 结论:文档坦率评价 Rust 的迁移故事欠佳——它接受了无限期支持旧 edition,却每三年才出一个 edition。Protobuf 计划积极得多,应当研究 Rust 对旧版本的宽容中"哪些是不可避免的、哪些是显式设计选择"。
  • 脚注 [^3] 补充:Rust 的 feature gate 主要用于让人提前尝试不稳定实验特性,与 edition 大体正交、且绑定编译器版本;Rust 的 feature gate 一般不改变既有程序的语义,只是让新程序变合法;特性"稳定化"时 flag 即被移除,feature flag 不参与 Rust 的稳定性承诺。

七、结语:从文档到源码的验证路径

Life of an Edition 的核心价值在于把"语言语义级演进"从一次性的架构事件,拆解为一条可重复的流水线:定义 feature(非破坏)→ 引入 feature(非破坏)→ 双兼容浸泡 → edition 翻转默认值(非破坏)→ 工具化清理 → 删除 feature(破坏,提前一年公告)。四类变更范式(Edition Zero、required 消亡、string_view 访问器、group 编码)分别覆盖了"无行为变化、仅 feature、edition 翻转、wire 破坏"四种风险等级,共享同一节奏骨架。

结合仓库源码,读者可以沿以下路径验证这套设计:

  • descriptor.protoEdition 枚举(EDITION_2023/EDITION_2024/EDITION_2026 按时间有序,EDITION_LEGACY 表示 feature 引入前的"无穷远过去"),印证了 edition-naming.md 将字符串全序简化为整数比较的落地结果;
  • descriptor.protoFeatures 消息的 field_presenceutf8_validation 等字段及其 edition_introduced/edition_defaults 注解,印证了"feature 拥有初始与期望默认值、默认值只随新 edition 翻转"的原则;
  • protobuf-editions-design-features.md:feature 的语法形态(文件级/字段级 option、语言扩展号分配、继承语义);
  • what-are-protobuf-editions.md:editions 机制的总体背景。

需要向读者明示的边界:文档中规划的 protoc --gc-featuresprotoc --upgrade-editionprotoc --latest-edition 等工具,在当前仓库编译器源码中尚未检索到对应实现,应视为演进方向而非现成能力;ALWAYS_SERIALIZEfeatures.group_encoded 等则是针对具体迁移的设计草案。理解这些"已落地"与"规划中"的分界,是准确使用这份设计文档的关键。

登录后查看全文
热门项目推荐
相关项目推荐