首页
/ Protobuf Editions Tooling 设计:用 Janitor、Adopter 与 Upgrader 自动完成 Edition 迁移

Protobuf Editions Tooling 设计:用 Janitor、Adopter 与 Upgrader 自动完成 Edition 迁移

2026-09-04 09:22:08作者:曹令琨Iris

本文基于 protobuf 仓库中的设计文档 Editions Tooling 展开,系统讲解 Protobuf Editions 项目配套的自动化工具链设计:为什么 edition 迁移必须由工具而非人来完成、三个核心工具(features janitor、editions adopter、editions upgrader)各自解决什么问题、它们共同使用的 ProtoChangeSpec 变更描述语言如何定义,以及这套工具如何以 protoc 模式的形式融入命令行体验。读完后,你将理解 editions 迁移的完整算法思路(以"显式化—切版本—再清理"的三步无操作转换为核心)、janitor 的最小化启发式算法,并能对照仓库中的变更规格 schema 与迁移测试资产评估这套设计在真实迁移中的落地方式。

需要特别说明:该文档所在的 docs/design/prototiller/ 目录在仓库中被明确标注为历史性设计文档(historical design documents),其状态是文档当初发布时的样子,可能已过时,不应视为当前实现的文档。本文以设计文档为主体,仓库中现有的 editions 相关实现(如 editions/defaults.bzl、迁移测试的输入/黄金文件等)仅作为佐证材料。

一、背景:Editions 为什么需要强工具支持

理解 Editions Tooling,先要理解它服务的目标。Protobuf Editions 设计文档 定义了核心机制:用 edition = "..." 取代 syntax = "proto2"/"proto3",并用 feature(一种特殊的 option,可挂在 file/message/enum/field 等任意语法实体上)控制代码生成与运行时行为。Feature 支持继承:父实体的 feature 值会递归传播给子实体,除非子实体显式覆盖;edition 本身只是"一组 feature 默认值"。

editions-tooling.md 开宗明义地指出了 Editions 的迁移约束:

Editions 旨在引入 Protobuf 的新语义,但重点在于机械的、增量可升级性(mechanical, incremental upgradability),以避免 proto2/proto3 那种"两套系统并存"的问题。第一个 edition(大概率叫 "2023")将引入 proto2 和 proto3 都允许的收敛语义(converged semantics),使任何非 editions 文件都能以最少的人工干预变成 editions 文件。

文档为这套工具链设定了两个明确目标:

  1. 压缩人工改动面:在 editions 领域,非自动化的大规模改动(large-scale change)应被限制在"修复生成代码的使用方式"和"在特定字段(或其他声明)上翻转 feature"这两类工作内;
  2. 给外部用户最无痛的迁移路径:迁移体验就是"运行这个工具,然后提交结果"(run this tool and commit the results)。

文档同时交代了一个关键前提:这套设计假设 Protochangifier Backend Design Doc(该文档不对外公开)作为前置工作集成进 protoc,因此工具可以作为 protoc 的一部分发布。原因是——工具必须知道某个 edition 的完整定义(即该 edition 下所有 feature 的默认值集合)才能工作,这几乎硬性要求它与 protoc 链接在一起。

二、三大工具与共同语言:ProtoChangeSpec

设计文档规划了三个工具,它们本质上都是对 .proto 文件做语义级重写的"分析模式":

工具 形态 输入 输出 职责
Features Janitor protoc 的一种模式 一个 .proto 文件 ProtoChangeSpec 增删 feature,使文件显式 feature 更少但语义不变
Editions Adopter protoc 的一种模式 proto2/proto3 文件 ProtoChangeSpec 把文件纳入 editions 模式,起始于指定 edition
Editions Upgrader adopter 的泛化 editions 文件 ProtoChangeSpec 把文件升级到更新的 edition

三者共同以 ProtoChangeSpec(变更规格)为"语言"描述如何修改文件。文档特别指出,除了变更规格形态,还应该提供就地(in-place)版本——对只想原子地对整个工程跑一遍工具的开源用户来说,后者通常更有用。

