首页
/ Protobuf Editions:FeatureSet 全生命周期剖析与跨语言特性解析策略

Protobuf Editions:FeatureSet 全生命周期剖析与跨语言特性解析策略

2026-09-06 11:33:45作者:冯爽妲Honey

本文基于 Protobuf 官方设计文档 Editions: Life of a FeatureSet(作者 @mkruskal-google,2023-08-17 批准)展开,系统讲解 Protobuf Editions 中 FeatureSet(特性集)从 .proto 源文件到代码生成器(generator)再到运行时(runtime)的完整生命周期:谁负责解析特性、谁只能看到未解析特性、edition 默认值如何在各语言中复用,以及 protoc、generator、runtime 三方各自的最小特性需求。读完本文,你将理解 Protobuf 为何放弃"protoc 统一解析、向下游传播已解析特性"的原始设计,转而采用"每个阶段独立解析、只共享未解析特性"的方案,并能结合当前仓库源码(descriptor.proto 中的 FeatureSet/FeatureSetDefaultseditions/defaults.bzl 规则、conformance 测试框架)验证该设计在 Protobuf 中的实际落地形态。

Feature 解析算法示意图:从 edition 默认值出发,逐层合并父级特性,最后合并描述符自身特性

1. 背景:FeatureSet 归属问题的由来

除少量拼写上的微调外,Protobuf 当前的 features 实现基本沿用了 Protobuf Editions Design: Features 中最初的特性设计。该方案定义了特性解析算法(即上图):

给定某个描述符(descriptor)的特性解析,首先根据 proto 文件的 edition 与特性 schema 生成默认特性集(edition defaults),然后自顶向下依次合并所有父级(parent)特性,最后合并该描述符自身的特性。

但这一做法导致每个描述符都对应了四种不同的特性集,而文档对以下三个问题长期处于"未充分定义"(under-specified)状态:

  • 谁负责生成这四种特性集(protoc?插件?运行时?);
  • 谁有权访问它们;
  • 它们需要传播到哪些地方。

文档回顾了此前的两次设计尝试:

  1. Exposing Editions Feature Sets(未对外公开):第一次尝试界定这些概念,把特性的可见性锁定在 protoc、生成器和运行时三者内部,用户只能通过代码生成变化或运行时辅助函数间接感知特性,以避免 Hyrum's law(希勒姆定律)把这些内部决策固化为事实上的公开 API。该方案"错误地"假设 protoc 前端能计算出所有特性集,然后把全部四套特性传播给生成器,由生成器再把完全解析后的运行时特性转发给运行时。其附带好处是:C++ 的特性解析逻辑可以充当唯一的 source-of-truth(权威来源),不必在每种支持的语言里重复实现。
  2. Editions: Runtime Feature Set Defaults(未对外公开):针对 edition 默认特性的后续尝试。团队意识到,要安全地滚动发布 editions,每种语言都需要拥有 proto2/proto3 的默认特性;而支持 descriptor pool(描述符池)的语言还存在完全绕过 protoc 的构造路径。当时的结论是继续以 protoc 前端为权威来源,把这些默认值下传到需要的运行时,以此修复 proto2/proto3 问题,并为 descriptor pool 用户提供一些便利工具。

2. 术语表:feature 的四种限定

原文档强调,"feature"一词在不同上下文中有微妙的含义差异,为避免歧义,采用以下严格定义。理解这些术语是理解后文所有设计决策的前提。

2.1 按归属划分:全局特性 vs 生成器特性

  • Global features(全局特性):直接作为 FeatureSet 消息字段存在的特性。它们作用于 protobuf 语言本身,而不是某个特定的运行时或生成器。对应本仓库 src/google/protobuf/descriptor.protoFeatureSet 消息的第一组字段(field_presenceenum_typerepeated_field_encodingutf8_validationmessage_encodingjson_formatenforce_naming_styledefault_symbol_visibilityenforce_proto_limits)。
  • Generator features(生成器特性):由某个特定运行时或生成器拥有的、对 FeatureSet扩展(extension)。例如 C++ 生成器在 src/google/protobuf/cpp_features.proto 中定义的 pb.cpp 特性(string_field_typenamespace 等),在 FeatureSet 中以扩展号 1000 声明(见 descriptor.proto 扩展声明区,依次为 pb.cpp=1000、pb.java=1001、pb.go=1002、pb.python=1003、pb.csharp=1004)。

