首页
/ Protobuf Editions 2023:DELIMITED 编码如何兼容 proto2 group——IsGroupLike 与 Smooth Extension 设计解析

Protobuf Editions 2023:DELIMITED 编码如何兼容 proto2 group——IsGroupLike 与 Smooth Extension 设计解析

2026-09-04 15:47:30作者:瞿蔚英Wynne

本篇解读 Protobuf 仓库的设计文档 group-migration-issues.md。它记录了 Edition 2023 中 DELIMITED 编码特性在开源发布前暴露出的一个严重隐患:新版定界编码过度依赖 proto2 group 时代的“类型名/字段名同步”假设,导致该特性在多数场景下几乎不可用。读完本文,你将理解 proto2 group 的命名耦合为何成为迁移障碍,各语言生成代码(codegen)与文本格式(text format)的具体分歧,以及仓库最终选择的 “Smooth Extension” 方案如何通过在 descriptor.cc 中实现的 IsGroupLike() 判定函数,在不引入新语言特性、不破坏兼容性的前提下同时保住旧行为并解锁新迁移。

一、背景:DELIMITED 编码为何“几乎无用”

DELIMITED 是 Edition 2023 引入的字段级消息编码特性。在 descriptor.proto 中,它作为 MessageEncoding 枚举的一个值被定义:

enum MessageEncoding {
  MESSAGE_ENCODING_UNKNOWN = 0;
  LENGTH_PREFIXED = 1;
  DELIMITED = 2;
}
optional MessageEncoding message_encoding = 5 [
  retention = RETENTION_RUNTIME,
  targets = TARGET_TYPE_FIELD,
  targets = TARGET_TYPE_FILE,
  feature_support = {
    edition_introduced: EDITION_2023,
  },
  edition_defaults = { edition: EDITION_LEGACY, value: "LENGTH_PREFIXED" }
];

从源码结构看:message_encoding 是运行时保留(RETENTION_RUNTIME)的特性,作用于字段与文件两个作用域(TARGET_TYPE_FIELD / TARGET_TYPE_FILE),由 Edition 2023 引入(EDITION_2023),且 legacy edition 默认回退为 LENGTH_PREFIXED。这正是 proto2 group 的定界编码在现代特征系统(feature set)下的对应物。

问题是在 Edition 2023 早期版本的实验中,有人发现:新的消息编码特性过度沿用了旧 group 的处理逻辑,结果在一般情况下几乎不可用。而既有测试与迁移工具之所以没有拦截住这个问题,是因为它们的重心放在保持旧行为上——这正是 edition 2023 的首要目标。凡是结构上完全仿照 proto2 group 的定界消息(消息与字段同作用域、名称匹配),行为都原封未动,看起来一切正常。

文档特别指出,这个问题的严重性在于:当时存在一份计划将整个生态迁往定界编码的内部设计(Submessages: In Pursuit of a More Perfect Encoding)。把一个“半残”的特性当作消除废弃语法的迁移工具发布,问题尚且有限;但若要把整个生态推向它,则是不可接受的。

二、问题根源:group 的合成消息与不可逆的大小写转换

在 Edition 2023 之前,group 字段的字段名与类型名总是保证唯一且可互相推导的。Proto2 会把 group 拆成一个合成的嵌套消息:

  • 类型名与 group 声明名一致(要求首字母大写);
  • 字段名则是全小写的类型名。

例如:

optional group MyGroup = 1 { ... }

等价于:

message MyGroup { ... }
optional MyGroup mygroup = 1;

文档强调,这里的大小写非常关键,因为该转换是不可逆的:从字段名一般无法还原 group 名——只有当 group 只由单个单词组成时才行。

Edition 2023 移除了“同步生成合成消息”这一语言机制:用户现在显式定义消息,任何消息字段都可以被标记为 DELIMITED。这意味着所有假设“类型名与字段名保持同步”的下游逻辑——生成器、文本解析器——都可能被打破。这正是后续各语言行为分歧的总根源。