变更规格的 schema:仓库中的配套证据

editions-tooling.md 只说工具"讲 ProtoChangeSpec",具体的 schema 由同目录的 Prototiller Requirements for Editions 文档给出。该文档将工具定位成"Prototiller——Protobuf 的批量重构瑞士军刀,类似于 Buildozer",并给出了如下建议 schema(原文照录):

syntax = "proto2";

package prototiller;

// This is the proto that Prototiller accepts as input.
message ChangeSpec {
  // Actions to execute on the file.
  repeated Action actions = 1;

  // Some changes may result in a wireformat break; changing field type is
  // usually unsafe. By default, Prototiller does not allow such changes,
  // users can set allow_unsafe_wire_format_changes to true to force the change.
  optional bool allow_unsafe_wire_format_changes = 2 [default = false];
  optional bool allow_unsafe_text_format_changes = 3 [default = false];
  optional bool allow_unsafe_json_format_changes = 4 [default = false];
}

// A single action. See messages below for description of their
// semantics.
message Action {
  oneof kind {
    UpgradeEdition upgrade_edition = 20;
    CleanUpFeatures clean_up_features = 21;
    ModifyFeature modify_feature = 22;
  }
}

// Upgrades the edition of a file to a specified edition.
// Treats syntax mode as being a weird, special edition that cannot be
// upgraded to.
//
// This action is always safe.
message UpgradeEdition {
  // The edition to upgrade to.
  optional string edition = 1;
}

// Cleans up features in a file, such that there are as few explicitly set
// features as necessary.
//
// This action is always safe.
message CleanUpFeatures {}

// Modifies a specific feature on all syntax elements that match and which can
// host that particular feature.
//
// Prototiller must be aware of which changes affect wire format, so that it
// can flag them as unsafe.
message ModifyFeature {
  // The name of the feature to modify.
  repeated proto2.UninterpretedOption.NamePart feature = 1;

  // A pattern for matching paths to syntax elements to modify.
  //
  // Elements of this field can either be identifiers, or the string "*", which
  // matches all identifiers. Thus, ["foo", "Bar"] matches the message foo.Bar,
  // ["foo", "Bar", "*"] matches all fields and nested types of foo.Bar
  // (recursively), and ["*"] matches all elements of a file.
  repeated string path_pattern = 2;

  // The value to set the feature to. If not set, this means that the
  // feature should be deleted.
  oneof value {
    int64 int_value = 20;
    double double_value = 21;
    // ... and so on.
  }
}

这个 schema 与三大工具一一对应:UpgradeEdition 即 adopter/upgrader 的动作;CleanUpFeatures 即 janitor 的动作;ModifyFeature 则支持在特定语法元素上修改 feature(对应总目标中"在特定字段上翻转 feature"这一类残留人工工作)。schema 设计上有几个值得注意的点:

  • 安全开关分层:三个 allow_unsafe_*_changes 布尔量分别控制线格式、文本格式、JSON 格式三类不兼容改动的放行,默认全部为 false。文档声明 UpgradeEditionCleanUpFeatures "always safe",因为它们保证是语义无操作(semantic no-op);只有 ModifyFeature 可能触线格式问题,因此需要工具识别哪些改动影响 wire format 并标记为 unsafe。
  • 路径模式匹配path_pattern 支持 * 通配,使同一份变更规格既能精确到单个元素("Schema Consumers 随 .proto 文件一起 check in 变更规格"的场景),又能泛化到全文件乃至整个工程的大规模改动。
  • 可扩展性Action 用 oneof 封装动作类型,为未来新增变更动作留了扩展位;需求文档也明确 schema 只是"这些需求的图示",实现方可以调整,约束性的是需求本身。

三、Features Janitor:把显式 feature 收敛到最少

3.1 它解决什么问题

Janitor("清扫器")的定位是迁移流程中的定期清理环节:大规模翻转 feature 之后,文件里会残留大量显式 feature 声明,janitor 负责在不改变语义的前提下把它们压缩掉。文档给出了一个完整的前后对比示例。