2.2 按解析状态划分:未解析 vs 已解析

  • Feature resolution(特性解析):执行 Protobuf Editions Design: Features 中给出的算法的过程,即 edition 默认值、父级特性与覆盖项全部合并完成。解析完成后,每个特性都应有显式取值
  • Unresolved features(未解析特性):用户在 .proto 文件中显式写到描述符上的特性。它们没有经过解析,是一种最小化表示,本身不足以支撑决策,还需要更多知识(edition 默认值、父级继承)才能有用。
  • Resolved features(已解析特性):已经过特性解析、应用了默认值与继承的特性。文档明确指出:只有已解析特性集才应被用于做决策

2.3 按 Option Retention 划分:source vs runtime

Protobuf 支持对所有 option(包括 features)声明 Option Retention(保留策略):

  • Source features(源码期特性):在 option retention 被应用之前,protoc 与生成器可见的特性。可以是已解析或未解析。
  • Runtime features(运行时特性):在 option retention 被应用之后,运行时可见的特性。同样可以是已解析或未解析。

由此可组合出文档开篇所说的"四种特性集":{已解析, 未解析} × {源码期, 运行时}。本文后续所有"最小需求清单"都建立在这四个维度之上。

3. 问题:protoc 无法成为特性解析的通用权威

文档的核心论断是:在原始设计下,protoc 不可能成为特性解析的通用 source-of-truth。 这一缺陷贯穿了所有相关设计文档,具体体现在三个层面。

3.1 import 发现漏洞

对全局特性而言没有问题:protoc 对 descriptor.proto 有一套自举(bootstrapping)机制,始终知道全局特性集。但生成器特性不同——按照 特性设计文档 的约定,它们依赖 import 来被发现(specification of an edition 一节所述)。

  • 如果用户确实覆盖了某个生成器特性,那么 proto 文件中必然存在相应 import,protoc 就能发现该生成器特性并参与解析;
  • 但如果用户对 edition 默认值满意,就没有 import 的必要。没有 import 时,protoc 无从知道这些生成器特性存在。

文档评估了两条看似可行的路并都否定了:把自家特性硬编码进 protoc,只是把问题推给了第三方插件;强制 proto 作者为每个(传递性地)生成代码的语言都加上 import(哪怕用不到),则既"非常 disruptive",也不符合语言习惯,不切实际。

3.2 把权威下推到生成器:代码体积代价

把 source-of-truth 推给生成器会略好一些:每个生成器都确切知道自己需要引入哪个特性文件,不再存在知识盲区;且许多生成器(包括不少非内置插件)都用 C++ 编写,可以复用 C++ 的特性解析工具,减少重复。但仍存在代码体积(code-size)问题:按前述方案,需要向运行时发送每个描述符的四套特性集(即写进 generator request、并以内嵌序列化字符串形式打进 gencode)。此时无法利用继承或引用来降低成本,每个FileDescriptorProto 内嵌进 gencode 的生成器都会遭遇巨大的代码体积膨胀。

3.3 descriptor pool 运行时构造的不一致

还有绕过 protoc 直接在运行时构建描述符的 descriptor pool 场景。这类用户通常是做非常规事情的"高级用户"(以及官方单测自己)。此前文档试图把成本推给他们:不给他们特性解析能力,要求他们在每个描述符上显式指定每个特性,既不能用 edition 默认值也不能用继承。但文档认为这个代价过高,且它会使 edition 字段变得无意义——任何缺失的特性都会成为运行时错误,"edition"这一概念不复存在。这会造成开发者体验不一致:一个上下文里按 edition 思考,另一个上下文里却要抛弃它。同时这会衍生出两种截然不同的 FileDescriptorProto 表示法(一种是只应经 protoc 处理的未解析特性,另一种是始终绕过 protoc 的完全解析特性),使描述符的 round-trip(往返)变得困难甚至不可能。

