首页
/ Protobuf Editions 设计解析:为何运行时要隐藏 FeatureSet 而非直接暴露 features()

Protobuf Editions 设计解析:为何运行时要隐藏 FeatureSet 而非直接暴露 features()

2026-09-04 21:37:48作者:宣利权Counsellor

本文围绕 Protobuf Editions 设计文档 editions-feature-visibility.md 展开,讲清一个容易被忽略但至关重要的 API 设计问题:当 .proto 文件中的 feature 声明被解析成运行时的 FeatureSet 后,各语言 runtime 应当以什么形式把 feature 行为暴露给最终用户。读完本文,你将理解 resolved 与 unresolved 两类 feature 的区别、直接暴露 feature proto 会埋下哪些长期维护陷阱,以及为什么 Protobuf 官方选择"隐藏所有 FeatureSet、只提供 has_presence() 这类高层辅助方法"的保守方案——并能在本仓库源码中看到这一方案的落地证据。

背景:feature 的流转链路中缺的一环

Editions 的整体设计中,editions-life-of-a-featureset.md 负责说明 feature 如何 runtime 传播(从 .proto 文件声明、按作用域合并、最终解析为确定值)。而本文档要解决的是链路的另一端:runtime 解析出 feature 之后,如何把它呈现给用户代码