清理前的文件——每个字段都显式声明了 C++ 后端的 string_type feature:

edition = "2023";
message Foo {
  optional string a = 1 [features.(pb.cpp).string_type = VIEW];
  optional string b = 2 [features.(pb.cpp).string_type = VIEW];
  optional string c = 3 [features.(pb.cpp).string_type = VIEW];
  optional string d = 4 [features.(pb.cpp).string_type = VIEW];
  optional string e = 5 [features.(pb.cpp).string_type = VIEW];
}
message Bar {
  optional string a = 1 [features.(pb.cpp).string_type = VIEW];
  optional string b = 2;
  optional string c = 3;
  optional string d = 4;
  optional string e = 5;
}

清理后的文件——Foo 里所有字段取值一致,声明被上提为消息级 option,字段回归简洁;Bar 只保留那个"异类"字段 a 的显式声明:

edition = "2023";
message Foo {
  option features.(pb.cpp).string_type = VIEW;
  optional string a = 1;
  optional string b = 2;
  optional string c = 3;
  optional string d = 4;
  optional string e = 5;
}
message Bar {
  optional string a = 1 [features.(pb.cpp).string_type = VIEW];
  optional string b = 2;
  optional string c = 3;
  optional string d = 4;
  optional string e = 5;
}

这正是 what-are-protobuf-editions.md 中"feature 继承"设计的直接体现:feature 可以声明在更高层实体(如 file)上以覆盖其内所有定义,目的是"因化高频出现的 feature 声明,减少迁移期间的杂乱"。

3.2 最小化算法:一个刻意不求最优的启发式

文档坦承,真正求"显式 feature 最少"是非线性问题,因此设计了一个启发式(heuristic),思路是:

  1. 区分 critical 与 grouping:对每个 AST 节点,一个可显式出现的 feature 要么是关键(critical)的(如 string_type 对 field 是关键的),要么只用于分组(grouping)(如 string_type 对 message 只是用来给子节点分组,本身不作用于 message)。
  2. 显式化全部 feature:把 feature 显式传播到每个节点,包括 edition 默认值——即先让整棵语法树上没有"隐式"值。
  3. 自底向上归并:对每个 feature f、每个对 f 非关键但其(递归地)包含 f 关键节点的节点 n(按 DFS 序):把 n 上的 f 设为其直接子节点中占多数的取值,并从那些子节点上删除显式 f。平票时,若 edition 默认值在多数取值之列则选 edition 默认值,否则任选。
  4. 删除被 edition 默认值蕴含的声明:重复步骤 3 直到根节点后,删除所有"从根出发、不跨越其他非 edition 默认值的显式 feature 即可到达"的显式 feature——即被 edition 默认值蕴含的那些声明。

文档明确表态:容易构造出该算法非最优的例子,"但那不重要"。Janitor 的存在只是为了让文件更漂亮同时保持等价;而且由构造可知,该算法必然满足"语义无操作"要求——这是它敢于被激进运行的根基。这一点在 需求文档 的"已考虑的替代方案"中得到呼应:把 feature 清理设计成独立动作(而不是隐含在其他动作里),正是因为希望能在到处激进地跑这个操作,甚至作为 Cider 等 IDE 的 "format on save" 的一部分。

四、Adopter 与 Upgrader:统一的三步无操作转换

文档指出,adopter 只是 upgrader 的特殊情形——只要把 proto2/proto3视为一种 edition(edition 的本质就是"一组默认值"),"从 proto3 到 edition 2023"与"从 edition 2023 到 edition 2024"就是同一个问题。因此文档只描述 upgrader。