下图正是文档用来图解这一问题的场景:

同一份 proto 文件被 A、B 两个运行时使用的场景:schema 只覆盖了 A 的特性且未 import B 的特性,导致 protoc 不知道 B 的特性存在,动态消息在两个运行时中都无法遵循特性解析规范

场景说明:一份 proto 文件同时被 A 与 B 两个运行时使用,但 schema 本身只为 A 覆盖了特性、没有声明对 B 特性文件的 import。于是 protoc 不知道 B 的特性,Generator B 必须自行解析;而 A、B 两个运行时中的动态消息(dynamic messages)同样因为绕过了 protoc,无法遵循特性解析规范。

3.4 各参与方的最小特性需求

文档给出了各方"最低限度"的特性需求清单,这是评估一切候选方案的基准:

参与方 所需特性集 用途
protoc 已解析全局源码期特性(Resolved global source features) 做 proto 层面的决策
protoc 未解析全局源码期特性(Unresolved global source features) 用于验证
每个生成器 已解析生成器源码期特性(Resolved generator source features) 做语言相关的 codegen 决策
每个生成器 未解析生成器源码期特性(Unresolved generator source features) 用于验证
每个生成器 已解析全局源码期特性(Resolved global source features) 做更复杂的决策
每个运行时 全部已解析运行时特性(All resolved runtime features) 做运行时决策
每个运行时 全部未解析运行时特性(All unresolved runtime features) round-trip 行为与调试

在此之上,文档还列出理想方案必须满足的四个软性要求:

  • 最小的代码体积代价——代码体积膨胀足以阻断 editions 的发布,而且一旦触及限制,团队并没有很好的补救手段;
  • 最小的性能代价——避免任何不必要的 CPU 或内存(RAM)回退;
  • 最小的代码重复——当然希望尽量降低,但无法避免之处需要一套合适的测试策略来保持多语言实现同步;
  • 对动态消息(dynamic messages)的运行时支持——动态消息虽使用频率较低,却是大量关键系统依赖的重要特性,方案不应使任何支持它的运行时变得更难使用。

4. 推荐方案:每个阶段独立解析,只共享未解析特性

文档给出的长期建议是:支持并使用特性解析贯穿 FeatureSet 生命周期的每一个阶段。protoc、每个运行时、每个生成器都独立完成特性解析,彼此之间只共享未解析特性

4.1 设计动机:重复是不可避免的

这必然意味着在几乎每种支持的语言中都存在重复实现,而正当性在于一个简单事实:edition 默认值几乎在到处都需要

  • 生成器需要自己特性的默认值才能得到完全解析的生成器特性集来做 codegen 决策,而且并非总能从 protoc 拿到这些默认值;
  • 运行时同时需要全局特性与生成器特性的默认值,以便在动态消息中 honor editions,并压低 RAM 成本(例如"没有任何特性覆盖"这一情况应当退化为引用某个共享默认对象,而不是每份拷贝一份数据);
  • 而 edition 默认值的计算是特性解析中最复杂的部分,其余都只是 proto 合并。既然默认值计算无法避免重复,不如干脆把整个算法都复制过去,反而让每端逻辑更简单、更好理解。

4.2 优缺点

优点(Pros):

  1. 已解析特性集永远不会被公开暴露:
    • API 显著变简单,不同特性集的种类直接减少一半(从四种变为两种:未解析的对外,已解析的仅限内部);
    • FeatureSet 对象的语义不再有歧义:在 protobuf 代码之外它永远处于未解析状态,在 protobuf 代码内部则永远对所有可访问特性完全解析;
    • RAM 与代码体积代价最小,因为只存储和传播最小信息量(未解析特性);
    • 通过处处用包装器(wrapper)围绕已解析特性来对抗 Hyrum's law,而不是让人直接依赖裸数据。
  2. 在"已经必需"的重复(edition 默认值)之上,额外重复最小
  3. 动态消息与 proto 文件获得平等待遇(on equal footing)。
  4. 所需的特性依赖总能在正确的上下文中获得。
  5. 可以简化现有实现——protoc 不再需要处理对 imported features 的解析。

