实现版本支持

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

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

概述

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 可以在此处指定,以提供“遗留”版本的默认值(请参见 遗留版本)。

什么是特性?

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

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

这一点需要注意的是与线边界相关的行为。使用特定于语言的特性来控制序列化或解析行为可能很危险,因为任何其他语言都可能位于另一端。线格式更改应始终由 descriptor.proto 中的全局特性控制,每个运行时可以一致地尊重这些特性。

生成器

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

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

显式支持

生成器必须准确指定它们支持哪些版本。这允许您在版本发布后,根据自己的时间安排安全地添加对该版本的支持。Protoc 将拒绝发送到不包含其 CodeGeneratorResponsesupported_features 字段中的 FEATURE_SUPPORTS_EDITIONS 的生成器的任何版本 proto。此外,我们有 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 - 重复字段是否具有打包编码
  • FieldDescriptor::is_delimited - 消息字段是否具有定界编码
  • EnumDescriptor::is_closed - 字段是否已关闭

注意:在大多数语言中,消息编码特性目前仍由 TYPE_GROUP 信号发出,并且必需字段仍设置了 LABEL_REQUIRED。这不是理想的,这样做是为了使下游迁移更容易。最终,这些应该迁移到相应的帮助器和 TYPE_MESSAGE/LABEL_OPTIONAL

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

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

在许多类别的用户代码中存在这些检查,但对版本有害。例如,需要专门处理 proto3 optional 的代码(因为它 的合成 oneof 实现)只要极性类似于 syntax == "proto3"(而不是检查 syntax != "proto2"),就不会对版本有害。

如果无法完全删除这些 API,则应弃用并避免使用。

特性可见性

editions-feature-visibility中所述,功能原型应保持为任何 Protobuf 实现的内部细节。它们控制的行为应通过描述符方法公开,但原型本身不应公开。值得注意的是,这意味着任何公开给用户的选项都需要将其features字段去除。

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

旧版版本

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

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

  • required - 当字段具有LABEL_REQUIRED时,我们推断LEGACY_REQUIRED的存在。
  • groups - 当字段具有TYPE_GROUP时,我们推断DELIMITED消息编码。
  • packed - 当packed选项为真时,我们推断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",
)

或者,您可以直接调用 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 运行时以获得完整的版本支持。