Protobuf Editions 设计解析:edition-zero-features 文档中的 Feature 机制、默认值与 proto2/proto3 语义收敛
本文基于 protobuf 仓库内的设计文档 docs/design/editions/edition-zero-features.md(Edition Zero Features,2022-07-22 批准)展开,系统讲解 Editions("无 syntax 声明"的新 Protobuf 范式)最初提议的一组 feature 开关、其取值与默认值,以及 proto2/proto3 文件如何无语义损失地改写为 editions 文件的迁移规则。读完本文,你将理解 field_presence、enum_type、repeated_field_encoding、utf8/string 校验、json_format、message_encoding 六个 feature 的设计动机,并能对照当前仓库中 descriptor.proto 的最终 FeatureSet 实现与 editions/ 目录下的构建规则、测试 proto,验证这套设计是如何落地的。
一、文档定位:定义"第一个 edition"的收敛语义
该文档的原文目标(Overview)是:定义 Edition Zero Features,即"无 syntax Protobuf 新世界的第一个版本"。它规定了需要在 protoc 中实现的 feature 机制(editions 狭义下的"特性")以及选定的默认值。文档强调两点约束:
- 由于它实质上是在定义一种新的 Protobuf
syntax,必须保证任何 proto2/proto3 文件都能通过恰当设置 features 转换为 editions 文件,且不改变语义(即大規模重写的 no-op); - 必须处理"混合语法"(mixed syntax)消息的行为,不能有意外破坏。
文档中有一句重要的时效性说明(NOTE):该文档的内容" largely replaced"(大部分被取代)于后续正式发布的"Feature Settings for Editions"主题文档,因此阅读时应把它视为设计起点与决策记录,而不是当前的完整规范。
1.1 必须先保留的"历史欠账"
文档专门用一节 Existing Non-Conformance 指出:各语言实现对 proto2/proto3 语义本来就存在偏离。Edition Zero 的一个硬约束是必须保留这些偏离行为——因为只有这样,proto2/proto3 → editions 的机械改写才能是 no-op。文档举的例子是:文中定义 features.enum = {CLOSED, OPEN},但 Go 目前并没有按 proto2 语义实现 closed enum。这个不符规范的行为在 edition zero 中必须原样保留。用文档的话说:定义 feature 及其语义在范围内,但"修正各代码生成器使其完全符合这些语义"明确不在范围内。
1.2 术语表(Glossary)
因为 proto2 与 proto3 在部分概念上术语冲突,文档定义了以下术语(code font 字样指 Protobuf 语言关键字):
- presence discipline(存在性纪律):每个字段在概念上都有一个 hasbit——它是否被 API 显式设置过、或反序列化时是否出现过对应记录。纪律规定了这个位如何暴露给用户:
- No presence(无存在性):API 不暴露 hasbit。字段的默认值像一个特殊哨兵值:不序列化、也不会被 merge 覆盖。hasbit 可能仍存在于实现内部(例如 C++ 意外地通过
HasField泄漏了它)。repeated 字段大体上表现得像 no presence 字段。 - Explicit presence(显式存在性):API 通过
has方法和Clear方法暴露 hasbit;只要 hasbit 置位,默认值也会被序列化。
- No presence(无存在性):API 不暴露 hasbit。字段的默认值像一个特殊哨兵值:不序列化、也不会被 merge 覆盖。hasbit 可能仍存在于实现内部(例如 C++ 意外地通过
- closed enum(封闭枚举):解析时必须校验该类型字段解析出的
int32是否属于已知合法值集合。 - open enum(开放枚举):没有这个限制,本质上就是"有若干众所周知取值的
int32字段"。
二、收敛语义:proto2 与 proto3 的差异清单
文档指出需要捕捉两类语法行为:由关键字打开的(如 required)和隐式的(如 open enum)。proto2 与 proto3 的当下差异归纳为五条:
| 差异点 | proto2 | proto3 |
|---|---|---|
| Required | 有 required,没有 defaulted |
有 defaulted,没有 required;defaulted 字段不允许自定义默认值;message 类型上 defaulted 是 optional 的同义词 |
| Groups | 有 group | 没有 group |
| Enums | closed:不在已知集合内的枚举值存入 unknown field set | open |
| String 校验 | 序列化时是否必须 UTF-8 表现"摇摆不定" | 强制执行(有时) |
| Extensions | 支持 extensions | 不支持(Any 是官方替代方案) |
基于这份差异清单,文档提议了下面六个 feature。
三、逐项 Feature 详解
3.1 features.field_presence:单值字段的存在性纪律
枚举类型,控制单值(singular)字段的存在性纪律:
EXPLICIT(默认值):字段具有显式存在性。任何显式设置的值都会被序列化上线路(即使等于默认值)。IMPLICIT:字段无存在性。默认值不上线路(即使被显式设置)。LEGACY_REQUIRED:字段在线上是 required、在 API 上是 optional。设置它要求文件在required允许列表中。显式设置的值会被序列化(即使等于默认值)。
语法选择上,文档记录了讨论结论:同时删除 optional 和 required 关键字,出现即解析错误。单值字段像 proto3 一样不带标签书写,其存在性由 features.field_presence 决定。文档提到迁移代价:需要删除 google3 中所有 proto 文件里的 optional 实例,共 385,236 处。
语义层面的补充决定:
has_optional_keyword()与has_presence()从此检查EXPLICIT,二者实质同义;proto3_optional作为解析错误被拒绝(改用 feature);IMPLICIT字段的行为与 proto3 隐式字段类似:不能自定义默认值、在子消息字段上被忽略;若它是 enum 类型,则该枚举必须是 open(定义在syntax = "proto3"文件中,或传递性地设置了option features.enum = OPEN;)。
原文给出的迁移示例(迁移前):
// foo.proto
syntax = "proto2"
message Foo {
required int32 x = 1;
optional int32 y = 2;
repeated int32 z = 3;
}
// bar.proto
syntax = "proto3"
message Bar {
int32 x = 1;
optional int32 y = 2;
repeated int32 z = 3;
}
迁移后:
// foo.proto
edition = "tbd"
message Foo {
int32 x = 1 [features.field_presence = LEGACY_REQUIRED];
int32 y = 2;
repeated int32 z = 3;
}
// bar.proto
edition = "tbd"
option features.field_presence = NO_PRESENCE;
message Bar {
int32 x = 1;
int32 y = 2 [features.field_presence = EXPLICIT_PRESENCE];
repeated int32 z = 3;
}
注意迁移规则:proto2 的 required → 字段级 LEGACY_REQUIRED 标注;proto3 的 optional → 字段级 EXPLICIT 标注,而整个 proto3 文件则在文件级声明"无存在性"的默认。文档还列出了被否决的备选方案(Alternatives):强制书写 optional(对 proto3 用户是噪声)、发明 singular 新标签、允许 optional 与无标签并存(可能引起混淆)、proto:allow_required 侧车开关、引入真正的 defaulted 关键字、以及对 IMPLICIT 字段禁止自定义默认值等权衡。
Future Work:未来可以引入 features.always_serialize 或新的枚举项 ALWAYS_SERIALIZE,使 EXPLICIT_PRESENCE 字段无条件序列化,从而让 LEGACY_REQUIRED 字段在未来一次大改中降级为普通 EXPLICIT_PRESENCE。
3.2 features.enum_type:CLOSED 与 OPEN
两种枚举口味:
- closed:越界枚举值存入 unknown field set;
- open:越界值直接解析进字段。
文档附了两条 NOTE:
- closed enum 对"并行数组"(两个约定第 i 个索引对应同一逻辑概念的 repeated 字段)会造成混淆——unknown 值被挪进 unknown field set 后两个数组不再平行,且解析/序列化会改变 repeated closed enum 的顺序(unknown 值被移到末尾);
- C++ 和 Java 目前不是用枚举声明处判断 closed/open,而是用字段所在 message 的文件语法。为保留这一 proto2 怪癖,Java 和 C++(及有同样怪癖的运行时)将使用"字段所在 message 文件级"的
features.enum值:文件级设置features.enum = CLOSED时,其中定义的枚举字段一律按 closed 处理,与枚举声明在哪无关;而 Java/C++ 中的IMPLICIT单值字段总是按 open 处理(因为它们历史上只可能定义在 proto3 文件中)。
规则对比与决定:
- proto2:枚举 closed,首个枚举值无强制要求,首个枚举值即默认值;
- proto3:枚举 open,首个枚举值必须为零,默认值即该零值;
- edition zero:
features.enum_type = {CLOSED, OPEN},默认OPEN;由 proto2 升级来的文件显式设置CLOSED;同时取消"首个枚举值必须为零"的强制要求。
文档指出这名义上暴露了一个此前无法表达的配置空间:非零默认值的 OPEN 枚举。他们判断"仅仅因为以前写不出来就排除掉它"是得不偿失的。
迁移示例(迁移前):
// foo.proto
syntax = "proto2"
enum Foo {
A = 2, B = 4, C = 6,
}
// bar.proto
syntax = "proto3"
enum Bar {
A = 0, B = 1, C = 5,
}
迁移后:
// foo.proto
edition = "tbd"
option features.enum_type = CLOSED;
enum Foo {
A = 2, B = 4, C = 6,
}
// bar.proto
edition = "tbd"
enum Bar {
A = 0, B = 1, C = 5,
}
如果希望合并进一个文件,则改为在枚举体内部标注:
// foo.proto
edition = "tbd"
enum Foo {
option features.enum_type = CLOSED;
A = 2, B = 4, C = 6,
}
enum Bar {
A = 0, B = 1, C = 5,
}
被否决的备选方案:为"强制首值为零"单独加属性(过度复杂);直接去掉 CLOSED 能力(属于语义变更,不允许)。
3.3 features.repeated_field_encoding:PACKED 成为默认
文档给出的决策依据是真实数据:用户显式启用 packed 字段 12.3k 次,而显式禁用只 200 次——明显倾向 PACKED,这也与最佳实践一致。因此:
- proto3 中
repeated_field_encoding默认PACKED,proto2 中默认EXPANDED; features.repeated_field_encoding的默认值定为PACKED;- 既有的
[packed = …]语法在 edition zero 中被做成设置该 feature 的别名,该别名未来会被移除(是在启用 edition zero 的大规模改写时移除还是之后跟进,届时再定); - 长期目标是清除显式的
features.repeated_field_encoding = EXPANDED,但要把这次大改与 edition zero 的落地分开——所以迁移 proto2 文件时会在文件级显式设置EXPANDED。
迁移示例(迁移前):
// foo.proto
syntax = "proto2"
message Foo {
repeated int32 x = 1;
repeated int32 y = 2 [packed = true];
repeated int32 z = 3;
}
// bar.proto
syntax = "proto3"
message Foo {
repeated int32 x = 1;
repeated int32 y = 2 [packed = false];
repeated int32 z = 3;
}
迁移后:
// foo.proto
edition = "tbd"
options features.repeated_field_encoding = EXPANDED;
message Foo {
repeated int32 x = 1;
repeated int32 y = 2 [packed = true];
repeated int32 z = 3;
}
// bar.proto
edition = "tbd"
message Foo {
repeated int32 x = 1;
repeated int32 y = 2 [packed = false];
repeated int32 z = 3;
}
文档特别说明:迁移后没有把 packed 改写成 features.repeated_field_encoding = PACKED(虽然可以),倾向推迟到 editions 落地之后的大规模改写再做。被否决的备选:强制所有人 packed(语义变更);不加该 feature、转换时到处写 [packed = false](语法和 diff 双重噪声)。
3.4 features.string_field_validation:三态字符串校验
文档带着一句 WARNING:UTF-8 校验实际比预想复杂,该 feature 正在后续文档 Editions Zero Feature: utf8_validation 中被重新考虑(对应本仓库 docs/design/editions/edition-zero-feature-enum-field-closedness.md 等同目录系列设计文档的持续演进)。三态定义:
MANDATORY:运行时必须校验 UTF-8;HINT:运行时可以拒绝解析非法 UTF-8,也可以在部分构建模式为性能跳过检查;NONE:字段在 wire 上像bytes,但解析器可能以未规定的方式破坏字符串(例如 Java 可能插入替换字符)。
默认值为 MANDATORY。长期目标是移除该 feature、让所有 string 字段都 MANDATORY。被否决的备选:彻底放弃 UTF-8 要求(问题更多、与"string 是 UTF-8 类型、bytes 是其不校验兄弟"的愿景相悖);把 opt-in 校验做成硬性要求而非 hint(用户就失去了性能调节旋钮)。
Future Work 描述了一条更远的路径:识别出许多调用方真正想要的是"带 string 风格 API 的 bytes 字段",为此可增加每代码生成后端的 feature(如 java.bytes_as_string),让 bytes 字段获得类似 string 的生成 API;迁移时把 HINT/SKIP 的 string 字段转成带相应 API 修饰的 bytes(纯 C++ 的 proto 则什么都不用做)。
3.5 features.json_format:JSON 映射的严格程度
edition zero 中为双态:
ALLOW:运行时必须支持 JSON 解析与序列化,并在 proto 层面检查 JSON 映射是否有良定义;LEGACY_BEST_EFFORT:运行时尽力而为,允许存在导致运行时未定义行为的 proto(例如多对一、一对多映射)。
默认 ALLOW,对应当前 proto3 行为;LEGACY_BEST_EFFORT 留给需要它的 proto2 文件(例如设置了 deprecated_legacy_json_field_conflicts 的文件)。备选方案中"保留 proto2 行为"被否决——那会让 proto3 文件失去 JSON 映射校验、带来更多未定义行为;"只用 ALLOW"也被否决——内部有约 30 处依赖了未指定(但事实上定义良好)运行时行为的无效 JSON 映射。长期方向是移除该 feature 或用 DISALLOW 取代 LEGACY_BEST_EFFORT,在 proto 语言层面强制"没有合法 JSON 映射的 proto 不能做 JSON 序列化/解析"。
3.6 Extensions 一律允许
Extensions 可用于所有 message,解除了 proto3 的限制。文档同时指出 extensions 与 TypeResolver 配合不佳,"可以修,但大概只有有人抱怨时才值得修"。备选方案"加 features.allow_extensions(默认 true)"被否决——因为使用 extensions 本来就必须写 extend 和 extensions 语法,再加开关是多余的。
3.7 features.message_encoding:用 feature 取代 group 语法
默认 LENGTH_PREFIXED。editions 中不存在 group 语法;把 message 类型字段设为 features.message_encoding = DELIMITED 时,它按 group(wire type 3/4)编码,而不是长度前缀的字节块(wire type 2)。这样既保留了既有 API(group 本来就是"奇怪的 message 字段"),又简化了解析器。
迁移规则:proto2 的 group 字段转换为同名嵌套 message 类型,外加一个 DELIMITED 的单一子消息字段,字段名取 message 类型的 snake_case 形式。文档还提到这为"未来让新的 message 字段改用 group 编码"(此前提到的效率方向)留了口子。
迁移示例(迁移前):
// foo.proto
syntax = "proto2"
message Foo {
group Bar = 1 {
optional int32 x = 1;
repeated int32 y = 2;
}
}
迁移后:
// foo.proto
edition = "tbd"
message Foo {
message Bar {
optional int32 x = 1;
repeated int32 y = 2;
}
Bar bar = 1 [features.message_encoding = DELIMITED];
}
被否决的备选:在 editions 中原样保留 group 语法(group 本就已弃用,正好趁 edition = … 这个破坏性变更机会一并移除);为 group 增加类似 required 的侧车允许列表(与本 feature 基本正交,没必要)。
四、Proposed Features Message 原文与最终实现对照
文档最后把上述内容汇总为提议的 Features message(含 retention 与 target 规则):
message Features {
enum FieldPresence {
EXPLICIT = 0;
IMPLICIT = 1;
LEGACY_REQUIRED = 2;
}
optional FieldPresence field_presence = 1 [
retention = RUNTIME,
target = FILE,
target = FIELD
];
enum EnumType {
OPEN = 0;
CLOSED = 1;
}
optional EnumType enum = 2 [
retention = RUNTIME,
target = FILE,
target = ENUM
];
enum RepeatedFieldEncoding {
PACKED = 0;
UNPACKED = 1;
}
optional RepeatedFieldEncoding repeated_field_encoding = 3 [
retention = RUNTIME,
target = FILE,
target = FIELD
];
enum StringFieldValidation {
MANDATORY = 0;
HINT = 1;
NONE = 2;
}
optional StringFieldValidation string_field_validation = 4 [
retention = RUNTIME,
target = FILE,
target = FIELD
];
enum MessageEncoding {
LENGTH_PREFIXED = 0;
DELIMITED = 1;
}
optional MessageEncoding message_encoding = 5 [
retention = RUNTIME,
target = FILE,
target = FIELD
];
extensions 1000; // for features_cpp.proto
extensions 1001; // for features_java.proto
}
在最终落地的实现中,这个 message 以 FeatureSet 之名出现在 descriptor.proto(src/google/protobuf/descriptor.proto#L1060-L1149)。对照可见三点演进:
- 枚举值整体偏移了一位:为每个 feature 预留了
*_UNKNOWN = 0(如FIELD_PRESENCE_UNKNOWN = 0; EXPLICIT = 1; IMPLICIT = 2; LEGACY_REQUIRED = 3;),原提案中EXPLICIT = 0等编号不再成立。文档当时也自述"feature 的具体命名还只是 bikeshedding 阶段",编号后移符合这一预期。 - 默认值被参数化为按 edition 的默认表:提案里的"默认值"变成了
edition_defaults属性,逐 edition 声明。例如field_presence声明为EDITION_LEGACY → EXPLICIT、EDITION_PROTO3 → IMPLICIT、EDITION_2023 → EXPLICIT,正好对应文档"EXPLICIT 为默认"的决策;repeated_field_encoding声明为LEGACY → EXPANDED、PROTO3 → PACKED;utf8_validation(提案中叫string_field_validation)声明为LEGACY → NONE、PROTO3 → VERIFY;message_encoding声明LEGACY → LENGTH_PREFIXED,与提案默认一致。enum_type在表中显式声明了LEGACY → CLOSED、PROTO3 → OPEN,2023 版的解析则由下述 defaults 生成机制统一处理。 - 保留了按语言扩展的扩展段:提案中
extensions 1000; // for features_cpp.proto与1001; // for features_java.proto的思路最终实现为FeatureSet上overridable_features扩展字段,各语言 feature 定义(如cpp_features.proto、java_features.proto)通过扩展挂接——editions/defaults_test.cc 中就包含cpp_features.pb.h与java_features.pb.h,并断言测试用扩展pb::test的file_feature()解析结果,证明扩展解析链路的完整性。
4.1 默认值如何被"编译"进运行时
仓库提供了完整可查证的默认值生成与测试链路:
- editions/defaults.bzl 定义了两条规则:
compile_edition_defaults调用 protoc 的--edition_defaults_out(并带--edition_defaults_minimum/--edition_defaults_maximum、--proto_path)把各 edition 的 feature 默认值编译为.binpb;embed_edition_defaults再把这段二进制数据以octal/base64/decimal_array/hex_array四种编码之一嵌入 C++ 模板文件(对应 editions/defaults_test_embedded.h.template 等四个模板)。 - editions/defaults_test.cc 的
DefaultsTest.Check2023加载test_defaults_2023.binpb后断言defaults()[2]即EDITION_2023的overridable_features().field_presence() == FeatureSet::EXPLICIT——这正是本文第三节所述默认值决策在实现层的直接验证。 - 辅助工具见 editions/edition_defaults_test_utils.cc / editions/edition_defaults_test_utils.h。
4.2 迁移示例在仓库中的"活体"测试
文档中的迁移示例(proto2/proto3 → editions)在仓库里有对应的真实测试 proto 与 golden 文件:
- editions/codegen_tests/ 下存放着各 feature 的专用测试 proto,如 proto2_proto3_enum.proto(closed/open 枚举混合场景)、proto2_utf8_strict.proto、proto3_utf8_strict.proto(UTF-8 校验相关)、proto2_group.proto(group → DELIMITED 迁移)等,与 editions/BUILD 中的规则一起构成 codegen 级验证;
- editions/golden/ 下有
edition2023_transform/、edition2024_transform/的输入/输出对照(如 simple_proto2.proto),用于校验"把 legacy 语法转换为 editions"的转换结果是否符合文档规定的迁移规则; - 同目录系列设计文档 legacy-syntax-editions.md、edition-zero-converged-semantics.md、protobuf-editions-design-features.md 可视为本文档的配套与后续演进,适合对照阅读。
五、小结
| Feature | 取值 | 文档默认值 | 语义核心 |
|---|---|---|---|
field_presence |
EXPLICIT / IMPLICIT / LEGACY_REQUIRED | EXPLICIT | 删除 optional/required 关键字,存在性由 feature 表达 |
enum_type |
CLOSED / OPEN | OPEN | closed 值越界入 unknown set;取消"首值必须为零" |
repeated_field_encoding |
PACKED / UNPACKED | PACKED | [packed=…] 作为别名保留、计划移除;proto2 迁移文件级设 EXPANDED |
string_field_validation |
MANDATORY / HINT / NONE | MANDATORY | UTF-8 校验严格度;后被 utf8_validation 专题重新设计 |
json_format |
ALLOW / LEGACY_BEST_EFFORT | ALLOW | proto2 遗留的模糊 JSON 映射行为被隔离到显式标注 |
message_encoding |
LENGTH_PREFIXED / DELIMITED | LENGTH_PREFIXED | group 语法消失,改由字段级 DELIMITED 表达 |
这份 2022 年的设计文档的核心遗产在于:它把 proto2/proto3 之间所有"语法级"差异(required、group、枚举封闭性、packed、UTF-8、JSON 严格度、extensions 许可)统一归约为一个带 retention/target 元信息的 Features message 加上按 edition 的默认值表,并坚持"可无语义损失迁移"这一验收标准。虽然文档自述已被正式的 Feature Settings 文档大部分取代、最终实现的枚举编号与字段命名也有调整(如 enum_type 取代 enum、utf8_validation 取代 string_field_validation),但六大 feature 的划分、默认值取向以及迁移规则,均可在 descriptor.proto 的 FeatureSet、editions/defaults.bzl 的构建链路与 editions/codegen_tests/ 的测试 proto 中找到一一对应的落地证据。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00