Protobuf Editions 迁移规则详解:Prototiller 对 Edition Zero 的前后端特征转换需求设计
本文基于 protobuf 仓库中 Prototiller 设计文档 prototiller-reqs-for-edition-zero.md,完整解读 Edition Zero(即首个正式 edition "2023")大规模迁移工具的需求规格:包括 field_presence、enum_type、repeated_field_encoding、message_encoding、json_format 五个前端特征的 proto2/proto3 → edition 转换规则与示例,legacy_closed_enum 等后端特征的处理策略,以及 reserved 标识符语法等附带转换。读完本文,你能理解"no-op 迁移"是如何通过 feature 层级折叠被最小化的,并能对照仓库中的 golden 转换文件验证这些规则的真实落地形态。
背景:为什么 Edition Zero 需要一个大规模迁移工具
Edition Zero 的核心设计是用 features 统一 proto2 和 proto3:不再区分 syntax = "proto2" 与 syntax = "proto3",而是让每一个语义差异(字段 presence、枚举开闭、packed 编码、group 编码、JSON 行为等)都由一个可配置的 feature 及其 edition 默认值来表达。该设计有一个明确目标:任意 proto2/proto3 文件都可以通过恰当应用 features 转换为 editions 文件,且不产生任何语义变化(no-op)。
为了把 Google 内部仓库(以及帮助开源社区)从旧语法迁移到 editions 模型,项目引入了 Prototiller——一个类似 Buildozer 的 .proto 批量重构工具。本需求文档(作者 @mkruskal-google,2023-07-07 批准)专门定义 Prototiller 在"升级到 Edition Zero"这一最高优先级工作流中必须满足的转换规则;而更通用的 Prototiller 需求(变更规格模式、UpgradeEdition/CleanUpFeatures/ModifyFeature 三个 action 的接口设计)则定义在姊妹文档 Prototiller Requirements for Editions 中,二者需配合阅读。
总览:no-op 转换必然存在,但相当复杂
由于 Edition Zero 的 features 本来就是从 proto2/proto3 的行为差异"反推"出来的,从 proto2/proto3 到 Edition Zero 总存在一个 no-op 变换,但这个变换的细节相当复杂,依赖于很多不同因素。文档中提到,当时已存在一个临时脚本作为占位实现,覆盖了大部分规则,但有两个已知缺口:无法处理 extensions 中的 group,以及 oneof 中的 group。该脚本及其 golden 测试可作为 Prototiller 实现的质量基准。
这些 golden 测试后来正式沉淀在仓库中:输入样本位于 editions/input/edition2023_transform/(proto2.proto、proto3.proto、proto2_lite.proto、utf8 变体等),期望输出锁定在 editions/golden/edition2023_transform/。其中 golden 文件 proto2.proto 的注释写道:"This file contains various edge cases we've collected from migrating real protos in order to lock down the transformations",即这些用例来自真实 proto 的迁移经验,用于固定转换行为。
特征优化(Feature Optimization)
文档指出,让 Edition Zero 大规模变更"痛感"最小化的关键一环是 特征优化阶段,其原则有两条:
- 能不加就不加:如果某个 feature 不显式添加也能让升级成为 no-op,就不要添加,而应依赖该 edition 的默认值,把决策留给未来变更。
- 层级折叠缩小 diff:尽量把 feature 规格折叠到更高作用域(例如把大量字段级 feature 收敛为文件级默认值),从而最小化总变更规模。
这一思想与 Editions Tooling 中 "features janitor" 的设计一脉相承:janitor 会先把 edition 默认值沿 AST 向下传播到每个节点,再按"多数子节点取值优先、平局时倾向 edition 默认值"的启发式自底向上折叠显式 feature,最后删除所有可被 edition 默认值隐式推出的显式 feature。从源码结构看,edition 默认值本身的生成由 editions/defaults.bzl 中的 compile_edition_defaults 规则驱动(调用 protoc --edition_defaults_out ... --edition_defaults_minimum ... --edition_defaults_maximum ... 生成中间 feature set),再经 embed_edition_defaults 嵌入各语言生成器——这保证了"折叠到 edition 默认值"这一优化在整个工具链中有明确的数据来源。
前端特征转换规则
前端 feature 指的是所有语言生成器共享、作用于 proto 文件前端的语义特征。以下逐条给出转换规则与原文档示例。
field_presence:字段存在性
field_presence 默认值为 EXPLICIT,与 proto2/proto3 中 optional 字段行为一致;LEGACY_REQUIRED 对应 proto2 的 required 字段;IMPLICIT 对应 proto3 中非 optional 的字段。为最小化变更,应利用文件级默认值。
转换示例一(proto2 → edition):
// 迁移前
syntax = "proto2";
message Foo {
optional string bar = 1;
required string baz = 2;
}
// 迁移后
edition = "2023";
message Foo {
string bar = 1;
string baz = 2 [
features.field_presence = LEGACY_REQUIRED];
}
转换示例二(proto3 混合 optional/非 optional → edition):
// 迁移前
syntax = "proto3";
message Foo {
optional string bar = 1;
string baz = 2;
string bam = 3;
}
// 迁移后:文件级默认 IMPLICIT,仅 optional 字段显式标注
edition = "2023";
features.field_presence = IMPLICIT;
message Foo {
string bar = 1 [features.field_presence = EXPLICIT];
string baz = 2;
string bam = 3;
}
转换示例三(proto3 全为 optional → edition):
// 迁移前
syntax = "proto3";
message Foo {
optional string bar = 1;
optional string baz = 2;
}
// 迁移后:optional 字段与 edition 默认 EXPLICIT 完全一致,直接删除关键词即可
edition = "2023";
message Foo {
string bar = 1;
string baz = 2;
}
注意 editions 语法中 optional/required 关键词被彻底删除,单值字段一律按 proto3 风格书写(不带标签),presence 语义完全由 features.field_presence 决定——这也是特征定义文档中明确的设计决议。
enum_type:枚举开闭性
enum_type 默认值为 OPEN,与 proto3 行为一致;CLOSED 对应典型的 proto2 行为。同样应利用文件级默认值最小化变更。
proto2 枚举 → edition(需文件级声明 CLOSED):
// 迁移前
syntax = "proto2";
enum Foo {
VALUE1 = 0;
VALUE2 = 1;
}
// 迁移后
edition = "2023";
features.enum_type = CLOSED;
enum Foo {
VALUE1 = 0;
VALUE2 = 1;
}
proto3 枚举 → edition(默认即 OPEN,无需任何 feature):
// 迁移前
syntax = "proto3";
enum Foo {
VALUE1 = 0;
VALUE2 = 1;
}
// 迁移后
edition = "2023";
enum Foo {
VALUE1 = 0;
VALUE2 = 1;
}
repeated_field_encoding:重复字段编码
repeated_field_encoding 默认值为 PACKED,与 proto3 一致;EXPANDED 对应 proto2 的默认行为。两种旧语法都允许用 packed 字段选项覆盖默认值,迁移时这些都应被替换为 feature 写法。这里的变更最小化更复杂,因为可能存在大量 repeated 字段被显式覆盖的文件。
proto2 混合默认/覆盖 → edition:
// 迁移前
syntax = "proto2";
message Foo {
repeated int32 bar = 1;
repeated int32 baz = 2 [packed = true];
repeated int32 bam = 3;
}
// 迁移后:文件级 EXPANDED,被显式 packed 的字段单独标注
edition = "2023";
features.repeated_field_encoding = EXPANDED;
message Foo {
repeated int32 bar = 1;
repeated int32 baz = 2 [
features.repeated_field_encoding = PACKED];
repeated int32 bar = 3;
}
proto3 显式 packed = false → edition:
// 迁移前
syntax = "proto3";
message Foo {
repeated int32 bar = 1;
repeated int32 baz = 2 [packed = false];
}
// 迁移后
edition = "2023";
message Foo {
repeated int32 bar = 2;
repeated int32 baz = 2 [
features.repeated_field_encoding = EXPANDED];
}
proto2 中 packed = true 与 edition 默认 PACKED 重合 → 直接删除:
// 迁移前
syntax = "proto2";
message Foo {
repeated int32 x = 1 [packed = true];
// Strings are never packed.
repeated string z = 1;
repeated string w = 2;
}
// 迁移后:packed = true 与默认值一致,删除;字符串字段本来就不 packed
edition = "2023";
message Foo {
repeated int32 x = 1;
repeated string z = 1;
repeated string w = 2;
}
message_encoding:group 的替代品
message_encoding feature 用于取代 proto2 专属的 group 语法:其取值 DELIMITED 表示按 group(wire type 3/4)编码,而默认值永远是 LENGTH_PREFIXED(wire type 2)。这是一个相对"别扭"的变换,因为 group 定义允许出现在任何字段可出现的地方,即使 message 定义不允许。基本转换规则是:在最内层包围作用域内创建一个与字段同名的新 message 类型,字段名改为小写并使用该类型。
普通 group → edition:
// 迁移前
syntax = "proto2";
message Foo {
optional group Bar = 1 {
optional int32 x = 1;
}
optional Bar baz = 2;
}
// 迁移后
edition = "2023";
message Foo {
message Bar {
int32 x = 1;
}
Bar bar = 1 [features.message_encoding = DELIMITED];
Bar baz = 2;
}
oneof 内的 group → edition(注意 group 字段必须留在 oneof 内,而 message 定义被提升到外层作用域):
// 迁移前
syntax = "proto2";
message Foo {
oneof foo {
group Bar = 1 {
optional int32 x = 1;
}
}
}
// 迁移后
edition = "2023";
message Foo {
message Bar {
int32 x = 1;
}
oneof foo {
Bar bar = 1 [
features.message_encoding = DELIMITED];
}
}
json_format:JSON 映射行为
json_format 是个"离群"特征——至少在 Edition Zero 中它只影响 proto 文件的前端构建检查。ALLOW(proto3 行为)会对字段名启用所有 JSON 映射冲突检查,除非设置了 deprecated_legacy_json_field_conflicts;LEGACY_BEST_EFFORT(proto2 行为)则关闭这些检查。理想的最小转换策略是:除了设置了 deprecated_legacy_json_field_conflicts 或存在 JSON 映射冲突的文件外,其余全部切到 ALLOW;这些例外情况回退到 LEGACY_BEST_EFFORT。文档同时给出了备选方案:如果 Prototiller 难以处理,可以在迁移完成后用一次大规模变更,把所有构建能通过的 LEGACY_BEST_EFFORT 实例清理掉。
proto2 无冲突 → edition(默认即 ALLOW,不加 feature):
// 迁移前
syntax = "proto2";
message Foo {
optional string bar = 1;
optional string baz = 2;
}
// 迁移后
edition = "2023";
message Foo {
string bar = 1;
string baz = 2;
}
proto3 无冲突 → edition(仅处理 presence):
// 迁移前
syntax = "proto3";
message Foo {
string bar = 1;
string baz = 2;
}
// 迁移后
edition = "2023";
features.field_presence = IMPLICIT;
message Foo {
string bar = 1;
string baz = 2;
}
proto2 存在 JSON 字段名冲突(bar 与 bar_ 的 JSON 名均为 "bar",在 proto2 中仅是 warning)→ 回退 LEGACY_BEST_EFFORT:
// 迁移前
syntax = "proto2";
message Foo {
// Warning only
string bar = 1;
string bar_ = 2;
}
// 迁移后
edition = "2023";
features.json_format = LEGACY_BEST_EFFORT;
message Foo {
string bar = 1;
string bar_ = 2;
}
proto3 显式设置 deprecated_legacy_json_field_conflicts → 该选项删除,文件回退 LEGACY_BEST_EFFORT:
// 迁移前
syntax = "proto3";
message Foo {
option
deprecated_legacy_json_field_conflicts = true;
string bar = 1;
string baz = 2 [json_name = "bar"];
}
// 迁移后
edition = "2023";
features.field_presence = IMPLICIT;
features.json_format = LEGACY_BEST_EFFORT;
message Foo {
string bar = 1;
string baz = 2;
}
后端特征转换规则
后端(语言专属)feature 通过 FeatureSet 的扩展字段实现,例如 features.(pb.cpp).xxx、features.(pb.java).xxx。文档特别强调一条防膨胀原则:最好先检查某个 proto 文件是否真的被用于生成目标语言的代码;如果没有,就没有理由为其添加该语言的后端专属 feature。
legacy_closed_enum:Java/C++ 的"遗留闭枚举"怪癖
Java 和 C++ 默认会把 proto3 枚举视为闭枚举(closed),前提是该枚举被用于 proto2 消息中(即以消息所在文件的语法为准,而非枚举声明处)。内部曾可用 cc_open_enum 字段选项覆盖这一行为,但其使用极少,可能不值得考虑。虽然枚举开闭语义仍由前端 enum_type 决定,但对于"proto2 文件使用 proto3 消息"的场景(反向组合则不被允许),需要额外添加这个后端 feature。
转换示例(proto2 文件 import proto3 文件,使用其中的枚举):
// 迁移前
syntax = "proto2";
import "some_proto3_file.proto"
enum Proto2Enum {
BAR = 0;
}
message Foo {
optional Proto3Enum bar = 1;
optional Proto2Enum baz = 2;
}
// 迁移后:需要 import 两个后端 feature 文件
edition = "2023";
import "third_party/protobuf/cpp_features.proto"
import "third_party/protobuf/java_features.proto"
import "some_proto3_file.proto"
features.enum_type = CLOSED;
message Foo {
Proto3Enum bar = 1 [
features.(pb.cpp).legacy_closed_enum = true,
features.(pb.java).legacy_closed_enum = true];
Proto2Enum baz = 2;
}
这条规则在仓库中有完整的源码级印证。C++ 侧该 feature 定义于 cpp_features.proto:CppFeatures.legacy_closed_enum 的目标为 TARGET_TYPE_FIELD 和 TARGET_TYPE_FILE,并且通过 feature_support.edition_defaults 声明了 EDITION_LEGACY → "true"、EDITION_PROTO3 → "false" 的默认值——恰好对应"legacy 语法下 proto2 消息内的 proto3 枚举按 closed 处理"这一行为;其 deprecation_warning 还写明该行为计划在 edition 2025 移除。Java 侧的对应定义见 java_features.proto。
golden 测试文件同样固化了这一转换:editions/golden/edition2023_transform/proto2.proto 中的 TestOpenEnumMessage 对来自 proto3 的枚举字段 open_enum_field 同时标注了 features.(pb.cpp).legacy_closed_enum = true 与 features.(pb.java).legacy_closed_enum = true,而本文件的 closed_enum_field 则不加标注——与需求文档的示例完全一致。
UTF8 Validation
utf8_validation 特征在文档成文时仍待 Editions Zero Feature: utf8_validation 提案批准(该提案未对外发布)。从后续落地的仓库代码看,该特征最终以 features.utf8_validation 的形式存在:golden 文件 proto2.proto 中 proto2 输入迁移后带有 option features.utf8_validation = NONE;(对应 proto2 对字符串不强制 UTF-8 校验的行为),且存在专门的 proto2_utf8_disabled.proto / proto3_utf8_disabled.proto 输入用例来锁定这一转换。
其他转换:reserved 标识符语法
除 features 外,Edition Zero 还引入了一些附带变化。依据 Protobuf Change Proposal: Reserved Identifiers(未对外发布)的决议,reserved 字段从字符串切换为标识符。这"应该"是个平凡改动,但如果 proto 文件中出现了不是合法标识符的字符串,就存在歧义:它们今天会被忽略,但也可能是拼写错误,不能盲目删除。因此转换策略是为它们留下一条注释。
合法标识符 → 直接去引号:
// 迁移前
syntax = "proto2";
message Foo {
reserved "bar", "baz";
}
// 迁移后
edition = "2023";
message Foo {
reserved bar, baz;
}
非法标识符 → 保留为注释:
// 迁移前
syntax = "proto2";
message Foo {
reserved "bar", "1";
}
// 迁移后
edition = "2023";
message Foo {
reserved bar;
/*reserved "1";*/
}
规则落地的完整对照:golden 转换文件
仓库 editions/golden/edition2023_transform/ 目录下的 golden 文件是上述规则的"验收基准"。以 proto2.proto 为例,可以看到多种规则叠加后的真实形态:
- 文件头:
edition = "2023";,并带// LINT: ALLOW_GROUPS与文件级option features.repeated_field_encoding = EXPANDED;、option features.utf8_validation = NONE;(来自 proto2 默认行为的文件级折叠); - group 转换:
OptionalGroup嵌套 message +OptionalGroup optionalgroup = 16 [features.message_encoding = DELIMITED](第 84–90 行),字段名按规则小写化; - 枚举转换:
TestEnum内部使用option features.enum_type = CLOSED;(枚举级而非文件级,因为同文件还有 open 枚举); - 后端特征:
TestOpenEnumMessage.open_enum_field上的(pb.cpp)/(pb.java).legacy_closed_enum双标注; - packed 覆盖:
int_field_packed = 9 [features.repeated_field_encoding = PACKED, features.(pb.proto1).legacy_packed = true]——除规则文档描述的转换外,golden 文件还显示迁移工具会追加legacy_packed这类用于兼容旧行为检测的标记,可见实际转换比需求文档示例更细。
配套的 proto3.proto、noop.proto(已是 editions 的文件应保持原样)、test_messages_proto2/3_editions.proto(对应 editions/golden/ 顶层的 test_messages 系列)共同覆盖了"全量转换 / 零转换 / 大消息文件转换"三类场景。
Prototiller 的使用方式(配套接口设计)
本需求文档约束的是"规则",而"规则如何被执行"由通用需求文档 prototiller-reqs-for-editions.md 定义。其推荐方案是 Prototiller 接受一个 ChangeSpec 消息作为输入,核心 action 有三种:
UpgradeEdition:把文件升级到指定 edition(把 syntax 模式视为一种"奇怪的、特殊的 edition"),保证升级是 no-op,这是最高优先级工作流;CleanUpFeatures:执行本文"特征优化"一节的折叠算法,把显式 feature 压缩到最少;ModifyFeature:按路径模式(如["foo", "Bar", "*"])批量修改某个 feature 的取值或删除它,服务于 Schema Consumer 的细粒度管控。
ChangeSpec 还带有 allow_unsafe_wire_format_changes、allow_unsafe_text_format_changes、allow_unsafe_json_format_changes 三个安全开关(默认全为 false):因为 Protochangifier 需要知道哪些变更会影响 wire/text/JSON 格式并据此标记为不安全。Editions Tooling 进一步规划了工具形态:janitor / editions adopter / editions upgrader 三种模式打包进 protoc,通过 protoc --change_spec=spec.pb --change_out=foo-changed.proto foo.proto 或 --janitor 等分析开关调用,upgrader 的流程正是"先把旧 edition 默认值全部显式化 → 改 edition → 跑 janitor 按新 edition 折叠",三步各自 no-op,合起来完成版本跃迁。
适用前提与限制
- docs/design/prototiller/ 的 README 明确说明:这些是 Prototiller 项目的历史设计文档,反映其发布时的状态,可能已经过时,仅具历史价值,不应视为当前实现状态的最新说明。阅读时应以仓库中实际生效的代码(如
editions/下的 golden 测试与src/google/protobuf/compiler/下的实现)为准。 - 文档示例中的
edition = "2023"是首个正式 edition;legacy_closed_enum等过渡性特征带有明确的废弃计划(C++ 侧标注将于 edition 2025 移除),迁移工具生成的中间态代码需结合后续 edition 升级逐步清理。 - 文档中引用的 Editions Zero Feature: utf8_validation 与 Protobuf Change Proposal: Reserved Identifiers 为内部提案(not available externally),其细节只能从本仓库的最终实现与 golden 文件中反推。
综上,这份需求文档的价值在于把"proto2/proto3 → Edition Zero 的 no-op 迁移"从一句口号拆解成了可逐条实现、可逐条用 golden 测试验证的转换规则:五个前端 feature 各有一套"默认值对齐 + 例外显式标注 + 文件级折叠"的策略,后端 feature 则按"是否真的生成该语言代码"按需附加,再叠加 reserved 标识符等语法级清理——这正是 Prototiller 能够支撑大规模 editions 迁移的规则基础。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00