实施版本支持

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

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

概述

2023 版本

发布的第一个版本是 2023 版本,旨在统一 proto2 和 proto3 语法。我们在 版本的特性设置 中详细介绍了为弥合行为差异而添加的功能。

特性定义

除了支持版本和我们定义的全局特性之外,您可能还希望定义自己的特性以利用基础设施。这将允许您定义任意特性,这些特性可供您的生成器和运行时用于控制新行为。第一步是为 descriptor.proto 中 FeatureSet 消息的扩展号声明 9999 以上的数字。您可以向我们在 GitHub 上发送拉取请求,它将被包含在我们的下一个版本中(例如,请参阅 #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(目前仅支持布尔和枚举类型)。除了定义它可以采用的值之外,您还需要指定如何使用它

  • 目标 - 指定此特性可以附加到的 proto 描述符的类型。这控制用户可以在何处显式指定特性。每个类型都必须显式列出。
  • 特性支持 - 指定此特性相对于版本的生命周期。您必须指定引入它的版本,并且在此之前不允许使用它。您可以选择在以后的版本中弃用或删除该特性。
  • 版本默认值 - 指定特性默认值的任何更改。这必须涵盖每个支持的版本,但您可以省略默认值未更改的任何版本。请注意,此处可以指定 EDITION_PROTO2EDITION_PROTO3,以便为“旧版”版本提供默认值(请参阅 旧版版本)。

什么是特性?

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

您无意删除的控制可选行为的标志最好实现为 自定义选项。这与我们将特性限制为布尔类型或枚举类型的原因有关。任何受(相对)无限数量的值控制的行为可能都不太适合版本框架,因为最终关闭如此多不同的行为是不现实的。

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

生成器

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

以与它们为其生成代码的运行时相同的语言编写的插件可能需要为其特性定义进行一些自定义引导。

显式支持

生成器必须准确指定它们支持的版本。这允许您在版本发布后,按照您自己的计划安全地添加对版本的支持。Protoc 将拒绝发送给其 CodeGeneratorResponsesupported_features 字段中不包含 FEATURE_SUPPORTS_EDITIONS 的生成器的任何版本 protos。此外,我们有 minimum_editionmaximum_edition 字段,用于指定您的精确支持窗口。一旦您为新版本定义了所有代码和特性更改,您就可以增加 maximum_edition 以宣传此支持。

代码生成测试

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

此基础设施尚不可重用,但计划在未来的版本中实现。届时,您将能够使用它们来验证迁移到版本是否没有任何意外的代码生成更改。

运行时

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

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

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

语法反射

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

应在描述符上实施以下特性帮助程序,并使用适合语言的命名

  • FieldDescriptor::has_presence - 字段是否具有显式存在性
    • 重复字段永远没有存在性
    • 消息、扩展和 oneof 字段始终具有显式存在性
    • 其他所有内容都具有存在性,当且仅当 field_presence 不是 IMPLICIT
  • FieldDescriptor::is_required - 字段是否为必需字段
  • FieldDescriptor::requires_utf8_validation - 字段是否应检查 utf8 有效性
  • FieldDescriptor::is_packed - 重复字段是否具有 packed 编码
  • FieldDescriptor::is_delimited - 消息字段是否具有 delimited 编码
  • EnumDescriptor::is_closed - 字段是否为封闭的

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

  • FileDescriptor 语法
  • Proto3 可选 API
    • FieldDescriptor::has_optional_keyword
    • OneofDescriptor::is_synthetic
    • Descriptor::*real_oneof* - 应重命名为 “oneof”,并且应删除现有的 “oneof” 帮助程序,因为它们泄漏了有关合成 oneof 的信息(合成 oneof 在版本中不存在)。
  • Group 类型
    • 应删除 TYPE_GROUP 枚举值,并用 is_delimited 帮助程序替换。
  • Required 标签
    • 应删除 LABEL_REQUIRED 枚举值,并用 is_required 帮助程序替换。

在许多类别的用户代码中,这些检查都存在,但与版本冲突。例如,由于其合成 oneof 实现,需要专门处理 proto3 optional 的代码,只要极性类似于 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 本身处理,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",
)

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

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++ 实现。这在大多数情况下都很简单,但扩展有点令人惊讶,因为它们的父级是封闭范围,而不是扩展对象。Oneof 也需要被视为其字段的父级。

一致性测试

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

示例

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

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 运行时,以实现完整的版本支持