Protobuf Editions 2023:DELIMITED 编码如何兼容 proto2 group——IsGroupLike 与 Smooth Extension 设计解析
本篇解读 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.cc 与 objectivec/field.cc 中做同样判断,C# 则见 csharp_helpers.cc。而 C++、Python、Go、upb 一路的生成器直接使用 field->name(),即字段名,与文档表格完全吻合。
四、文本格式:按消息名输出、只接受消息名解析
官方文本格式规范明确规定:group 消息按消息名而非小写字段名编码。group MyGroup 会被序列化为:
MyGroup {
...
}
在 C++ 实现中,序列化时始终输出消息名,解析时只接受消息名(正路径有 conformance 测试锁定;负路径——拒绝字段名——没有 conformance 测试,但 C++/Java/Python 行为一致,也没有已知的不一致案例)。仓库中这段逻辑位于 text_format.cc 的 PrintFieldName:
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 带来三个具体问题(引自文档):
- 重构(改名)消息会改变所有文本格式输出;
- 新 DELIMITED 字段会产生出乎意料的文本格式输出,且可能与其他字段冲突;
- 文本解析器期待消息名,这既令人意外,又可能无法唯一指定。
五、候选方案与取舍
文档的推荐结论是“组合拳”:短期走 Smooth Extension 解锁定界编码迁移;长期用 Global Feature(或更优的 Aliases)偿还技术债;若出现意外延误,则用 Nerf Delimited Encoding in 2023 快速解锁 27.0 版本的发布。下面完整梳理全部候选方案及其优缺点。
5.1 Smooth Extension(短期首选,已落地)
不改变现有行为,而是扩充规范,同时覆盖 proto2 与 editions:定义一个“group-like”概念,满足以下全部条件的字段归入其中:
- 采用
DELIMITED编码; - 其类型是直接嵌套在包含消息之下的嵌套消息;
- 字段名是其类型名的小写形式。
注意: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 的三条定义:
- DELIMITED 编码:源码中体现为
TYPE_GROUP。注释解释得直白——group 总是 tag-delimited,在FieldDescriptor层面由TYPE_GROUP类型表示;而普通LENGTH_PREFIXED消息字段是TYPE_MESSAGE,第一步即被排除; - 字段名等于类型名的小写:
field.name() != absl::AsciiStrToLower(field.message_type()->name())直接实现“名称同步”判据; - 类型直接嵌套在包含消息之下(同作用域):最后一行比较
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为真;GroupLikeNotDelimited:lengthprefixed字段类型是TYPE_MESSAGE(即LENGTH_PREFIXED编码),即使命名满足模式也断言为假;GroupLikeMismatchedName:notgrouplike/not_group_like_scope字段名与类型名小写形式不一致,断言为假;GroupLikeMismatchedScope:notgrouplikescope(嵌套作用域不对)与文件级扩展grouplike(消息与扩展不同作用域),断言为假;GroupLikeMismatchedFile:messageimport引用的消息来自另一个文件(跨文件导入),断言为假。
这套测试把 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 字段接受类型名(旧拼写)与字段名(新拼写)两种文本,其他字段维持严格匹配,恰好落实了文档中“至少防止解析失败”的迁移路径承诺。
七、总结
这篇设计文档的结论可归纳为:
- Edition 2023 的
DELIMITED编码若不加处理,会因依赖 proto2 group 的“类型名/字段名同步”假设而在多数新场景下失效;该特性以MessageEncoding.DELIMITED字段特性的形式定义于 descriptor.proto; - 短期方案 Smooth Extension 通过 “group-like” 三条件(DELIMITED 编码 + 直接嵌套 + 字段名等于类型名小写)让旧 group 完全沿用 proto2 语义,其余字段按普通字段处理;该判定在仓库中落地为 descriptor.cc 的
IsGroupLike(),并有 descriptor_unittest.cc 的完整正反例测试锁定; - 各语言生成器按既有惯例处理 group-like 字段(Java/ObjC 等用类型名,C++/Python/Go/upb 等用字段名,见第三节的源码对照),文本解析器则同时接受两种拼写,为行为迁移留下缓冲;
- 长期演进方向是用
legacy_group_handling全局特性或别名(aliases)机制逐步移除“按类型给字段贴标签”的旧行为;若时间不济,则用“禁止类型名与字段名不匹配的 DELIMITED 字段”这一 protoc 校验作为 27.0 发布前的应急后备,并把完整可用的DELIMITED留到 edition 2024。
对阅读者而言,理解这一设计的关键在于:它不是在给 DELIMITED 增加功能,而是用一组可判定、可测试、可回归的条件,把一段危险的“隐式约定”显式化——这既是 editions 兼容工程的一次典型样本,也为后续用特性系统(feature set)逐步偿还 proto2 遗留行为提供了模板。
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 StartedRust0622
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