三、codegen 行为盘点:各语言生成 API 的命名分歧

用字段名来命名生成 API,可以让生成器少写特判代码,但对于多词驼峰 group,字段名生成的 API 可读性略差。其结果是各生成器呈现出一种“看似随机”的分裂。文档用 protoc-explorer 的调查结果给出如下总表:

语言 Edition 2023 生成 API 命名依据 proto2 时代 getter 示例
C++ 字段名 MyGroup mygroup()
Java(全部) 消息名(类型名) MyGroup getMyGroup()
Python 字段名 mygroup
Go(全部) 字段名 GetMygroup() *Foo_MyGroup
Dart V1 字段名 / 消息名(实现期有意修正) get mygroup
upb(含 Ruby、Rust 等所有基于 upb 的运行时) 字段名 Foo_mygroup()
Objective-C 消息名(类型名) MyGroup* myGroup
Swift 消息名(类型名) MyGroup myGroup
C# 字段名 / 消息名(扩展字段用字段名) MyGroup Mygroup

几点值得注意的背景(引自文档):

  • Dart V1 在实现过程中有意引入了升级行为变化,因为确认 google3 中只有极少数 proto 受影响,可人工修复;
  • Java 的影响面要大得多,因为相当大比例的 proto 会生成 Java 代码;
  • Objective-C 值得单独关注,因为它已开源;Swift 在开源社区使用广泛且不在 Google 控制范围内。

由此产生的风险是:即使 editions 升级在整体上仍属非破坏性(non-breaking),生成 API 也可能出现非常意外的拼写,甚至可能不唯一。极端情况下,在同一个消息里给两个 DELIMITED 字段使用同一个类型,会在某些语言中产生两套同名的生成 API。

这一行为分歧在仓库源码中留有清晰的“指纹”:各语言生成器都通过 IsGroupLike 判断是否沿用 proto2 的“类型名”惯例。例如 Java 生成器在 names.cc 中:

