实现版本支持

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

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

概览

2023 版本

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

功能定义

除了支持版本和我们定义的全局功能之外,您可能希望定义自己的功能来利用此基础设施。这将允许您定义任意功能,您的生成器和运行时可以使用这些功能来控制新行为。第一步是为 descriptor.proto 中高于 9999 的 FeatureSet 消息申请一个扩展号。您可以在 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,以便为“旧版本”提供默认值(参见 旧版本)。

什么是功能?

功能旨在提供一种机制,通过版本边界随时间逐渐消除不良行为。虽然实际移除功能的时间可能需要数年(或数十年),但任何功能的理想目标都应该是最终移除。当识别出不良行为时,您可以引入一个新功能来保护修复。在下一个版本(或可能更晚),您将翻转默认值,同时仍然允许用户在升级时保留其旧行为。在未来的某个时候,您将把该功能标记为已弃用,这将触发自定义警告,提示任何覆盖它的用户。在后续版本中,您将标记它已移除,阻止用户再覆盖它(但默认值仍然适用)。直到在破坏性发布中放弃对最后一个版本的支持,该功能对于停留在旧版本上的 proto 仍然可用,为它们提供迁移时间。

控制您无意移除的可选行为的标志最好作为自定义选项来实现。这与我们将功能限制为布尔或枚举类型的原因有关。任何由(相对)无限数量值控制的行为可能都不太适合版本框架,因为它不太可能最终取消那么多不同的行为。

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

生成器

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

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

显式支持

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

Codegen 测试

我们有一套 codegen 测试,可用于锁定 Edition 2023 不会产生意外的功能更改。这些测试在 C++ 和 Java 等语言中非常有用,因为很大一部分功能在 gencode 中。另一方面,在 Python 等语言中,gencode 基本只是序列化描述符的集合,这些测试就没有那么有用了。

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

运行时

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

带反射但不带动态消息的语言需要已解析的功能,但可以选择只在其生成器中处理。这可以通过在 codegen 期间将已解析和未解析的功能集传递给运行时来完成。这避免了在运行时重新实现功能解析,主要缺点是效率,因为它将为每个描述符创建一个唯一的功能集。

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

语法反射

在带反射的运行时中实现版本的第一步是移除所有对 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 - 字段是否 closed

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

  • FileDescriptor 语法
  • Proto3 optional APIs
    • 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,则应将其弃用并劝阻使用。

功能可见性

版本功能可见性中所述,功能 proto 应保留为任何 Protobuf 实现的内部细节。它们控制的行为应通过描述符方法暴露,但 proto 本身不应暴露。值得注意的是,这意味着任何暴露给用户的选项都需要去除其 features 字段。

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

旧版本

旧语法版本中更详细地讨论,获得版本实现早期覆盖率的一个好方法是统一 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...>

一旦默认消息被您的代码连接和解析,给定版本的 file descriptor 的功能解析遵循一个简单的算法

  1. 验证版本在适当的范围 [minimum_edition, maximum_edition] 内
  2. 对有序的 defaults 字段进行二分查找,找到小于或等于该版本的最高条目
  3. 将选定默认值中的 overridable_features 合并到 fixed_features
  4. 合并描述符上设置的任何显式功能(file options 中的 features 字段)

从那里,您可以递归解析所有其他描述符的功能

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

对于确定“父”描述符,您可以参考我们的C++ 实现。在大多数情况下这很简单,但扩展有点令人惊讶,因为它们的父级是封闭范围而不是 extendee。Oneofs 也需要被视为其字段的父级。

一致性测试

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

示例

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

Java

  • #14138 - 使用 C++ gencode 为 Java 功能 proto 引导编译器
  • #14377 - 在 Java、Kotlin 和 Java Lite 代码生成器中使用功能,包括 codegen 测试
  • #15210 - 在 Java full 运行时中使用功能,涵盖 Java 功能引导、功能解析和旧版本,以及单元测试和一致性测试

纯 Python

  • #14546 - 提前设置 codegen 测试
  • #14547 - 一次性完全实现版本,包括单元测试和一致性测试

𝛍pb

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

Ruby

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