实现 Editions 支持

有关在运行时和插件中实现版本支持的说明。

本主题解释了如何在新的运行时和生成器中实现版本(editions)。

概览

2023 版本

发布的第一个版本是 2023 版本,旨在统一 proto2 和 proto3 语法。我们为弥补行为差异而添加的特性在版本的特性设置中有详细说明。

特性定义

除了支持版本和我们已定义的全局特性外,您可能还希望定义自己的特性来利用此基础架构。这将允许您定义任意特性,供您的生成器和运行时用于控制新行为。第一步是在 descriptor.proto 中的 FeatureSet 消息中申请一个大于 9999 的扩展号。您可以在 GitHub 上向我们发送一个 pull-request,它将被包含在我们的下一个版本中(例如,请参见 #15439)。

获得扩展号后,您可以创建自己的特性 proto(类似于 cpp_features.proto)。这些通常看起来像这样

edition = "2023";

package foo;

import "google/protobuf/descriptor.proto";

extend google.protobuf.FeatureSet {
  MyFeatures features = <extension #>;
}

message MyFeatures {
  enum FeatureValue {
    FEATURE_VALUE_UNKNOWN = 0;
    VALUE1 = 1;
    VALUE2 = 2;
  }

  FeatureValue feature_value = 1 [
    targets = TARGET_TYPE_FIELD,
    targets = TARGET_TYPE_FILE,
    feature_support = {
      edition_introduced: EDITION_2023,
      edition_deprecated: EDITION_2024,
      deprecation_warning: "Feature will be removed in 2025",
      edition_removed: EDITION_2025,
    },
    edition_defaults = { edition: EDITION_LEGACY, value: "VALUE1" },
    edition_defaults = { edition: EDITION_2024, value: "VALUE2" }
  ];
}

这里我们定义了一个新的枚举特性 foo.feature_value(目前只支持布尔和枚举类型)。除了定义它可以取的值之外,您还需要指定它的使用方式

  • 目标(Targets) - 指定此特性可以附加到的 proto 描述符的类型。这控制了用户可以在何处显式指定该特性。每种类型都必须明确列出。
  • 特性支持(Feature support) - 指定此特性相对于版本的生命周期。您必须指定它被引入的版本,在此之前将不允许使用。您可以选择在后续版本中弃用或移除该特性。
  • 版本默认值(Edition defaults) - 指定特性的默认值的任何更改。这必须覆盖所有支持的版本,但您可以省略任何默认值未发生变化的版本。请注意,这里可以指定 EDITION_PROTO2EDITION_PROTO3,为“旧版”版本提供默认值(请参见旧版版本)。

什么是特性?

特性旨在提供一种机制,以便在版本边界上随着时间的推移逐步减少不良行为。虽然实际移除一个特性的时间线可能是数年(或数十年)之后,但任何特性的最终目标都应该是被移除。当发现一个不良行为时,您可以引入一个新特性来保护修复。在下一个版本(或可能更晚),您将翻转默认值,同时仍允许用户在升级时保留旧行为。在未来的某个时候,您会将该特性标记为已弃用,这将对任何覆盖它的用户触发自定义警告。在更晚的版本中,您会将其标记为已移除,从而阻止用户再覆盖它(但默认值仍将适用)。直到在某个重大发布中放弃对最后一个版本的支持之前,该特性对于停留在旧版本上的 proto 仍然可用,从而给它们时间进行迁移。

对于您不打算移除的可选行为控制标志,最好实现为自定义选项。这与我们将特性限制为布尔或枚举类型的原因有关。任何由(相对)无界数量的值控制的行为可能都不适合版本框架,因为最终减少这么多不同行为是不现实的。

对此的一个警告是与线路边界(wire boundaries)相关的行为。使用特定于语言的特性来控制序列化或解析行为可能很危险,因为任何其他语言都可能在另一端。线路格式的更改应始终由 descriptor.proto 中的全局特性控制,这样每个运行时都可以统一遵守。

生成器

用 C++ 编写的生成器可以免费获得很多好处,因为它们使用 C++ 运行时。它们不需要自己处理特性解析,如果需要任何特性扩展,可以在其 CodeGenerator 的 GetFeatureExtensions 中注册它们。它们通常可以使用 GetResolvedSourceFeatures 来访问代码生成中描述符的已解析特性,并使用 GetUnresolvedSourceFeatures 来访问它们自己的未解析特性。

用与其生成代码的运行时相同的语言编写的插件可能需要一些自定义的引导过程来定义其特性。

显式支持

生成器必须明确指定它们支持哪些版本。这使您可以按照自己的时间表,在版本发布后安全地添加对该版本的支持。Protoc 将拒绝任何发送给生成器的版本化 protos,如果这些生成器在其 CodeGeneratorResponsesupported_features 字段中不包含 FEATURE_SUPPORTS_EDITIONS。此外,我们有 minimum_editionmaximum_edition 字段来指定您的精确支持范围。一旦您为新版本定义了所有代码和特性更改,就可以提升 maximum_edition 来宣告此支持。

代码生成测试

我们有一套代码生成测试,可用于锁定 2023 版本不会产生意外的功能性更改。这在 C++ 和 Java 等语言中非常有用,因为这些语言的大量功能都在生成代码中。另一方面,在 Python 等语言中,生成代码基本上只是一组序列化的描述符,这些测试就不那么有用了。

这个基础架构目前还不可重用,但计划在未来的版本中实现。届时,您将能够使用它们来验证迁移到版本不会有任何意外的代码生成更改。

运行时

没有反射或动态消息的运行时应该不需要做任何事情来实现版本。所有这些逻辑都应由代码生成器处理。

反射但没有动态消息的语言需要已解析的特性,但可以选择只在它们的生成器中处理。这可以通过在代码生成期间将已解析和未解析的特性集都传递给运行时来完成。这样可以避免在运行时重新实现特性解析,主要缺点是效率较低,因为它会为每个描述符创建一个唯一的特性集。

具有动态消息的语言必须完全实现版本,因为它们需要在运行时构建描述符。

语法反射

在具有反射的运行时中实现版本的第一步是移除所有对 syntax 关键字的直接检查。所有这些都应移至更细粒度的特性辅助函数中,这些函数在必要时可以继续使用 syntax

应在描述符上实现以下特性辅助函数,并使用适合该语言的命名

  • FieldDescriptor::has_presence - 字段是否具有显式存在性(presence)
    • 重复字段永远没有存在性
    • 消息、扩展和 oneof 字段总是具有显式存在性
    • 其他所有字段当且仅当 field_presence 不是 IMPLICIT 时具有存在性
  • FieldDescriptor::is_required - 字段是否为必需(required)
  • FieldDescriptor::requires_utf8_validation - 字段是否应检查 utf8 有效性
  • FieldDescriptor::is_packed - 重复字段是否具有 packed 编码
  • FieldDescriptor::is_delimited - 消息字段是否具有分隔(delimited)编码
  • EnumDescriptor::is_closed - 枚举是否为封闭的(closed)

下游用户应迁移到这些新的辅助函数,而不是直接使用语法。以下类别的现有描述符 API 理想情况下应被弃用并最终移除,因为它们泄露了语法信息

  • FileDescriptor 语法
  • Proto3 optional API
    • FieldDescriptor::has_optional_keyword
    • OneofDescriptor::is_synthetic
    • Descriptor::*real_oneof* - 应重命名为 “oneof”,现有的 “oneof” 辅助函数应被移除,因为它们泄露了关于合成 oneof 的信息(这在版本中不存在)。
  • Group 类型
    • TYPE_GROUP 枚举值应被移除,并替换为 is_delimited 辅助函数。
  • Required 标签
    • LABEL_REQUIRED 枚举值应被移除,并替换为 is_required 辅助函数。

在许多类型的用户代码中,这些检查存在但并与版本冲突。例如,需要特殊处理 proto3 optional 因为其合成 oneof 实现的代码,只要其极性是类似 syntax == "proto3"(而不是检查 syntax != "proto2"),就不会与版本冲突。

如果无法完全移除这些 API,则应将其弃用并不再鼓励使用。

特性可见性

editions-feature-visibility 中所讨论的,特性 protos 应该保持为任何 Protobuf 实现的内部细节。它们控制的行为应该通过描述符方法暴露,但 protos 本身不应该。值得注意的是,这意味着任何暴露给用户的选项都需要剥离其 features 字段。

我们允许特性泄露的一种情况是序列化描述符时。生成的描述符 protos 应该是原始 proto 文件的忠实表示,并且应该在选项内包含未解析的特性

旧版版本

legacy-syntax-editions 中更详细讨论的,获得版本实现早期覆盖的一个好方法是统一 proto2、proto3 和版本。这实际上是将 proto2 和 proto3 在底层迁移到版本,并使语法反射中实现的所有辅助函数都专门使用特性(而不是根据语法进行分支)。这可以通过在特性解析中插入一个特性推断阶段来完成,其中 proto 文件的各个方面可以告知哪些特性是合适的。然后这些特性可以合并到父级的特性中,以获得已解析的特性集。

虽然我们已经为 proto2/proto3 提供了合理的默认值,但对于 2023 版本,还需要以下额外的推断

  • required - 当字段具有 LABEL_REQUIRED 时,我们推断为 LEGACY_REQUIRED 存在性
  • groups - 当字段具有 TYPE_GROUP 时,我们推断为 DELIMITED 消息编码
  • packed - 当 packed 选项为 true 时,我们推断为 PACKED 编码
  • expanded - 当 proto3 字段显式设置 packed 为 false 时,我们推断为 EXPANDED 编码

一致性测试

已经添加了特定于版本的符合性测试,但需要选择加入。可以向运行器传递一个 --maximum_edition 2023 标志来启用这些测试。您需要配置您的被测二进制文件以处理以下新的消息类型

  • protobuf_test_messages.editions.proto2.TestAllTypesProto2 - 与旧的 proto2 消息相同,但已转换为 2023 版本
  • protobuf_test_messages.editions.proto3.TestAllTypesProto3 - 与旧的 proto3 消息相同,但已转换为 2023 版本
  • protobuf_test_messages.editions.TestAllTypesEdition2023 - 用于覆盖 2023 版本特定的测试用例

特性解析

版本使用词法作用域来定义特性,这意味着任何需要实现版本支持的非 C++ 代码都需要重新实现我们的特性解析算法。然而,大部分工作由 protoc 本身处理,它可以配置为输出一个中间的 FeatureSetDefaults 消息。此消息包含一组特性定义文件的“编译”结果,列出了每个版本中的默认特性值。

例如,上面的特性定义将编译为 proto2 和 2025 版本之间的以下默认值(以文本格式表示)

defaults {
  edition: EDITION_PROTO2
  overridable_features { [foo.features] {} }
  fixed_features {
    // Global feature defaults…
    [foo.features] { feature_value: VALUE1 }
  }
}
defaults {
  edition: EDITION_PROTO3
  overridable_features { [foo.features] {} }
  fixed_features {
    // Global feature defaults…
    [foo.features] { feature_value: VALUE1 }
  }
}
defaults {
  edition: EDITION_2023
  overridable_features {
    // Global feature defaults…
    [foo.features] { feature_value: VALUE1 }
  }
}
defaults {
  edition: EDITION_2024
  overridable_features {
    // Global feature defaults…
    [foo.features] { feature_value: VALUE2 }
  }
}
defaults {
  edition: EDITION_2025
  overridable_features {
    // Global feature defaults…
  }
  fixed_features { [foo.features] { feature_value: VALUE2 } }
}
minimum_edition: EDITION_PROTO2
maximum_edition: EDITION_2025

为了简洁,全局特性默认值被省略了,但它们也会存在。此对象包含一个有序列表,其中包含在指定范围内具有唯一默认值集的每个版本(某些版本可能最终不会出现)。每组默认值分为可覆盖的固定的特性。前者是该版本支持的特性,可以由用户自由覆盖。固定的特性是那些尚未引入或已被移除的特性,不能被用户覆盖。

我们提供了一个 Bazel 规则来编译这些中间对象

load("@com_google_protobuf//editions:defaults.bzl", "compile_edition_defaults")

compile_edition_defaults(
    name = "my_defaults",
    srcs = ["//some/path:lang_features_proto"],
    maximum_edition = "PROTO2",
    minimum_edition = "2024",
)

输出的 FeatureSetDefaults 可以嵌入到您需要进行特性解析的任何语言的原始字符串字面量中。我们还提供了一个 embed_edition_defaults 宏来执行此操作

embed_edition_defaults(
    name = "embed_my_defaults",
    defaults = ":my_defaults",
    output = "my_defaults.h",
    placeholder = "DEFAULTS_DATA",
    template = "my_defaults.h.template",
)

或者,您可以直接调用 protoc(在 Bazel 之外)来生成此数据

protoc --edition_defaults_out=defaults.binpb --edition_defaults_minimum=PROTO2 --edition_defaults_maximum=2023 <feature files...>

一旦默认值消息被挂接并由您的代码解析,给定版本的文件描述符的特性解析遵循一个简单的算法

  1. 验证版本是否在适当的范围 [minimum_edition, maximum_edition] 内
  2. 在有序的 defaults 字段中二分搜索小于或等于该版本的最高条目
  3. overridable_features 合并到所选默认值中的 fixed_features
  4. 合并描述符上设置的任何显式特性(文件选项中的 features 字段)

从那里,您可以递归地解析所有其他描述符的特性

  1. 初始化为父描述符的特性集
  2. 合并描述符上设置的任何显式特性(选项中的 features 字段)

为了确定“父”描述符,您可以参考我们的 C++ 实现。这在大多数情况下都很直接,但扩展有点令人意外,因为它们的父级是封闭作用域而不是被扩展者。Oneofs 也需要被视为其字段的父级。

一致性测试

在未来的版本中,我们计划添加符合性测试来验证跨语言的特性解析。在此之前,我们常规的符合性测试确实提供了部分覆盖,我们的示例继承单元测试可以被移植以提供更全面的覆盖。

示例

以下是我们如何在运行时和插件中实现版本支持的一些真实示例。

Java

  • #14138 - 使用 C++ gencode 引导编译器,用于 Java 特性 proto
  • #14377 - 在 Java、Kotlin 和 Java Lite 代码生成器中使用特性,包括代码生成测试
  • #15210 - 在 Java 完整运行时中使用特性,涵盖 Java 特性引导、特性解析和旧版版本,以及单元测试和符合性测试

纯 Python

  • #14546 - 预先设置代码生成测试
  • #14547 - 一次性完全实现版本,以及单元测试和符合性测试

𝛍pb

  • #14638 - 初步实现版本,涵盖特性解析和旧版版本
  • #14667 - 添加了更完整的字段标签/类型处理、对 upb 代码生成器的支持以及一些测试
  • #14678 - 将 upb 连接到 Python 运行时,并提供更多单元测试和符合性测试

Ruby

  • #16132 - 将 upb/Java 连接到所有四个 Ruby 运行时,以实现完整的版本支持