std::string FieldName(const FieldDescriptor* field) {
  std::string field_name;
  // Groups are hacky:  The name of the field is just the lower-cased name
  // of the group type.  In Java, though, we would like to retain the original
  // capitalization of the type name.
  if (internal::cpp::IsGroupLike(*field)) {
    field_name = std::string(field->message_type()->name());
  } else {
    field_name = std::string(field->name());
  }
  ...

对 group-like 字段保留类型名的原始大写(对应文档表格中 Java/ObjC/Swift 一列);Objective-C 同样在 objectivec/names.ccobjectivec/field.cc 中做同样判断,C# 则见 csharp_helpers.cc。而 C++、Python、Go、upb 一路的生成器直接使用 field->name(),即字段名,与文档表格完全吻合。

四、文本格式:按消息名输出、只接受消息名解析

官方文本格式规范明确规定:group 消息按消息名而非小写字段名编码。group MyGroup 会被序列化为:

MyGroup {
  ...
}

在 C++ 实现中,序列化时始终输出消息名,解析时只接受消息名(正路径有 conformance 测试锁定;负路径——拒绝字段名——没有 conformance 测试,但 C++/Java/Python 行为一致,也没有已知的不一致案例)。仓库中这段逻辑位于 text_format.ccPrintFieldName

void TextFormat::FastFieldValuePrinter::PrintFieldName(
    const Message& /*message*/, const Reflection* /*reflection*/,
    const FieldDescriptor* field, BaseTextGenerator* generator) const {
  if (field->is_extension()) {
    generator->PrintLiteral("[");
    generator->PrintString(field->PrintableNameForExtension());
    generator->PrintLiteral("]");
  } else if (internal::cpp::IsGroupLike(*field)) {
    // Groups must be serialized with their original capitalization.
    generator->PrintString(field->message_type()->name());
  } else {
    generator->PrintString(field->name());
  }
}

注释“Groups must be serialized with their original capitalization”与文档描述逐字对应。另有一个“更怪”的事实:扩展字段(用 group 字段扩展其他消息)在文本格式中一律使用字段名,所以 group 扩展在 editions 下反而没有兼容性问题。

对于非扩展的 group 字段,Edition 2023 带来三个具体问题(引自文档):

  1. 重构(改名)消息会改变所有文本格式输出;
  2. 新 DELIMITED 字段会产生出乎意料的文本格式输出,且可能与其他字段冲突;
  3. 文本解析器期待消息名,这既令人意外,又可能无法唯一指定。

五、候选方案与取舍

文档的推荐结论是“组合拳”:短期走 Smooth Extension 解锁定界编码迁移;长期用 Global Feature(或更优的 Aliases)偿还技术债;若出现意外延误,则用 Nerf Delimited Encoding in 2023 快速解锁 27.0 版本的发布。下面完整梳理全部候选方案及其优缺点。

5.1 Smooth Extension(短期首选,已落地)

不改变现有行为,而是扩充规范,同时覆盖 proto2 与 editions:定义一个“group-like”概念,满足以下全部条件的字段归入其中:

  1. 采用 DELIMITED 编码;
  2. 其类型是直接嵌套在包含消息之下的嵌套消息;
  3. 字段名是其类型名的小写形式。

注意:proto2 group 必然是 group-like。对任何 group-like 字段,沿用 proto2 的既有语义(无论今天是什么样子);其余字段在 codegen 与 text format 中一律按普通字段处理。这样大多数新定界编码场景获得期望行为,所有旧 group 继续工作。唯一的例外是:用户的消息/字段名碰巧满足上述模式时,会看到出乎意料的 proto2 行为。

这种“意外行为”总体是安全的:借助条件 2、3 以及字段名唯一性约束,可以在 codegen 与文本编码中保证绝不出现符号冲突——不可能存在两个同类型的 DELIMITED 字段同时走旧行为,也不会有其他消息或字段占用这两种拼写。

此外,文档要求文本解析器对 group-like 字段同时接受旧的“消息名”拼写和新的“字段名”拼写,至少能在用户撞上行为变化时避免解析失败。

  • 优点:完整支持旧 proto2 行为;正确对待大多数新 editions 字段;排除了当前已知的各种危险情形;解析器双接受提供了一条“wire”格式迁移路径;与 editions 发布解耦(非破坏性变更且不需要新特性)。
  • 缺点:需要所有 editions 兼容运行时(以及大量生成器)协同修改;让旧 proto2 行为无限期留存且没有移除路径;给特定命名的用户埋下意外的边界情形。

5.2 Global Feature(长期治本)

引入一个新的全局消息特性 legacy_group_handling,统一控制所有期望的行为变化,且仅作用于 group-like 字段。特性开启时,这些字段在文本格式中始终使用消息名;每个不合规的语言也可以用它来开关自己的 codegen 规则。

  • 优点:一个简单布尔值开关所有行为变化;不需要给尚无该机制的语言加语言特性;利用 editions 机制逐步消除坏行为。
  • 缺点:此时为 2023 引入新特性已偏晚(涉及 edition 生命周期约束);需要所有运行时协同修改;用户覆盖该特性值同时是“wire”破坏与 API 破坏,迁移路径不清晰;特性开启时用户仍会看到今天的全部问题。

5.3 Feature Suite(Global Feature 的扩展)

把 codegen 变化按语言拆分成各自独立的特性开关。

  • 优点:多个简单布尔值分别开关不同的行为变化;用 editions 逐步消除坏行为;迁移故事更好,因为它把 API 破坏与“wire”破坏分开。
  • 缺点:需要成批的新语言特性,而这类特性的首次配置通常很繁琐;需要所有运行时协同修改;显著增加 edition 2023 的复杂度;特性开启时用户仍会看到今天的问题。

5.4 Nerf Delimited Encoding in 2023(应急后备)

快速修复:在 protoc 中直接禁止“消息名与字段名不匹配”的 DELIMITED 情形,覆盖绝大多数场景;对于支持动态消息的语言,可能还需要在运行时追加校验。这是在 27.0 发布前实在来不及做更好的实现时的合理后备:让 editions 以合理状态发布,把可用的 DELIMITED 特性留到 edition 2024。

  • 优点:解锁 editions 推进;实现简单安全;避免仓促实现“正确的修复”;避免文本格式的运行时问题;避免升级后的意外构建破坏(例如重命名嵌套消息导致构建失败)。
  • 缺点:发布出来的仍是一个很糟糕的特性——“像 group 但更差”;2023 中无法修复,因为第三方插件可能引入版本偏斜,大概率要等 edition 2024;可能需要大量运行时协同修改;无法解锁定界编码的推广工作。

5.5 Rename Fields in Editions(不可行)

利用 2023 升级顺手给 group 字段改名(例如 mygroup 改成 my_group)看似诱人,实际上行不通:大量运行时已经用字段名生成 API,这一转换会把它们全部打破。

  • 优点:对文本格式和部分语言确实效果好。
  • 缺点:把 2023 升级变成许多语言的破坏性变更。

5.6 Aliases(长期最优,待完整设计)

别名的讨论大多围绕 Any 展开,但对任何锁死字段/消息名的编码方案都有用。如果别名系统完整落地,它就是这里最完美的缓解手段——可惜当时尚无实现,时间窗口也太紧。

  • 优点:修复上述所有问题;旧行为可以用 proto 语言显式表达,从而能被 Prototiller(自动重构工具)处理。
  • 缺点:希望它成为一个真正被完整思考过的特性,而不是赶工塞进紧张时间表的 hack。

5.7 Do Nothing(不做任何事)

什么都不做不会真的“搞坏”任何人,但很丢人。

  • 优点:简单。
  • 缺点:在第一个 edition 中发布一个满是陷阱的糟糕特性;无法解锁定界编码推广。

六、源码落地:IsGroupLike() 即 Smooth Extension 的三条判据

文档推荐的 Smooth Extension 已在当前仓库中实现。核心是 descriptor.cc 中的 IsGroupLike()

bool IsGroupLike(const FieldDescriptor& field) {
  // Groups are always tag-delimited, currently specified by a TYPE_GROUP type.
  if (field.type() != FieldDescriptor::TYPE_GROUP) return false;
  // Group fields always are always the lowercase type name.
  if (field.name() != absl::AsciiStrToLower(field.message_type()->name())) {
    return false;
  }

  if (field.message_type()->file() != field.file()) return false;

  // Group messages are always defined in the same scope as the field.  File
  // level extensions will compare NULL == NULL here, which is why the file
  // comparison above is necessary to ensure both come from the same file.
  return field.is_extension() ? field.message_type()->containing_type() ==
                                    field.extension_scope()
                              : field.message_type()->containing_type() ==
                                    field.containing_type();
}

逐条对照文档中 group-like 的三条定义:

  1. DELIMITED 编码:源码中体现为 TYPE_GROUP。注释解释得直白——group 总是 tag-delimited,在 FieldDescriptor 层面由 TYPE_GROUP 类型表示;而普通 LENGTH_PREFIXED 消息字段是 TYPE_MESSAGE,第一步即被排除;
  2. 字段名等于类型名的小写field.name() != absl::AsciiStrToLower(field.message_type()->name()) 直接实现“名称同步”判据;
  3. 类型直接嵌套在包含消息之下(同作用域):最后一行比较 message_type()->containing_type() 与字段的包含类型(普通字段用 containing_type(),扩展字段用 extension_scope())。注释还解释了一个细节:文件级扩展的 containing_type()extension_scope() 都可能是空,NULL == NULL 会误判为同作用域,因此需要额外先比较 file() 相同(第 9115 行的 if (field.message_type()->file() != field.file()) return false;),这正是“同文件”这一隐含约束的实现。

6.1 单元测试:正例与四类反例

descriptor_unittest.cc 中的 IsGroupLike 测试族基于 TestDelimited 消息,逐一验证了上述每条判据:

  • GroupLikeDelimited:消息级字段 grouplike 与文件级扩展 grouplikefilescope 类型均为 TYPE_GROUP 且满足全部条件,断言 IsGroupLike 为真;
  • GroupLikeNotDelimitedlengthprefixed 字段类型是 TYPE_MESSAGE(即 LENGTH_PREFIXED 编码),即使命名满足模式也断言为假;
  • GroupLikeMismatchedNamenotgrouplike / not_group_like_scope 字段名与类型名小写形式不一致,断言为假;
  • GroupLikeMismatchedScopenotgrouplikescope(嵌套作用域不对)与文件级扩展 grouplike(消息与扩展不同作用域),断言为假;
  • GroupLikeMismatchedFilemessageimport 引用的消息来自另一个文件(跨文件导入),断言为假。

这套测试把 Smooth Extension 判定的每一个边界条件都钉死为可回归验证的行为。

6.2 文本解析器:同时接受两种拼写

文档要求“解析器对 group-like 字段同时接受旧的消息名拼写和新的字段名拼写”。这一行为在 text_format.cc 中实现:

field = descriptor->FindFieldByName(field_name);
// Group-like delimited fields will accept both the capitalized type
// names as well.
if (field == nullptr) {
  std::string lower_field_name = field_name;
  absl::AsciiStrToLower(&lower_field_name);
  field = descriptor->FindFieldByName(lower_field_name);
  // If the case-insensitive match worked but the field is NOT a group,
  if (field != nullptr && !internal::cpp::IsGroupLike(*field)) {
    field = nullptr;
  }
  if (field != nullptr && field->message_type()->name() != field_name) {
    field = nullptr;
  }
}

解析顺序是:先按原文做精确字段名查找;查不到再小写化重查(以容忍以大写类型名形式书写的文本);但随后做两道“防伪”检查——小写化命中后若该字段并非 group-like,则丢弃(避免把非 group 消息字段的大小写宽容当作合法拼写);并且输入的 field_name 必须逐字等于消息类型名,否则同样丢弃。效果是:group-like 字段接受类型名(旧拼写)与字段名(新拼写)两种文本,其他字段维持严格匹配,恰好落实了文档中“至少防止解析失败”的迁移路径承诺。

七、总结

这篇设计文档的结论可归纳为:

  1. Edition 2023 的 DELIMITED 编码若不加处理,会因依赖 proto2 group 的“类型名/字段名同步”假设而在多数新场景下失效;该特性以 MessageEncoding.DELIMITED 字段特性的形式定义于 descriptor.proto
  2. 短期方案 Smooth Extension 通过 “group-like” 三条件(DELIMITED 编码 + 直接嵌套 + 字段名等于类型名小写)让旧 group 完全沿用 proto2 语义,其余字段按普通字段处理;该判定在仓库中落地为 descriptor.ccIsGroupLike(),并有 descriptor_unittest.cc 的完整正反例测试锁定;
  3. 各语言生成器按既有惯例处理 group-like 字段(Java/ObjC 等用类型名,C++/Python/Go/upb 等用字段名,见第三节的源码对照),文本解析器则同时接受两种拼写,为行为迁移留下缓冲;
  4. 长期演进方向是用 legacy_group_handling 全局特性或别名(aliases)机制逐步移除“按类型给字段贴标签”的旧行为;若时间不济,则用“禁止类型名与字段名不匹配的 DELIMITED 字段”这一 protoc 校验作为 27.0 发布前的应急后备,并把完整可用的 DELIMITED 留到 edition 2024。

对阅读者而言,理解这一设计的关键在于:它不是在给 DELIMITED 增加功能,而是用一组可判定、可测试、可回归的条件,把一段危险的“隐式约定”显式化——这既是 editions 兼容工程的一次典型样本,也为后续用特性系统(feature set)逐步偿还 proto2 遗留行为提供了模板。

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

项目优选

收起
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.82 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
504
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384