首页
/ Protobuf 遗留语法 Edition 设计:将 proto2/proto3 统一为特殊 Edition 的方案与实现

Protobuf 遗留语法 Edition 设计:将 proto2/proto3 统一为特殊 Edition 的方案与实现

2026-09-04 15:30:29作者:平淮齐Percy

本文围绕 Protobuf 官方设计文档 Legacy Syntax Editions 展开,系统讲解 Protobuf 如何将 proto2/proto3 两种遗留语法建模为"特殊 edition"(EDITION_PROTO2 = 998EDITION_PROTO3 = 999):包括这样做的动机(消除语法/edition 双代码路径带来的维护与测试成本)、序列化 descriptor 的兼容回退、bootstrap 引导期特性默认值编译、以及从遗留语法推断 feature 的机制。读完本篇,你可以理解当前 Protobuf 代码库中 edition 解析、feature 推断等关键实现的来龙去脉,并能在源码中找到每个设计决策的落点。

背景:Edition 体系为 proto2/proto3 的统一铺好了路

Protobuf 的 Edition Zero Features 设计规划了 edition 2023,其核心目标是统一 proto2 与 proto3 的语义表达。在原设计过程中,团队一直讨论过把 proto2/proto3 视为"特殊 edition",但始终没有明确具体形态,也没有确认是否有必要这样做。

两个后续的重构为此创造了条件:

  • Edition 以枚举表示Edition Naming 重新设计了 edition 的表示方式,使其成为 descriptor.proto 中的一个枚举,而非随意的字符串;
  • Edition 默认值的传递机制Editions: Life of a FeatureSet 定义了 edition 的 feature 默认值如何沿继承链传播到生成器和运行时。

有了这两项基础设施之后,proto2/proto3 的"特殊化"就从概念变成了可落地的方案。

问题陈述:两条代码路径的维护成本

原计划保持 editions 与 syntax 正交(orthogonal),但这意味着从字面上看,需要长期维护两套差异很大的代码库。原设计文档指出了三个具体问题:

  1. 测试覆盖率的退化风险:editions 与语法被当作两个独立体系,初期 editions 的测试覆盖必然不完美,随着时间推移,这种不完美的覆盖会逐渐变成"语法层面的糟糕覆盖"。由于两种体系都需要长期支持,这是不可接受的。
  2. C++ 实现中被迫做的统一:在 C++ 实现 editions 时,团队为规避上述问题主动统一了大量基础设施——为 proto2 和 proto3 各定义一个全局 feature set,内部尽量使用 feature 来判断行为,而不是直接检查语法。这样把"语法/edition 的分支"尽量前移到代码栈的最上层,从而让 editions 路径尽早获得大量间接测试覆盖。
  3. Prototiller 的迁移困境:Prototiller(Protobuf 的模式迁移工具,设计见 docs/design/prototiller/)需要支持把遗留语法转换为 edition 2023。对内置 feature,可以在转换规则中硬编码默认值;但对第三方 feature,其 owner 没有任何途径声明"该 feature 在旧 proto2/proto3 下是什么行为",Prototiller 因此无法提供默认转换,第三方只能自己编写硬编码了全部 feature 的自定义 Prototiller transform。

推荐方案:新增两个特殊 edition

原设计文档给出的推荐方案是向 edition 集合中新增两个特殊值,当时给出的最小定义是:

enum Edition {
  EDITION_UNKNOWN = 0;
  EDITION_PROTO2 = 998;
  EDITION_PROTO3 = 999;
  EDITION_2023 = 1000;
}

这两个值与其他 edition 的处理方式完全相同,唯一的例外在解析器.proto 文件中不允许出现 edition = "proto2"edition = "proto3",会被直接拒绝。真正的收益在于:

  • feature 的定义方可以声明自己 feature 的 proto2/proto3 默认值,Prototiller 做迁移时因此有了依据;
  • 生成器与运行时可以把 proto2/proto3 文件与 edition 文件完全同等地处理,从而在内部实现上彻底统一。

仓库中的落地形态:完整 Edition 枚举

当前仓库的 descriptor.proto 中,该方案已经完整落地,且枚举比原设计文档中的版本更加丰富:

