实现 Editions 支持
本主题解释了如何在新的运行时和生成器中实现 editions。
概览
2023 版
发布的第一个 edition 是 Edition 2023,旨在统一 proto2 和 proto3 语法。我们为弥合行为差异而添加的特性在Editions 的特性设置中有详细说明。
特性定义
除了支持 editions 和我们已定义的全局特性外,您可能还希望定义自己的特性以利用此基础架构。这将允许您定义任意特性,供您的生成器和运行时用于控制新行为。第一步是在 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 的生命周期。您必须指定它被引入的 edition,在此之前它是不被允许的。您可以选择在后续的 editions 中弃用或移除该特性。
- Edition 默认值(Edition defaults) - 指定特性的默认值的任何更改。这必须覆盖每个受支持的 edition,但您可以省略任何默认值没有改变的 edition。请注意,这里可以指定
EDITION_PROTO2和EDITION_PROTO3,为“遗留” editions 提供默认值(参见遗留 Editions)。
什么是特性?
特性的设计初衷是提供一种机制,在 edition 的边界上,随着时间的推移逐步减少不良行为。虽然实际移除一个特性的时间线可能长达数年(或数十年),但任何特性的最终目标都应该是被移除。当发现一个不良行为时,您可以引入一个新的特性来保护修复。在下一个 edition(或可能更晚),您会翻转默认值,同时仍然允许用户在升级时保留旧行为。在未来的某个时间点,您会将该特性标记为已弃用,这将对任何覆盖它的用户触发自定义警告。在更晚的 edition 中,您会将其标记为已移除,从而阻止用户再覆盖它(但默认值仍然适用)。在一个破坏性版本中放弃对最后一个 edition 的支持之前,该特性对于停留在旧 editions 上的 protos 仍然可用,给它们时间进行迁移。
对于那些您不打算移除的可选行为控制标志,最好实现为自定义选项。这与我们将特性限制为布尔或枚举类型的原因有关。任何由(相对)无界数量的值控制的行为,可能都不适合 editions 框架,因为最终关闭那么多不同的行为是不现实的。
对此的一个警告是与网络边界相关的行为。使用特定于语言的特性来控制序列化或解析行为可能很危险,因为另一端可能是任何其他语言。网络格式的更改应始终由 descriptor.proto 中的全局特性控制,以便每个运行时都能统一遵守。
生成器
用 C++ 编写的生成器可以免费获得很多好处,因为它们使用 C++ 运行时。它们不需要自己处理特性解析,如果需要任何特性扩展,可以在其 CodeGenerator 的 GetFeatureExtensions 中注册它们。它们通常可以使用 GetResolvedSourceFeatures 来访问代码生成中描述符的已解析特性,并使用 GetUnresolvedSourceFeatures 来访问它们自己的未解析特性。
用与其生成代码的运行时相同语言编写的插件可能需要为其特性定义进行一些自定义引导。
显式支持
生成器必须明确指定它们支持哪些 editions。这允许您在某个 edition 发布后,按照自己的时间表安全地添加对其的支持。Protoc 会拒绝将任何 editions protos 发送给那些在其 CodeGeneratorResponse 的 supported_features 字段中不包含 FEATURE_SUPPORTS_EDITIONS 的生成器。此外,我们有 minimum_edition 和 maximum_edition 字段用于指定您精确的支持窗口。一旦您为新的 edition 定义了所有代码和特性更改,就可以提升 maximum_edition 来宣告此支持。
代码生成测试
我们有一套代码生成测试,可用于确保 Edition 2023 不会产生意外的功能性更改。这些测试在 C++ 和 Java 等语言中非常有用,因为这些语言的大部分功能都在生成代码中。另一方面,在像 Python 这样的语言中,生成代码基本上只是一组序列化的描述符,这些测试就不是那么有用了。
这个基础架构尚不可重用,但计划在未来版本中提供。届时,您将能够使用它们来验证迁移到 editions 不会产生任何意外的代码生成更改。
运行时
没有反射或动态消息的运行时应该不需要做任何事情来实现 Editions。所有这些逻辑都应该由代码生成器处理。
有反射但没有动态消息的语言需要已解析的特性,但可以选择只在它们的生成器中处理它。这可以通过在代码生成期间将已解析和未解析的特性集都传递给运行时来完成。这样可以避免在运行时重新实现特性解析,主要缺点是效率不高,因为它会为每个描述符创建一个唯一的特性集。
有动态消息的语言必须完全实现 Editions,因为它们需要在运行时构建描述符。
语法反射
在具有反射的运行时中实现 Editions 的第一步是移除所有对 syntax 关键字的直接检查。所有这些检查都应移至更细粒度的特性辅助函数中,这些函数如果需要,可以继续使用 syntax。
应在描述符上实现以下特性辅助函数,并采用适合该语言的命名
FieldDescriptor::has_presence- 字段是否具有显式存在性(presence)- 重复字段永远没有存在性
- 消息、扩展和 oneof 字段总是有显式存在性
- 其他所有字段当且仅当
field_presence不是IMPLICIT时才有存在性
FieldDescriptor::is_required- 字段是否为必需(required)FieldDescriptor::requires_utf8_validation- 字段是否应检查 UTF-8 有效性FieldDescriptor::is_packed- 重复字段是否具有 packed 编码FieldDescriptor::is_delimited- 消息字段是否具有分隔(delimited)编码EnumDescriptor::is_closed- 枚举是否为封闭(closed)
注意
在大多数语言中,消息编码特性目前仍由TYPE_GROUP 表示,必需字段仍然设置了 LABEL_REQUIRED。这并不理想,这样做是为了让下游的迁移更容易。最终,这些应该迁移到适当的辅助函数和 TYPE_MESSAGE/LABEL_OPTIONAL。下游用户应该迁移到使用这些新的辅助函数,而不是直接使用语法。以下类别的现有描述符 API 最好被弃用并最终移除,因为它们会泄露语法信息
FileDescriptor语法- Proto3 optional API
FieldDescriptor::has_optional_keywordOneofDescriptor::is_syntheticDescriptor::*real_oneof*- 应重命名为 “oneof”,并移除现有的 “oneof” 辅助函数,因为它们泄露了关于合成 oneof(在 editions 中不存在)的信息。
- Group 类型
- 应移除
TYPE_GROUP枚举值,替换为is_delimited辅助函数。
- 应移除
- Required 标签
- 应移除
LABEL_REQUIRED枚举值,替换为is_required辅助函数。
- 应移除
在许多类别的用户代码中,这些检查存在但并不与 editions 冲突。例如,由于其合成 oneof 实现而需要特殊处理 proto3 optional 的代码,只要其判断条件是类似 syntax == "proto3"(而不是检查 syntax != "proto2")的形式,就不会与 editions 冲突。
如果无法完全移除这些 API,应该将它们弃用并不再鼓励使用。
特性可见性
正如在 editions-feature-visibility 中讨论的,特性 protos 应该保持为任何 Protobuf 实现的内部细节。它们控制的行为应该通过描述符方法暴露,但 protos 本身不应该。值得注意的是,这意味着任何暴露给用户的选项都需要移除其 features 字段。
我们允许特性泄露的一种情况是序列化描述符时。生成的描述符 protos 应该是原始 proto 文件的忠实表示,并且应该在选项中包含未解析的特性。
遗留 Editions
正如在 legacy-syntax-editions 中更详细讨论的,一种获得 editions 实现早期覆盖的好方法是统一 proto2、proto3 和 editions。这实际上是在底层将 proto2 和 proto3 迁移到了 editions,并使语法反射中实现的所有辅助函数都只使用特性(而不是根据语法进行分支判断)。这可以通过在特性解析中插入一个特性推断阶段来完成,其中 proto 文件的各个方面可以为哪些特性是合适的提供信息。然后这些特性可以合并到父级的特性中,以获得已解析的特性集。
虽然我们已经为 proto2/proto3 提供了合理的默认值,但对于 edition 2023,需要进行以下额外的推断
- required - 当字段具有
LABEL_REQUIRED时,我们推断为LEGACY_REQUIRED存在性 - groups - 当字段具有
TYPE_GROUP时,我们推断为DELIMITED消息编码 - packed - 当
packed选项为 true 时,我们推断为PACKED编码 - expanded - 当 proto3 字段显式设置
packed为 false 时,我们推断为EXPANDED编码
一致性测试
已添加了针对 Editions 的一致性测试,但需要选择加入。可以向运行器传递一个 --maximum_edition 2023 标志来启用它们。您需要配置您的被测试二进制文件以处理以下新的消息类型
protobuf_test_messages.editions.proto2.TestAllTypesProto2- 与旧的 proto2 消息相同,但已转换为 edition 2023protobuf_test_messages.editions.proto3.TestAllTypesProto3- 与旧的 proto3 消息相同,但已转换为 edition 2023protobuf_test_messages.editions.TestAllTypesEdition2023- 用于覆盖 edition-2023 特定的测试用例
特性解析
Editions 使用词法作用域来定义特性,这意味着任何需要实现 editions 支持的非 C++ 代码都需要重新实现我们的特性解析算法。然而,大部分工作由 protoc 本身处理,它可以被配置为输出一个中间的 FeatureSetDefaults 消息。此消息包含一组特性定义文件的“编译”结果,列出了每个 edition 中的默认特性值。
例如,上面的特性定义将编译为 proto2 和 edition 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
为了简洁,全局特性默认值被省略了,但它们也会存在。此对象包含一个有序列表,列出了在指定范围内具有唯一默认值集的每个 edition(某些 editions 可能最终不会出现)。每组默认值分为可覆盖和固定特性。前者是该 edition 支持的特性,可以由用户自由覆盖。固定特性是那些尚未引入或已被移除的特性,用户无法覆盖。
我们提供一个 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...>
一旦默认值消息被连接并由您的代码解析,针对给定 edition 的文件描述符的特性解析遵循一个简单的算法
- 验证 edition 是否在适当的范围 [
minimum_edition,maximum_edition] 内 - 对有序的
defaults字段进行二分搜索,找到小于或等于该 edition 的最高条目 - 将所选默认值中的
overridable_features合并到fixed_features中 - 合并描述符上设置的任何显式特性(文件选项中的
features字段)
从那里,您可以递归地为所有其他描述符解析特性
- 初始化为父描述符的特性集
- 合并描述符上设置的任何显式特性(选项中的
features字段)
要确定“父”描述符,您可以参考我们的C++ 实现。在大多数情况下这很简单,但扩展有点令人意外,因为它们的父级是其外围作用域而不是被扩展者。Oneofs 也需要被视为其字段的父级。
一致性测试
在未来的版本中,我们计划添加一致性测试来验证跨语言的特性解析。在此之前,我们常规的一致性测试确实提供了部分覆盖,并且我们的继承单元测试示例可以被移植以提供更全面的覆盖。
示例
以下是我们在运行时和插件中实现 editions 支持的一些真实示例。
Java
- #14138 - 使用 C++ gencode 引导编译器以支持 Java 特性 proto
- #14377 - 在 Java、Kotlin 和 Java Lite 代码生成器中使用特性,包括代码生成测试
- #15210 - 在 Java 完整运行时中使用特性,涵盖 Java 特性引导、特性解析和遗留 editions,以及单元测试和一致性测试
纯 Python
𝛍pb
- #14638 - 初次尝试实现 editions,涵盖特性解析和遗留 editions
- #14667 - 添加了更完整的字段标签/类型处理,支持 upb 的代码生成器,以及一些测试
- #14678 - 将 upb 连接到 Python 运行时,包含更多单元测试和一致性测试
Ruby
- #16132 - 将 upb/Java 连接到所有四个 Ruby 运行时以实现完整的 editions 支持