缺点(Cons):

  • 需要在每个运行时、每种"独特的生成器语言"中重复特性解析逻辑,意味着要建设额外的基础设施来强制跨语言一致性(conformance)——这一点直接催生了后文的 conformance 测试框架设计。

5. 分类处置:各场景的具体策略

5.1 无反射能力的运行时(Runtimes Without Reflection)

有些运行时根本不支持反射或动态消息(例如 Java lite、ObjC)。它们通常把所需的"特性化"信息直接以定制对象的形式内嵌进 gencode。对这些运行时,问题简单得多:它们并不需要完整的 FeatureSet 对象。因此无需在运行时重复特性解析,生成器可以直接把运行时需要的、完全解析后的特性值内嵌进 gencode(当然,生成器自己仍可能需要重复解析逻辑来获得这些值)。

5.2 动态消息的分阶段发布(Staged Rollout)

长期目标是:任何支持反射(因而需要 FeatureSet 对象)的运行时,都在运行时处理特性解析,以降低代码体积/RAM 成本并支持动态消息。但在某些代码体积/RAM 压力不大的语言里,可以接受分阶段方案:

  • 第一阶段:生成器把序列化的已解析源码期特性连同其余 options 一起内嵌进 gencode;同时利用 raw_features 字段(该字段最终应被删除)携带未解析特性以供反射使用。
  • 该阶段即可实现并测试 editions,解除所有非动态场景的迁移阻塞;
  • 后续优化再下推到运行时,gencode 中只内嵌未解析特性;
  • 在此过渡方案下,动态消息仍可使用 editions——前提是每个描述符上都提供了完全解析的特性。等真正实现运行时解析后,只是删掉冗余特性而已;且从已解析特性到未解析特性应始终存在一个合法变换(valid transformation)。

5.3 C++ 生成器:在 DescriptorPool 中显式注册特性

C++ 编写的生成器处境更好:它们不需要任何代码重复——可以让它们看到现有的特性解析工具并自行解析。但文档推荐的更优做法是改进该工具本身,提供一些辅助 API(类似 Exposing Editions Feature Sets 中提议的 helper),直接访问已经存在的已解析特性。

这里要理解 protoc 的两条数据通路:

  1. 前端阶段:protoc 先解析输入 proto 文件,构建进 descriptor pool。此阶段只需要全局特性;
  2. codegen 阶段
    • 内置语言:描述符直接传给生成器做代码生成;
    • 插件(plugin):描述符被序列化为 descriptor protos,在生成器进程中用新的 descriptor pool 重建,再传给生成器代码做代码生成。

无论哪条通路,proto 的 DescriptorPool 构建都发生在一个必然链接了相关生成器特性的二进制里。现状是"在 pool 中发现被构建 proto 所 import 的特性",这正是前面所述"未 import 的特性无法被发现"的漏洞。新方案转向更显式的发现策略

  • DescriptorPool 默认只解析全局特性C++ 特性(因为这是 C++ 运行时的默认配置);
  • DescriptorPool 新增一个方法,允许用新的特性集替换 C++ 特性参与解析;
  • 生成器通过 CodeGenerator 类上的一个虚方法注册自己的特性,生成器的 pool 构建在解析时把这些特性纳入考虑。

文档把具体注册方式留作实现细节,列举了两个候选形态:

  • 由生成器提供包含相关特性集自己的 DescriptorPool
  • 由生成器提供 edition → 默认 FeatureSet 对象的映射。

在 API 层面,文档说明(原文该处的 API 代码块留白未给出,语义由上下文明确):通过 CodeGenerator 类,C++ 生成器可以访问任意描述符的全部已解析特性集来做 codegen 决策,同时可以访问自己的未解析生成器特性用于验证;而 FileDescriptor::CopyTo 继续输出未解析的运行时特性,在经过 option retention 剥离(生成器本就应该做的操作)之后成为未解析源码期特性,供内嵌进 gencode 供运行时使用。

5.4 示例:假设语言 lang 如何引入自己的特性