// The full set of known editions.
enum Edition {
  // A placeholder for an unknown edition value.
  EDITION_UNKNOWN = 0;

  // A placeholder edition for specifying default behaviors *before* a feature
  // was first introduced.  This is effectively an "infinite past".
  EDITION_LEGACY = 900;

  // Legacy syntax "editions".  These pre-date editions, but behave much like
  // distinct editions.  These can't be used to specify the edition of proto
  // files, but feature definitions must supply proto2/proto3 defaults for
  // backwards compatibility.
  EDITION_PROTO2 = 998;
  EDITION_PROTO3 = 999;

  // Editions that have been released.  The specific values are arbitrary and
  // should not be depended on, but they will always be time-ordered for easy
  // comparison.
  EDITION_2023 = 1000;
  EDITION_2024 = 1001;
  EDITION_2026 = 1002;

  // A placeholder edition for developing and testing unscheduled features.
  EDITION_UNSTABLE = 9999;
  ...
}

注意枚举上 EDITION_PROTO2/EDITION_PROTO3 的注释,正是对原设计文档结论的直接复述:"这些早于 editions 出现,但行为上很像独立的 edition;它们不能用于指定 proto 文件的 edition,但 feature 定义方必须为它们提供 proto2/proto3 默认值以保证向后兼容"。此外仓库还补充了 EDITION_LEGACY(900,表示 feature 引入之前的"无限过去")、EDITION_UNSTABLE、若干 *_TEST_ONLY 占位值以及 EDITION_MAX,这些扩展不改变原设计的核心结论,只是完善了 edition 解析机制。

解析器对 legacy edition 的拒绝

"parser 会拒绝 edition = "proto2""这一约束在 C++ 解析器中的实现位于 Parser::ParseSyntaxIdentifier。关键分支是:

if (has_edition) {
  if (!Edition_Parse(absl::StrCat("EDITION_", syntax), &edition_) ||
      edition_ == Edition::EDITION_PROTO2 ||
      edition_ == Edition::EDITION_PROTO3 ||
      edition_ == Edition::EDITION_UNKNOWN) {
    RecordError(syntax_token.line, syntax_token.column, [&] {
      return absl::StrCat("Unknown edition \"", syntax, "\".");
    });
    return false;
  }
  syntax_identifier_ = "editions";
  return true;
}

也就是说,解析 edition = "..." 时,解析器会把字符串拼成 EDITION_ 前缀去查枚举,EDITION_PROTO2EDITION_PROTO3EDITION_UNKNOWN 都会被显式拦截并报错 Unknown edition "proto2".——枚举存在但语法层面不可用,这与设计文档"treated the same as any other edition, except in our parser"的描述完全一致。

作为对照,遗留的 syntax = "proto2"/"proto3" 声明仍然合法(见同函数 L726-L735 的分支),且未声明任何 syntax/edition 的文件会默认按 proto2 处理并给出警告(parser.cc)。

兼容性要点一:序列化 descriptor 的回退机制

设计文档指出了一个工程约束:当时(以及现在)存在大量已经序列化在外的 descriptor.proto 快照,它们需要在数月(O(months))的时间尺度内继续可用。为了不阻塞 edition 2023 的发布,protoc 必须在 feature 解析失败时具备回退能力:

  • 如果文件是 proto2/proto3 且从 descriptor 快照解析 feature 失败,则回退到既有的硬编码默认值
  • 等到可以接受"打破这份变更之前的陈旧 descriptor.proto 快照"时,再移除这些回退逻辑。

从源码结构看,这类兼容性处理体现在 descriptor.cc 的 feature 解析与合并路径中:解析器会优先从 descriptor 自身的 features/edition_defaults 解析,解析不到时由默认值合并逻辑兜底。这一设计让"新 protoc + 旧 descriptor 快照"的组合不会直接失效。

兼容性要点二:bootstrap 引导期的默认值编译

设计文档的 Bootstrapping 一节指出了另一个必须先解决的问题:为了让 feature 解析机制同样作用于 proto2/proto3 文件,必须支持自举(bootstrapped)protos。在自举构建中,不能依赖任何 reflection(否则会死锁),因此 feature 默认值无法在运行时计算,必须在编译期嵌入代码