把一个 edition("old")更新为另一个 edition("new",不要求真的更新)的算法只有三步,且每一步单独看都是 no-op

  1. 显式化:把所有尚未在顶层显式设置的 feature,设为 "old" 给出的默认值。它们只设在最外层没有显式 feature 的作用域上:对文件级 feature 来说,就是把所有 feature 在文件级设为显式;对不是文件级的 message 级 feature 来说,就是在所有顶层 message 上放一个显式 feature。由于 edition = "old"; 本就蕴含这些取值,这一步是 no-op。
  2. 切换版本:把文件的 edition 从 "old" 改为 "new"。因为所有可能显式的 feature 都已显式化,切换 edition 只改变默认值表、而没有任何"隐式"取值在吃这些默认值,所以这一步也是 no-op。
  3. 运行 feature janitor:janitor 会把所有 feature 显式传播一遍(此时它们都已在顶层显式设定),然后按 "new" edition 清理;注意 janitor 在平票时偏好 edition 默认值,所以清理结果天然贴近新 edition 的默认风格。由于 janitor 自身就是 no-op,这一步同样是 no-op。

三步各自无操作,组合起来也必然无操作——这就是 adopter/upgrader "always safe" 声明的算法依据。这个"先显式化、再换表、后清理"的流程同时解释了为什么 janitor 是整套工具链的地基:upgrader 直接复用了 janitor 的清理能力。

真实迁移规则与仓库中的基准测试

上述三步是 edition 之间默认值差异的通用处理;而 adopter 场景(proto2/proto3 文件进入 editions)还涉及大量前端/后端语义到 feature 的具体映射规则,例如(摘自 Prototiller Requirements for Edition Zero):

  • Field Presencefield_presence 默认 EXPLICIT(对应 proto2/proto3 的 optional);LEGACY_REQUIRED 对应 proto2 required 字段;IMPLICIT 对应 proto3 非 optional 字段。proto3 文件通常应设置文件级 features.field_presence = IMPLICIT;,仅对 optional 字段在字段级覆写为 EXPLICIT
  • Enum Typeenum_type 默认 OPEN(proto3 行为),CLOSED 对应 proto2 常规行为。
  • Repeated Field Encodingrepeated_field_encoding 默认 PACKED(proto3 行为),EXPANDED 对应 proto2 默认;[packed = true/false] 字段选项在迁移中被替换为 feature。
  • Message Encodingmessage_encoding 用于取代 proto2 专属的 group 语法(DELIMITED 值),默认 LENGTH_PREFIXED。group 的转换是"在最近的包围作用域创建与字段同名的新 message 类型,字段名改小写并改为该类型"——这是三步流程之外的结构性改动。
  • JSON Formatjson_formatALLOW(proto3 行为)启用 JSON 映射冲突检查,LEGACY_BEST_EFFORT(proto2 行为)禁用;当存在冲突或设置了 deprecated_legacy_json_field_conflicts 时回退到 LEGACY_BEST_EFFORT

该文档还提到,当时已有一个"临时脚本"作为占位实现覆盖了大部分规则(局限是不能处理 extensions 或 oneof 内部的 group),"连同它的黄金测试(golden tests),可以成为 Prototiller 的有用基准"。仓库中确实保留着这样一对迁移测试资产:输入文件位于 editions/input/edition2023_transform/(含 proto2.protoproto3.protonoop.protoproto2_lite.protoproto2_utf8_disabled.protoproto3_utf8_disabled.proto),对应的期望输出(黄金文件)位于 editions/golden/edition2023_transform/。这类"输入 proto → 期望转换结果"的成对文件,正是文档所述迁移规则的自动化回归基准;editions/generated_files_test.cc 等测试则对生成产物做断言。edition 的默认值表本身由 editions/defaults.bzl 维护,并有 editions/defaults_test.cc 对其做一致性测试——这正是 upgrader 第一步("把未显式设置的 feature 设为 old 的默认值")所依赖的数据。

需要严谨说明的是:以当前仓库为检索范围,在源码(如 src/upb_generator/)中并未检索到 ProtoChangeSpec/--change_spec/janitor 等对应实现,这些工具属于设计阶段产物,本文对其的描述均为设计意图而非可用命令;仓库当前可用的是上述测试资产与 edition 默认值设施。

五、与 protoc 集成的 UX 设计