文档用一个假设语言 lang 完整演示了引入自定义特性的流程:

  1. lang 运行时也需要特性,就在其 runtime 目录里创建 lang_features.proto,像 descriptor.proto 那样自举其 gencode;
  2. 同时,用一套"特殊的、仅生成 C++ 的 protoc 构建"为它自举 C++ gencode。

下图即该自举布局(内置 C++ 生成器场景):

假设语言 lang 引入自身特性的自举布局:lang_features.proto 同时引导运行时 gencode 与 C++ gencode,生成器向 DescriptorPool 注册后,GetFeatures 返回的 FeatureSet 总包含完全解析的 lang 特性

图中的几个关键推论:

  • 如果生成器特性运行时并不需要,那么图中红色框(C++ gencode 引导部分)直接消失;
  • lang 是独立插件,"plugin" 框从 protoc 中移出即可,protoc 本身也可以兼任 protoc_cpp 的角色;
  • lang 不需要运行时特性,就把特性 proto 放进 lang 生成器中,只用上述同样的自举技术生成 C++ 代码;
  • 一旦生成器把 lang_features.proto 注册进 DescriptorPool,GetFeatures 返回的 FeatureSet 对象就总是带有完全解析的 lang 特性。

5.5 非 C++ 生成器:用 EditionFeatureDefaults 削减重复

非 C++ 生成器本就需要重复一部分解析逻辑;采用本方案后重复会更多——protoc 发来的 GeneratorRequest 只包含完整的未解析特性,它们需要自行解析并做 retention 剥离。

文档特别指出:解析逻辑中最棘手的一环是 edition 默认值计算,它需要大量反射(reflection)。一个可行思路是把 C++ 一节中"edition → 默认 FeatureSet 映射"的做法复用到非 C++ 生成器上:先定义一个 proto——

message EditionFeatureDefaults {
  message FeatureDefaults {
    string edition = 1;
    FeatureSet defaults = 2;
  }
  repeated FeatureDefaults defaults = 1;
  string minimum_edition = 2;
  string maximum_edition = 3;
}

它可以由任何特性集扩展(feature set extension)填充,从而得到一份"可用得多"的默认值规约。再封装一个 genrule,把特性 proto 转换为序列化的 EditionFeatureDefaults 字符串,就可以把它内嵌到任何需要的位置——C++ 与非 C++ 的生成器/运行时都可以内嵌这份数据。一旦默认值以此形式"预编译"好,特性解析就大幅简化:最难的部分变成写一个 edition 字符串的比较器(comparator),之后只需在 defaults 中做下界搜索(lower bound search),再做几次 proto 合并即可。

文档还附注:如果将来实现了双向插件通信(bidirectional plugin communication),Bidirectional Plugins 一节讨论的备选方案对"运行时不需要特性"的非 C++ 生成器可能是更简单的选择;但凡是运行时需要特性的,反正都要重新实现解析,该备选方案帮助有限。

5.6 自举难题:descriptor.proto 的特殊待遇

文档指出一个"很可能碰上的重大并发症":descriptor.proto 的自举。在支持动态消息的语言中,一种常见 codegen 策略是把该文件的 FileDescriptorProto 内嵌进代码,并在运行时启动时解析、构建它。对 descriptor.proto 而言处理 options 尤其棘手——以 Python 为例,官方会刻意剥离该文件的所有 options,并假定在构建期 options 描述符始终存在(在存在序列化 options 的前提下)。而 features 就是 options,这构成了因语言而异的难题。

必须对 descriptor.proto 做若干特殊处理,其中最关键的一点:该文件永远不会有生成器特性覆盖——因为它无法 import 那些特性文件。对其他所有文件,都可以安全地假设"已解析特性集里存在生成器特性";但对 descriptor.proto,至少在它被运行时首次构建时点,这些扩展并不在场,彼时也没有生成器特性 proto 可供反射,自然无法计算 edition 默认值。

文档给出的可能解法:针对这份自举 proto 额外 codegen 一些信息(类似 Editions: Runtime Feature Set Defaults 对 edition 默认值的做法),让生成器提供足够信息供运行时构建 descriptor.proto。只要这类特例被限制在 descriptor.proto 一个文件内,就可以留给更隔离的语言专题讨论。