原设计文档的结论是:这个问题本来在"把这些 protos 迁移到 editions"时就不得不解决,该提案只是把它提前了;而 Editions: Life of a FeatureSet 已经为此场景做好了铺垫,Google 内部已有把默认值嵌入代码的 Blaze 规则。具体到各语言:

  • C++:编好的默认值需要作为 checked-in 文件随其他自举 protos 一起提交;
  • 其他语言:可以借助 genrule 等方式动态完成。

在当前仓库中可以看到对应的产物形态:editions/defaults.bzl 以及配套的 defaults_test.cc 和一系列 defaults_test_embedded_*.h.template(如 defaults_test_embedded_base64.h.template),展示了"把 edition 默认值表以 base64/十六进制数组等内嵌形式编译进代码"的落地方式。

兼容性要点三:从遗留语法推断 Feature(Feature Inference)

设计文档指出:即便默认值可以用与 editions 相同的逻辑计算,但从 proto2/proto3 的语法本身推断出"features"需要定制代码。原设计文档列举了四组典型推断:

  • required 关键字 → 设置 LEGACY_REQUIRED feature;
  • proto3 的 optional 关键字 → 设置 EXPLICIT presence;
  • group 关键字 → 隐含 DELIMITED message encoding;
  • 文档中写作 enforce_utf8 的选项在 PACKED/EXPANDED 编码之间翻转——从当前仓库的实际实现看,repeated 字段编码(PACKED/EXPANDED)的推断是由 FieldOptions 中的 packed 选项驱动的,与文档此处的措辞存在出入,阅读时以源码为准。

由于这段推断逻辑"需要用代码写出来,并且在每个受支持的语言中重复实现",原设计文档要求定义一组尽量可移植的变换函数:每类 descriptor 拥有自己的一套针对 legacy edition 的 feature 变换

仓库中的实现证据:InferLegacyProtoFeatures

上述推断逻辑在 C++ 运行时中的对应实现是 DescriptorBuilder::InferLegacyProtoFeatures

static void InferLegacyProtoFeatures(const FieldDescriptorProto& proto,
                                     const FieldOptions& options,
                                     Edition edition, FeatureSet& features) {
  if (!features.GetExtension(pb::cpp).has_string_type()) {
    if (options.ctype() == FieldOptions::CORD) {
      features.MutableExtension(pb::cpp)->set_string_type(
          pb::CppFeatures::CORD);
    }
  }

  // Everything below is specifically for proto2/proto.
  if (!IsLegacyEdition(edition)) return;

  if (proto.label() == FieldDescriptorProto::LABEL_REQUIRED) {
    features.set_field_presence(FeatureSet::LEGACY_REQUIRED);
  }
  if (proto.type() == FieldDescriptorProto::TYPE_GROUP) {
    features.set_message_encoding(FeatureSet::DELIMITED);
  }
  if (options.packed()) {
    features.set_repeated_field_encoding(FeatureSet::PACKED);
  }
  if (edition == Edition::EDITION_PROTO3) {
    if (options.has_packed() && !options.packed()) {
      features.set_repeated_field_encoding(FeatureSet::EXPANDED);
    }
  }
}

这段代码逐条印证了设计文档的推断规则:required 标签 → LEGACY_REQUIREDgroup 类型 → DELIMITED 编码,显式 packed 选项 → PACKED/EXPANDED;同时 edition == EDITION_PROTO3 的分支正是"EDITION_PROTO3 作为一等 edition 参与 feature 计算"的直接体现。该函数在 feature 解析主流程 ResolveFeaturesImpl 中被调用,作用于文件/包/message/field 各层级的 descriptor,与"每类 descriptor 有自己的变换集合"的设想吻合。

proto3 optional 关键字的推断则发生在更前置的解析阶段:Parser 在解析 proto3 文件的 optional 字段时置位 proto3_optional,后续由 DescriptorBuilder 将其转化为 EXPLICIT presence 相关的 feature 语义。

