首页
/ Protobuf Editions 设计解析:edition-zero-features 文档中的 Feature 机制、默认值与 proto2/proto3 语义收敛

Protobuf Editions 设计解析:edition-zero-features 文档中的 Feature 机制、默认值与 proto2/proto3 语义收敛

2026-09-04 21:24:47作者:袁立春Spencer

本文基于 protobuf 仓库内的设计文档 docs/design/editions/edition-zero-features.md(Edition Zero Features,2022-07-22 批准)展开,系统讲解 Editions("无 syntax 声明"的新 Protobuf 范式)最初提议的一组 feature 开关、其取值与默认值,以及 proto2/proto3 文件如何无语义损失地改写为 editions 文件的迁移规则。读完本文,你将理解 field_presenceenum_typerepeated_field_encodingutf8/string 校验json_formatmessage_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 置位,默认值也会被序列化。
  • closed enum(封闭枚举):解析时必须校验该类型字段解析出的 int32 是否属于已知合法值集合。
  • open enum(开放枚举):没有这个限制,本质上就是"有若干众所周知取值的 int32 字段"。

二、收敛语义:proto2 与 proto3 的差异清单

文档指出需要捕捉两类语法行为:由关键字打开的(如 required)和隐式的(如 open enum)。proto2 与 proto3 的当下差异归纳为五条:

差异点 proto2 proto3
Required required,没有 defaulted defaulted,没有 requireddefaulted 字段不允许自定义默认值;message 类型上 defaultedoptional 的同义词
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 允许列表中。显式设置的值会被序列化(即使等于默认值)。

语法选择上,文档记录了讨论结论:同时删除 optionalrequired 关键字,出现即解析错误。单值字段像 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:

  1. closed enum 对"并行数组"(两个约定第 i 个索引对应同一逻辑概念的 repeated 字段)会造成混淆——unknown 值被挪进 unknown field set 后两个数组不再平行,且解析/序列化会改变 repeated closed enum 的顺序(unknown 值被移到末尾);
  2. 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/SKIPstring 字段转成带相应 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 本来就必须写 extendextensions 语法,再加开关是多余的。

3.7 features.message_encoding:用 feature 取代 group 语法

默认 LENGTH_PREFIXEDeditions 中不存在 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.protosrc/google/protobuf/descriptor.proto#L1060-L1149)。对照可见三点演进:

  1. 枚举值整体偏移了一位:为每个 feature 预留了 *_UNKNOWN = 0(如 FIELD_PRESENCE_UNKNOWN = 0; EXPLICIT = 1; IMPLICIT = 2; LEGACY_REQUIRED = 3;),原提案中 EXPLICIT = 0 等编号不再成立。文档当时也自述"feature 的具体命名还只是 bikeshedding 阶段",编号后移符合这一预期。
  2. 默认值被参数化为按 edition 的默认表:提案里的"默认值"变成了 edition_defaults 属性,逐 edition 声明。例如 field_presence 声明为 EDITION_LEGACY → EXPLICITEDITION_PROTO3 → IMPLICITEDITION_2023 → EXPLICIT,正好对应文档"EXPLICIT 为默认"的决策;repeated_field_encoding 声明为 LEGACY → EXPANDEDPROTO3 → PACKEDutf8_validation(提案中叫 string_field_validation)声明为 LEGACY → NONEPROTO3 → VERIFYmessage_encoding 声明 LEGACY → LENGTH_PREFIXED,与提案默认一致。enum_type 在表中显式声明了 LEGACY → CLOSEDPROTO3 → OPEN,2023 版的解析则由下述 defaults 生成机制统一处理。
  3. 保留了按语言扩展的扩展段:提案中 extensions 1000; // for features_cpp.proto1001; // for features_java.proto 的思路最终实现为 FeatureSetoverridable_features 扩展字段,各语言 feature 定义(如 cpp_features.protojava_features.proto)通过扩展挂接——editions/defaults_test.cc 中就包含 cpp_features.pb.hjava_features.pb.h,并断言测试用扩展 pb::testfile_feature() 解析结果,证明扩展解析链路的完整性。

4.1 默认值如何被"编译"进运行时

仓库提供了完整可查证的默认值生成与测试链路:

  • editions/defaults.bzl 定义了两条规则:compile_edition_defaults 调用 protoc 的 --edition_defaults_out(并带 --edition_defaults_minimum/--edition_defaults_maximum--proto_path)把各 edition 的 feature 默认值编译为 .binpbembed_edition_defaults 再把这段二进制数据以 octal/base64/decimal_array/hex_array 四种编码之一嵌入 C++ 模板文件(对应 editions/defaults_test_embedded.h.template 等四个模板)。
  • editions/defaults_test.ccDefaultsTest.Check2023 加载 test_defaults_2023.binpb 后断言 defaults()[2]EDITION_2023overridable_features().field_presence() == FeatureSet::EXPLICIT——这正是本文第三节所述默认值决策在实现层的直接验证。
  • 辅助工具见 editions/edition_defaults_test_utils.cc / editions/edition_defaults_test_utils.h

4.2 迁移示例在仓库中的"活体"测试

文档中的迁移示例(proto2/proto3 → editions)在仓库里有对应的真实测试 proto 与 golden 文件:

五、小结

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 取代 enumutf8_validation 取代 string_field_validation),但六大 feature 的划分、默认值取向以及迁移规则,均可在 descriptor.protoFeatureSeteditions/defaults.bzl 的构建链路与 editions/codegen_tests/ 的测试 proto 中找到一一对应的落地证据。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384