这一点在当前仓库中可以直接看到。FeatureSet 消息定义于 descriptor.proto,其字段几乎都标注了 retention = RETENTION_RUNTIME,即需要被 runtime 读取并在行为上生效:

  • field_presence(字段显式性,取值 EXPLICIT / IMPLICIT / LEGACY_REQUIRED,见 descriptor.proto
  • enum_type(开放/封闭枚举)
  • repeated_field_encoding(packed / expanded 编码)
  • utf8_validation(字符串校验)
  • message_encodingjson_formatenforce_naming_style

这些 proto 是"规格"层面的数据结构,天然按 .proto 文件的声明方式组织,而不是按用户真正关心的行为组织。因此,如何把这份规格转译成用户可用的 API,就成了必须单独设计的问题。

两大核心问题

文档从 runtime 视角归纳了两个关键顾虑,理解了它们才能理解后面所有方案的取舍。

问题一:直接访问 resolved features 的 proto 会造成 API 僵化

Runtime 的决策应该基于这些已解析的 proto 数据,但它们的 struct 形态非常僵硬:

  1. 内部重构困难——一旦用户开始依赖这个 proto 层面的 API,protobuf 团队后续就难以对 feature 的内部建模做调整;
  2. 组织维度错位——这些 proto 的结构对应的是 feature 在 .proto 文件里"如何被声明",而非"实际代表什么行为",使得复杂 feature 与其他条件之间的关系很难被统一、一致地处理。

文档举了 UTF-8 校验建模的实例:团队在 UTF8 校验应当用什么 feature 来表达上反复过多次,由于 edition zero 完整保留了 proto2/proto3 行为,这些提议都不产生功能变化,只是改变"用哪个 feature 控制它"的建模方式。大规模 .proto 升级(bump 到下一个 edition)不可避免,但如果用户代码到处直接检查 utf8_validation 字段,每次建模调整都不得不同时改动所有用户代码。

packed 是更典型的"不完整 feature":它更像一条上下文相关的建议而非硬性规则。文件级一旦设置,所有字段都会带上这个 feature,但只有可打包字段才真正遵循它。若用户直接拿到这个字段做判断,还必须自己再判断"该字段是否 packable"。field presence 则更复杂——用户真正应该基于什么逻辑做运行时决策,与 .proto 文件里写下的字面声明并不相同。

文档还提到了内存成本优化的考量:edition zero 铺开之后,每个 descriptor 都会携带独立的 features proto,开销可能变得可观。如果 feature 不直接作为 proto 暴露,团队就有自由像 descriptor 类过去的优化一样,用更紧凑的自定义内存布局来存储它们。

最后一个例子是年度大规模版本升级(Bumpy Edition Large-scale Change):proto 团队每年负责把下一个 edition 推入(至少 80% 的)内部仓库。这整个流程的前提假设是:用户只基于 resolved features 做决策,且 Prototiller 转换是行为保持的。如果用户轻易能拿到 unresolved features,"Hyrum's law"(任何可观察的行为最终都会被人依赖)就会让这类大规模变更被大量意外破坏拖慢。

问题二:unresolved features 是隐蔽的"飞刀"

unresolved(未解析)feature 对用户而言是一个明确的 foot-gun。它与 resolved feature 共用同一个类型,二者往往不易区分。用未解析的 feature 做运行时决策时,很可能"碰巧"在当前 edition 下行为正常,而一旦 proto 升级到更高 edition,这段代码就会以出人意料的方式坏掉。

在当前仓库源码中可以看到这种双份表示确实存在:descriptor.h 中各 descriptor 同时持有 proto_features_(原始声明,即 unresolved)与 merged_features_(按作用域合并后的已解析值),公开 getter 返回的是后者:

// src/google/protobuf/descriptor.h(多处出现,如 L799)
const FeatureSet& features() const { return *merged_features_; }

这正是文档所指"两者共用同一类型、难以区分"的具体体现——FeatureSet 既可以是原始未解析值,也可以是解析后的合并值。

推荐方案:隐藏全部 FeatureSet,只提供行为级辅助方法

文档给出的推荐方案是一个保守策略

在一切可能的场合,把 FeatureSet proto 从公开 API 中隐藏。即不存在公开的 features() getter;所有 descriptor 的 options() getter 返回的 options 中 features 字段应当是空的(stripped)。取而代之,在相关 descriptor 上提供辅助方法(helper methods),把用户关心的行为封装起来。

这一模型在 edition zero 时期已经落地,仓库中的 C++ descriptor API 就是范本。descriptor.h 声明了这样一组布尔辅助方法:

// src/google/protobuf/descriptor.h
PROTOBUF_FUTURE_ADD_EARLY_NODISCARD bool is_packable() const;
// Whether or not this field is packable and packed...
PROTOBUF_FUTURE_ADD_EARLY_NODISCARD bool is_packed() const;
// Returns true if this field tracks presence...
PROTOBUF_FUTURE_ADD_EARLY_NODISCARD bool has_presence() const;
// Returns true if this TYPE_STRING-typed field requires UTF-8 validation on parse.
PROTOBUF_FUTURE_ADD_EARLY_NODISCARD bool requires_utf8_validation() const;
PROTOBUF_FUTURE_ADD_EARLY_NODISCARD bool is_required() const;

而在 descriptor.cc 中,这些方法内部才是消费 resolved features 的地方——用户看到的是行为级布尔值,feature 的解析细节被完全封装:

// src/google/protobuf/descriptor.cc
bool EnumDescriptor::is_closed() const {
  return features().enum_type() == FeatureSet::CLOSED;
}

bool FieldDescriptor::is_packed() const {
  if (!is_packable()) return false;          // 封装"是否 packable"的上下文判断
  return features().repeated_field_encoding() == FeatureSet::PACKED;
}

bool FieldDescriptor::requires_utf8_validation() const {
  return type() == TYPE_STRING && IsStrictUtf8(this);  // 封装"类型是否为 string"的前置条件
}

bool FieldDescriptor::has_presence() const {
  if (is_repeated()) return false;
  return cpp_type() == CPPTYPE_MESSAGE || is_extension() ||
         containing_oneof() ||
         features().field_presence() != FeatureSet::IMPLICIT;
}

bool FieldDescriptor::is_required() const {
  return features().field_presence() == FeatureSet::LEGACY_REQUIRED;
}

注意这些实现恰好回应了前文的问题一:is_packed() 替用户补上了"字段必须 packable"的前置检查,has_presence() 把 repeated、message、extension、oneof、feature 值等多种条件的组合逻辑收拢到一处,requires_utf8_validation() 隐藏了"只有 string 字段才有意义"这一上下文。这些"行为封装"正是文档要求的"helper methods on the relevant descriptors to encapsulate the behaviors users care about",文档并明确指出这种模式应当延续下去。

feature 的解析本身则由 internal::InternalFeatureHelper::GetFeatures 按作用域向上查找父级(文件 → 消息 → oneof 等)完成,相关重载见 descriptor.cc,属于内部机制而非公开 API。

唯一例外:reflection 通道保留 unresolved features

文档指出了一个无法完全隐藏 feature 的地方——反射。多数 runtime 都提供了把 descriptor 还原为原始 proto 形态的 API(如 C++ 的 CopyToDebugString)。为了让这些接口忠实地还原原始 .proto 文件,应当在此处填回未解析的(unresolved)features

这一点在源码中同样有据可查:descriptor.cc 中的 RestoreFeaturesToOptions 模板函数把 descriptor 保存的原始 proto_features_ 复制回 options 的 features 字段;FileDescriptor::CopyToMessage::CopyToFieldDescriptor::CopyTo 等各 CopyTo 重载都调用了它(如 descriptor.ccL2986 等多处)。也就是说:普通公开路径上 options 里 features 是空的,只有走 CopyTo/DebugString 这类"还原"通道时,未解析 feature 才会被临时填回——与文档描述完全一致。文档也说明,鉴于这些方法本身效率低、返回的 proto 难以实用,预期 misuse 会很少。

为什么保守方案在 API 层面更有利

文档特别强调:未来若需要调整,这是弹性最大的选项。新增一个有明确用例的 API 很容易;而一旦决定不再想要某个已有 API,删除它已被证明极难。"隐藏"给了团队未来放宽的空间("如果将来真出现 features() getter 的实际用例,放宽是容易的"),而"暴露"则是不可逆的。

强制力(Enforcement)与特殊情形

强制力:对外建议,对内从严

文档明确:该推荐最终由各 runtime owner 自行决定。Google 之外无法强制,且强制的代价会落在那些用户身上(而非整个 protobuf 生态)。Google 内部则应当更严格地执行,因为成本主要落在自己头上。

μpb:runtime 实现而非完整 runtime 的特例

μpb(本仓库 upb/ 目录下的 C 运行时实现)是一个显著的特例:它是 runtime 的实现,但不是面向最终用户的完整 runtime。由于 μpb 只向包裹它的目标语言 runtime 提供 API,它可以自由地在任何地方暴露 feature,由外层语言 runtime 负责在适当位置剥除(strip)它们。这与 C++ 侧的内部结构相呼应:internal::InternalFeatureHelper 等机制供内部/代码生成器使用,而公开 API 层维持上述封装。

推荐方案的利弊清单

完整继承原文档的权衡分析:

优点(Pros)

  • 阻止一切对 resolved feature proto 的直接访问
    • 获得内部重构的自由度
    • 允许把更复杂的关系封装在内部
    • 用户无需区分 resolved / unresolved feature
  • 限制了 unresolved feature 的访问面
    • 误用这些 feature 的可能性更低(尤其是配合上一条)
  • 若将来发现 features() getter 的真实用例,放宽容易
  • 与既有 descriptor API 风格一致:descriptor API 本来就包装(wrap)了 descriptor proto,但并非与之一一对应;options 是例外,主要因为需要暴露 extension

缺点(Cons)

  • 修改 options() 的行为没有先例。在此之前它一直是 .proto 文件声明内容的忠实克隆
  • 将来若决定放宽,对 options() 而言有点尴尬:一旦停止 strip,用户会突然看到一个新字段,可能触发 Hyrum's law 导致破坏
  • 要求每种语言都重复实现同一套高层 feature 行为。例如 has_presence 需要在每种语言里实现得完全一致,很可能需要某种 conformance 测试来保证各语言结果一致

被否决的备选方案

备选一:直接暴露 Features

最简单的实现,也是早期原型的做法:在 descriptor API 上放公开的 features() getter,并在 options() 中保留 unresolved features。

  • 优点:极易实现
  • 缺点:没有解决上文列出的任何问题;且日后难以逆转

备选二:在生成的 Options 中用包级可见性隐藏 Features

对推荐方案的一个变体:给生成 options 消息中的 features 字段加"包级"可见性(如 C++ 中的 access token),外部 runtime 使用者甚至无法访问到"它是空的"这一事实,从而消除 Hyrum's law 顾虑。

  • 优点:解决推荐方案缺点之一
  • 缺点:每种 runtime 要单独做,意味着每个代码生成器里都要写特定的 hack;且收益不明确——只有当我们决定暴露 features、并且大量用户开始依赖"features 恒为空"这一事实时才有用

备选三:用 ClangTidy 警告 options().features() 访问

借助 ClangTidy 提醒用户不要检查 options().features()

  • 优点:解决推荐方案缺点之一
  • 缺点:不是每种语言都能用;在开源(OSS)场景不work

三个备选方案各有缺陷,最终未获采纳;隐藏 FeatureSet + 高层辅助方法的组合,在可逆性、一致性与实现代价之间取得了最佳平衡。

小结

editions-feature-visibility.md 给出的是一条清晰的 API 设计准则:面向用户暴露"行为",而非"规格"。对 Editions 生态中的 runtime 开发者而言,落地要点可以浓缩为三条:

  1. 不要在公开 API 中提供 features() 之类的 getter;决策所需的全部信息应封装为 has_presence()is_packed()requires_utf8_validation() 这类行为级布尔方法(参照 descriptor.hdescriptor.cc 的实现);
  2. descriptor 的 options() 保持 features 字段为空,仅在 CopyTo/DebugString 等还原通道中填回 unresolved features(参照 RestoreFeaturesToOptions);
  3. 跨语言实现同一套高层语义时保持一致,必要时以 conformance 测试兜底;本仓库 editions/codegen_tests/ 下按 edition 与 feature 维度组织的 .proto 用例(如 edition2023_string_type.protoproto2_packed.protoproto3_implicit.proto)即为各语言代码生成行为一致性验证的素材来源。

这一设计的深层价值在于为未来的内部重构、内存布局优化和年度大规模 edition 升级保留了自由度——而按 API 演进的普遍经验,"先隐藏、需要时再放开"永远比"先暴露、日后再收回"更容易走通。

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

项目优选

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