此外,解析器还对 editions 文件中的遗留语法做了显式的"翻译提示",这些错误信息本身就是一份 legacy → edition 的对照表:

  • required 在 editions 中不被支持,提示改用 features.field_presence = LEGACY_REQUIREDparser.cc);
  • group 语法在 editions 中不再支持,提示改用 features.message_encoding = DELIMITEDparser.cc);
  • optional 标签在 editions 中不被支持,因为 editions 下所有单数字段默认带 presence(parser.cc)。

feature 定义方如何声明 proto2/proto3 默认值

feature 侧的配套机制是 FeatureSet.FeatureSupport 选项中的 edition_defaults:feature 用 edition: EDITION_PROTO3, value: "..." 之类的条目声明自己在该 legacy edition 下的取值。仓库中有多处实例,例如 cpp_features.protodescriptor.protofield_presenceEDITION_PROTO3 声明 IMPLICIT、对 EDITION_2023 声明 EXPLICITmessage_type_visibilityEDITION_PROTO3 声明 OPEN 等),第三方 feature 测试用例 unittest_custom_features.proto 则示范了外部 feature 按同一机制注册 proto3/2023 默认值的方式。

收益与代价

原设计文档对方案做了如下权衡,这里完整保留:

收益(Pros)

  • 更清晰地表达 proto2/proto3 与 editions 是"同类"概念;
  • 为 Prototiller 从 proto2/proto3 向 editions 的转换提供了更多信息(目标 edition 不一定是 2023);
  • proto2/proto3 的默认值可以在单一位置声明;
  • 运行时更容易做 syntax/edition 代码的统一;
  • 使得 Editions: Life of a FeatureSet 中提到的 conformance 框架可以对 proto2/proto3 做跨语言测试——这一点在当前仓库中已有实体,conformance/ 下维护着大量语言的 failure list 与 test_protos 下的 edition 测试 proto(如 test_messages_edition2023.proto)。

代价(Cons)

  • 引入了"特殊 legacy edition"这一特例,可能造成一定困惑;
  • feature 推断逻辑需要移植到所有语言——不过设计文档认为,这比在每个语言中维护分叉的 proto2/proto3 分支代码更便宜。

被考虑过的替代方案:什么都不做

原设计文档评估的替代方案是"Do Nothing"——不对 syntax 与 editions 做任何内建统一,各运行时自行选择逻辑分叉点。

  • 收益:不需要改动 editions 代码;
  • 代价:测试覆盖率大概率更低;问题可能被掩盖到 edition 2023 开始铺开时才暴露;Prototiller 只能为已知 feature 硬编码 proto2/proto3 默认值,而对它不了解的运行时 feature 则完全无法参与迁移。

正是这组代价促使团队采纳了"legacy 特殊 edition"方案。

小结:从设计文档到仓库现状

legacy-syntax-editions.md 的三个要点映射回当前仓库,可以看到该设计已被完整吸收:

  1. 枚举建模descriptor.proto 中的 EDITION_PROTO2 = 998 / EDITION_PROTO3 = 999 与真实 edition(EDITION_2023 = 1000 起)按时间有序排列,且解析器(parser.cc)确保它们只用于 feature 定义、不可用于文件头声明;
  2. 推断机制InferLegacyProtoFeatures 等代码把遗留语法标签翻译成 feature 语义,使 proto2/proto3 文件与 edition 文件走同一条 feature 解析管道;
  3. 默认值声明:feature 通过 edition_defaults 声明各 legacy edition 下的取值(如 cpp_features.protounittest_custom_features.proto),并配合 editions/defaults.bzl 一类的工具链机制把默认值编译期嵌入自举产物。

对读者而言,理解这一设计的实际意义是:在当前 Protobuf 中,"文件是什么 edition"和"文件是什么 syntax"已经被统一进同一套 FeatureSet 继承与解析体系——proto2/proto3 不再是平行的另一套判断分支,而是枚举中的两个历史节点。这一结论也可以借助仓库中的测试资产做进一步验证,例如 editions/codegen_tests/ 下针对 edition 2023 各 feature 的代码生成测试,以及 parser_unittest.cc 中对 requiredLEGACY_REQUIRED 报错文案的断言。

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

项目优选

收起
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
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
504
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384