editions-tooling.md 的 UX 部分规定了所有捆绑进 protoc 的 Protochangifier 工具的统一交互模式,两条规则覆盖了"先审查后应用"与"直接就地修改"两种工作流:

规则一:--change_spec 标志——把一份变更规格应用到传入的 .proto 文件:

protoc --change_spec=spec.pb --change_out=foo-changed.proto foo.proto

把变更写入 foo-changed.proto--change_out 可以指向与 foo.proto 相同的文件(就地更新),也可以省略(变更打印到 stdout)。文档称这是 Protochangifier 的核心入口点:janitor/adopter/upgrader 产生的 ProtoChangeSpec 都可以经由此入口应用,实现"生成规格 → 人工审查 → 应用"的两段式迁移。

规则二:每个分析一个分析标志——如 --janitor,可选参数指向规格输出路径:

protoc --janitor=spec.pb foo.proto        # 输出变更规格到 spec.pb,不改动文件
protoc --janitor foo.proto               # 未提供路径时,直接就地应用变更

不传参数时,变更就地生效、无需再走 protoc --change_spec——这就是给"只想原子地跑一遍工具"的开源用户的就地版本。

为什么做成 protoc 的模式而不是独立工具?

文档专门论证了"捆绑进 protoc"这一分发决策:

  • 分发与可发现性:让工具跟编译器一起发布,用户最容易找到;
  • 用户心智模型:对外宣称"这本来就是编译器的一部分",有利于教育用户。团队预计会基于 Protochangifier 持续产出新的迁移工具,教会用户"所有分析入口长一个样子"(--<analysis>[=spec.pb])比每次教一个新工具成本低得多;
  • 类比 Rust:Rust 的 rustfix(用于 edition 升级等场景)虽然是独立二进制,但通过 cargo fix 暴露,而 cargo 在很大程度上是 Rust 面向用户的接口。把迁移工具放进"瑞士军刀"(protoc)能把它推到用户面前。

六、设计权衡小结

把三份文档对照起来,这套 Editions Tooling 设计的几条主线清晰可见:

  1. 安全是第一约束:adopter/upgrader 用"三步各自 no-op"的构造证明安全;CleanUpFeatures/UpgradeEdition 在 schema 层面被标注 always safe;ModifyFeature 通过 allow_unsafe_* 开关与 wire format 影响识别来兜底。迁移工具"敢让用户提交结果"的前提是"结果必然等价"。
  2. janitor 是一切的地基:它既服务 upgrader(第 3 步直接复用),又是可独立激进运行的清理动作(甚至可进 IDE 的 format on save);其启发式算法牺牲了最优性换取了"由构造保证语义无操作"的强性质。
  3. 统一规格 + 统一入口ProtoChangeSpec/ChangeSpec 让"精确到单个元素"与"覆盖整个工程"共用同一种语言;--change_spec--<analysis> 两组标志把"审查—应用"与"一步到位"两条路径收敛到同一个 protoc 二进制里。
  4. 与仓库现实的边界docs/design/prototiller/README.md 声明该目录文档"纯粹出于历史价值"发布;从当前仓库源码结构看(编译器目录中未见 Protochangifier 相关实现),这些工具的具体形态以设计文档为准,读者应把它们作为理解 editions 迁移思路(feature 继承、显式化/换表/清理三步法、no-op 证明)的一手资料,而不是可执行的命令行手册。仓库中可直接验证的部分是 edition 默认值设施(editions/defaults.bzleditions/defaults_test.cc)与 edition2023_transform 输入/黄金文件对(editions/input/edition2023_transform/editions/golden/edition2023_transform/)。

对于正在评估 editions 迁移方案的团队,这份设计的可迁移经验是:先把"目标版本的默认值表"作为工具输入(工具必须知道 edition 的完整定义),再把迁移拆成"显式化 → 换默认值表 → 按新表清理"三步以逐步获得 no-op 证明,最后用一个统一的可序列化变更规格把工具产出接进代码评审流程——这正是"运行工具、提交结果"这一最无痛迁移体验背后的工程骨架。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
982
503
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384