5.7 跨语言一致性:描述符级 conformance 测试框架

代码重复必然要求一套"让所有实现保持一致"的测试策略。文档提出实现一个 conformance 测试框架来验证各个语言/平台的特性解析实现彼此一致。现有 conformance 测试是良好范本(尽管原本面向解析/序列化):存在一个 runner 二进制,可对接用任意语言构建出的另一个二进制;runner 发送携带序列化 payload 与指令集的 ConformanceRequest,收回携带结果的 ConformanceResponse,然后在 runner 内遍历若干固定测试套件来验证被测二进制的合规性。本仓库中这套机制对应 conformance/ 目录下的 conformance.prototest_runner.htest_manager.cc 等文件。

文档主张对特性解析采用类似的但更通用的测试设置:与其只为特性解析写一个高度聚焦的框架,不如建一个能测试"任意描述符变换"的通用框架——这为将来留出空间(例如 option retention 目前没有重复实现,但完全可以按这种模式实现)。可测试的变换包括:proto3_optional、group/DELIMITED、required/LEGACY_REQUIRED 等。API 由如下请求/响应 proto 定义(原文原样给出):

message DescriptorConformanceRequest {
  // The file under test, pre-transformation.
  FileDescriptorProto file = 1;

  // The pool of dependencies and feature files required for build.
  FileDescriptorSet dependencies = 2;
}

message DescriptorConformanceResponse {
  // The transformed file.
  FileDescriptorProto file = 1;

  // Any additional features added during build.
  FileDescriptorSet added_features = 2;
}

工作方式:每个测试点构造一个 proto 文件、其依赖以及需要纳入特性解析的特性文件;conformance 二进制据此完整装饰(fully decorate)该 proto 文件——即填充已解析特性——并把结果回传,与 C++ source-of-truth 的输出比对;二进制在构建过程中附加的任何生成器特性也要一并回传,才能得到匹配的结果。

5.8 文档化策略

由于方案要求第三方生成器所有者自行处理特性解析,就必须公开文档,具体包括:

反过来,文档量也会显著减少——不再需要写"哪里该用哪种特性集"这类说明:descriptor protos 永远包含未解析特性;C++ 生成器则有一个简单的 API 来获取完全解析的特性。

6. 被否决的备选方案(Considered Alternatives)

文档以"Pros / Cons 对照"完整记录了五个备选方案的取舍过程,这部分对评估类似设计问题很有参考价值。

6.1 备选一:C++ 生成器使用"生成后 Pool"(Use Generated Pool)

注:原文说明此方案是原始提案的一部分,后被重构(见 Cons)。

做法:C++ 生成器无需重复代码,可见现有的特性解析工具;更好的是让工具提供 helper,访问已经存在的已解析特性。与推荐方案相同的两条数据通路(内置语言直传 / 插件序列化后重建 pool)。区别在于:供给生成器的 FeatureSet 会被变换到"生成后的 pool"(即 FeatureSet 对象而非 Message),其中生成器特性总是存在。于是可以放弃"从 import 中刮出(scrape)特性",改为从生成后的 pool 中刮——等价于:当你调用 MergeFeatures 拿到一个 FeatureSet 时,返回的集合是相对于当前生成后 pool 完全解析的。这是一个更清晰的契约,且每个 C++ 生成器可见的特性会自动带上它自己的正确生成器特性。CodeGenerator API 与 FileDescriptor::CopyTo 的行为与推荐方案一致(输出未解析运行时特性,retention 剥离后成为未解析源码期特性)。

  • Pros:二进制中用到的任何特性都会被自动纳入;特性永远不会处于"部分解析"状态。
  • Cons:隐式的"远处动作"(implicit action at a distance)可能引发意外行为;依赖全局状态(globals),测试困难;对 DescriptorPool 场景不友好——这些场景未必希望把每个链接进来的特性都送进特性解析。

6.2 备选二:默认值占位符(Default Placeholders)

protoc 继续传播并解析核心特性与已 import 的语言级特性;对 protoc 未知(未被 import)的语言级特性,传播一个"核心占位符特性",表示应尊重某个 edition 的默认值:

message FeatureSet {
  optional string unknown_feature_edition_default = N; // e.g. 2023
}

如此插件只需提供一个"edition → 默认 FeatureSet"的工具函数(基于生成器特性文件,可选缓存),而不必重复整个解析算法。例如:

if features.hasUtf8Validation():
  return features.getUtf8Validation()
else:
  default_features = getDefaultFeatures(features.getUnknownFeatureEditionDefault())
  return default_features.getUtf8Validation()
  • Pros:传播特性所需的重复逻辑更少。
  • Cons
    • 描述符 proto 出现与 FileDescriptorProto 中 edition 字段技术冗余的膨胀;
    • "部分特性已完全解析、部分没有"这一状态令人困惑;
    • 仍需要"从 edition 号解析 edition 默认值"的重复逻辑;
    • 原方案的代码体积与内存代价依然存在;
    • 对 descriptor pool 场景依然无解,那里可能仍需重复逻辑。

6.3 备选三:双向插件(Bidirectional Plugins)

既然生成器清楚自己关心哪些特性,可以让 protoc 与插件之间建立双向通信:插件先告诉 protoc 想加入哪些特性,protoc 便能在发送前把所有特性集完全解析。这还附带未来增强空间——例如插件可以在真正开始构建之前先上报其最低 edition 要求等约束。

注:双向插件仍可为其他目的实现;此处被当作"备选",特指"用该通信通道传递缺失的特性规格"这一具体用途。

  • Pros:消除代码重复问题;为未来增强提供基础设施。
  • Cons:不解决当前 API 令人困惑的问题(features 字段里到底装着哪种特性说不清);不解决运行时代码体积与内存代价;不解决 descriptor pool 场景。

6.4 备选四:中央特性注册表(Central Feature Registry)

不依赖"生成器 + import"来供给特性规格,改为维护一个中央注册表收录所有已知特性:生成器所有者不能只认领一个扩展号,而必须把全部特性 proto 提交到中央特性 proto 仓库,protoc 由此获得所有特性。两种实现路径:

  1. 直接构建进 protoc:可以免除任何 import 语句。为避免给 descriptor.proto 增加依赖仍会保留扩展点,但形如 features.(pb).cpp 而非 features.(pb.cpp)
  2. 维持现有扩展 + import 方案:proto 文件仍需 import 其覆盖的特性,但 protoc 依赖所有特性文件,并对未指定者填充默认值。
  • Pros:所有特性在任何需要的地方都易于发现;消除代码重复问题;获得"移除 import 语句"的选项——import 很可能在未来带来麻烦(edition zero 的 LSC、后续维护、需要支持大量第三方运行时的 proto 文件)。
  • Cons:不解决代码体积与内存代价;制造版本漂移(version skew)问题;所有权语义(ownership semantics)令人困惑。

6.5 备选五:什么都不做(Do Nothing)

基本等于放弃 editions。当前设计对第三方生成器"不能(也本就不可能)工作":它们只能在没有官方指导与支持的情况下自行重复逻辑;且除 C++ 外还会看到很难消除的代码体积与 RAM 膨胀。

  • Pros:工作量更少。
  • Cons:其余所有方面都更差。

7. 设计到仓库:当前代码库中的实现证据

设计文档是 2023 年批准的方案,而当前仓库中已能看到该方案的多个直接落地痕迹。以下均给出可核对的源码路径。

7.1 FeatureSet 消息:全局特性 + 扩展号分配

descriptor.proto 中的 FeatureSet 是"全局特性 vs 生成器特性"术语表的直接实体化:

  • 每个全局特性字段(field_presenceenum_type 等)都携带 retentionRETENTION_RUNTIME/RETENTION_SOURCE)、targets(可挂载的描述符类型)与 edition_defaults(各 edition 的默认值,如 EDITION_LEGACYEDITION_PROTO3EDITION_2023 三档),以及 feature_support.edition_introduced 标注该特性从哪个 edition 引入——这正是"retention 区分 source/runtime 特性"与"edition 默认值"机制在数据层的体现;
  • 消息末尾的扩展声明区(L1250-L1287)用 extensions 1000 to 9994 预留了生成器特性号,并逐条 declaration 了归属:1000 .pb.cpppb.CppFeatures、1001 .pb.javapb.JavaFeatures、1002 .pb.go、1003 .pb.python、1004 .pb.csharp,另有 9989 .pb.java_mutable、9990 .pb.proto1 及第三方预留段(如 10000 预留给 protobuf-es)——与文档"生成器特性是对 FeatureSet 的扩展、按扩展号声明归属"的设计一一对应。

7.2 FeatureSetDefaultscompile_edition_defaults:预编译默认值方案

文档 5.5 节提出的 EditionFeatureDefaults("由特性集扩展填充、可序列化内嵌、解析退化为下界搜索 + proto 合并")在当前仓库中对应 descriptor.proto 中的 FeatureSetDefaults 消息。其注释写道:

"A compiled specification for the defaults of a set of features. These messages are generated from FeatureSet extensions and can be used to seed feature resolution. The resolution with this object becomes a simple search for the closest matching edition, followed by proto merges."

字段设计与文档构想高度吻合:repeated FeatureSetEditionDefault defaults(每项含 editionoverridable_featuresfixed_features,必须按 edition 严格升序)、minimum_editionmaximum_edition(对应草案中的 minimum_edition/maximum_edition 字段)。

而"封装一个 genrule 把特性 proto 转成序列化字符串"这一步,对应 editions/defaults.bzl 中的 compile_edition_defaults 规则:它接收 proto_library 源与 minimum_edition/maximum_edition 参数,调用 protoc(//src/google/protobuf/compiler/release:protoc_minimal)并传入 --edition_defaults_out--edition_defaults_minimum--edition_defaults_maximum 等参数,产出中间文件。该文件的 docstring 甚至直接写着 "See go/life-of-a-featureset for more information"——明确指向本文所依据的设计文档。

7.3 跨语言一致性测试:editions/defaults_test

文档"最小代码重复需要配套测试策略"的要求,在仓库中的直接体现是 editions/defaults_test.cc 与一组默认值模板文件:

从文件结构看,这些模板把同一份 edition 默认值数据以多种编码形态(原始二进制、base64、十进制数组、十六进制数组)内嵌进测试,供不同语言/平台的测试二进制比对解析结果——这正是文档所要求的"保持跨语言重复实现同步"的测试策略的一种具体实现(另见 editions/edition_defaults_test_utils.cc 等配套工具与测试文件)。

7.4 特性解析工具与 conformance 框架

8. 小结

Editions: Life of a FeatureSet 回答的问题很具体:在特性以扩展机制分散到各语言、且 import 无法保证发现的现实下,"谁拥有已解析特性"才有意义。文档否定了"protoc 全量解析、下传四套特性集"(代码体积不可承受)、"生成器自解析 + 下传全部四套"(体积依旧)、"占位符"(状态语义混乱)、"双向插件"与"中央注册表"(各有未解问题)等路线,最终收敛到一个工程上诚实的方案:

  1. 对外只有一种特性表示——未解析特性;已解析特性集永远是各进程内部状态,杜绝其公开暴露;
  2. 每个阶段(protoc / 每个生成器 / 每个运行时)独立执行完整的特性解析,把"最复杂"的 edition 默认值计算通过预编译的 FeatureSetDefaults(仓库中已实现为 FeatureSetDefaults 消息 + compile_edition_defaults 规则)分摊掉;
  3. 用 conformance 测试框架为跨语言重复兜底,并用文档与简单 API(如 C++ 生成器的注册机制与已解析特性访问接口)降低第三方接入门槛;
  4. 对无反射运行时、动态消息、descriptor.proto 自举等特殊场景分别给出降级、分阶段或特例化处理。

对使用者而言,这份设计文档的直接价值在于:它解释了为什么 descriptor protos 中看到的 features "看起来不完整"(因为它们本就不应该完整)、为什么各语言运行时都内置了同一份 edition 默认值数据,以及第三方代码生成器如果要支持 editions,应当遵循"自行解析未解析特性 + 通过 conformance 套件自证一致性"的路径。

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