BongoCat Rust i18n Catalog 结构设计:领域化 key 规划、平台覆盖与编译期验证(ADR-0028 实践解析)

原创2026-09-30 16:01:20266 阅读
文章标签:桌面应用

BongoCat Rust i18n Catalog 结构设计:领域化 key 规划、平台覆盖与编译期验证(ADR-0028 实践解析)

本篇文章以 BongoCat 仓库中的架构决策记录 ADR-0028: Rust i18n catalog structure 为核心,结合 bongocat-i18n crate 的真实实现(lib.rs、build.rs)、六份 locale JSON 文件与单元测试源码,讲解这一跨平台桌面宠物应用如何组织 i18n 文案目录、如何在运行时查询稳定 key、如何用 platform_text 处理「菜单栏 / 系统托盘」这类平台差异表达,以及如何用测试保证所有语言 catalog 的 key 集合与占位符集合完全一致。读完本文,你将掌握一套可复制的 Rust 桌面应用 i18n 分层与校验方案。

背景:扁平 key 的失控与 rust-i18n 的编译期展平

BongoCat 最初把所有文案放在 ui.* 扁平 key 之下。随着设置页面、模型库、快捷键配置和诊断页面不断增多,这种命名方式暴露出两个问题:

  • 无法表达页面上下文:ui.title、ui.label 这类 key 出现在哪个页面、属于哪个功能,只能靠猜测;
  • 相同词语在不同语义下被误复用:一个「保存」在模型编辑页和设置页可能语义不同,但扁平 key 让它们只能共用一个文案,一旦某处措辞需要调整,所有引用点全部被动改变。

ADR-0028 的关键洞察是:rust-i18n 会在编译期把嵌套 JSON 展平为查找路径。这意味着开发者可以同时获得两样东西:

  1. 可读的源文件层级:locale JSON 里用嵌套对象组织文案,翻译时一目了然;
  2. 稳定的运行时 key:编译后每次查找都使用确定的点分路径字符串,如 "models.catalog.load_failed"。

两者并不冲突——嵌套结构只是「写给人看」的组织形式,而点分路径是「写给程序用」的稳定契约。

调研结论:四个开源项目的取舍

在做出决策前,团队调研了四个开源项目(原文档引用,本文按仓库证据归纳其借鉴点):

项目 借鉴点
Zed 桌面 UI 资源按产品域和页面区域组织,状态与操作不是塞进一个无意义的 common 大对象
Joplin locale 资源强调稳定语义标识和参数契约,复数/参数消息与页面结构分离,便于翻译工具和校验脚本处理
Nextcloud 以功能上下文维护消息,避免把视觉组件名称作为 key;同一文案只在语义确实相同时才复用
KeePassXC 对话框和菜单动作按交互上下文组织,按钮、菜单项和错误提示需要稳定的领域归属,而不是泛化的 label/text

需要强调的是:这些项目没有被直接照搬。BongoCat 根据自己的页面结构和 runtime 状态建立了独立的目录体系,这从 en-US.json 中可以看到,例如 system_menu、startup_permission 这类与 BongoCat 平台能力强相关的顶层领域,并不存在于上述任何一个参考项目中。

决策:九大顶层领域与 snake_case 约束

locale 文件保留根级 _version: 1(当前 en-US.json 第 2 行即为 "_version": 1),所有叶子 key 使用 snake_case,并按以下顶层领域组织:

领域 内容
navigation 设置窗口导航项及页面简介
settings 外观、overlay、模型交互、输入、应用和运行时设置
models catalog、身份、导入流程、行为和校验
shortcuts 快捷键范围、录入、动作和冲突
diagnostics 预留 runtime、输入指标、配置恢复和导出(当前无叶子)
about 产品信息和用户支持动作
actions 跨页面且语义稳定的确认、取消、刷新等动作
status 跨页面生命周期状态
errors 按 settings/models/runtime 归属的错误消息

从实际文件看,navigation 下是设置窗口的 9 个导航页标题(settings、appearance、model_library、model_behavior、model_window、input_interaction、shortcuts、app_system、about);actions 下只有语义绝对稳定的 cancel、confirm、close;而 errors.settings 下则按错误来源聚合了几十个具体错误消息,例如 model_import_failed、config_permission_denied、built_in_model_cannot_be_deleted。

命名约束

  • 页面区域使用 title、description 等有限且有上下文的字段;
  • 实体或状态继续使用具体名称,例如 models.identity.source.built_in 和 settings.overlay.maximum_fps.description,不使用无上下文的 common.name;
  • 需要参数的消息保留 %{name} 占位符,例如 en-US.json 中的 "current_version": "Current version %{version}"、"invalid_summary": "%{origin} · %{diagnostic}"、"conflict": "%{shortcut} is already assigned to another shortcut";
  • 所有 locale 的 key、层级和占位符集合必须完全一致——这是后续验证章节的核心契约。

源码级约束检查

catalog.rs 测试 中的 locale_source_uses_nested_snake_case_keys 会递归遍历每个 locale JSON,断言除 _version 外的所有 key 均为小写字母、下划线或数字,且不得包含点号(禁止扁平 key)。测试辅助函数 flatten(mod.rs)则跳过 _version,把嵌套对象递归展平为点分叶子 key。

运行时查询:bongocat-i18n 作为唯一 catalog owner

Rust 代码在文案实际使用处直接通过 bongocat-i18n 查询稳定路径,例如 "models.catalog.load_failed"。关键设计约束是:

  • bongocat-i18n 保持唯一的 rust_i18n::i18n! catalog owner(见 lib.rs 的 rust_i18n::i18n!("locales", fallback = "en-US"));
  • UI 不重复嵌入 JSON,也不使用会在并发窗口之间共享状态的全局 locale;
  • 动态摘要、错误、快捷键冲突和诊断指标在负责其展示语义的函数中通过同一 facade 查询,不按语言分支写自然语言。

公开 API 一览

lib.rs 对外暴露了四个主要入口:

函数 作用
locale_code(code: &str) -> &str 把任意 locale 标签解析到实际发布的 catalog 名称
text(locale, key) -> &'static str 按稳定 key 查询文案(带缓存)
format_text(locale, key, values) -> String 查询并插值 %{name} 占位符
platform_text(locale, base_key) -> &'static str 查询支持按平台覆盖的文案

语言标签解析:RFC 4647 子标签回退

locale_code(lib.rs)实现的是与浏览器 Intl、CLDR、操作系统一致的 RFC 4647 语言子标签回退:ar-EG 回退到 ar 而非英文。六份已发布 catalog 为 <a href="https://link.gitcode.com/i/8fd63951b2ab479d83d0c2c716f78447" target="_blank">"en-US", "zh-CN", "zh-TW", "ar-SA", "vi-VN", "pt-BR"](即 [SHIPPED_LOCALES),机器上报的 vi、vi-VN、ar、ar-EG、pt、pt-PT 都能落到同一份 catalog 上。

zh 是唯一不能仅凭主子标签决定的情况:简体和繁体是两种不同的书写形式,各自独立发布 catalog。因此 zh 分支按脚本子标签(hant/hans)判定,区域子标签 tw/hk/mo 作为平台上报的拼写兜底,详见 is_simplified_chinese(lib.rs)。这一逻辑在 catalog.rs 的 each_chinese_script_reaches_its_own_catalog 测试中被验证:zh-HK、zh-Hant-HK、zh_Hant_TW 都落到 zh-TW,而 zh-CN、zh-Hans、zh_CN 落到 zh-CN,且两种脚本的同一 key 文案必然不同。

参数插值:format_text

format_text(lib.rs)把 %{name} 占位符按传入的 (name, value) 键值对逐个替换。这让 UI 代码无需写任何 locale 相关的分支,同时保留 rust-i18n 的编译期 catalog。format.rs 测试 验证了 update.current_version 在六种语言下分别渲染为 "Current version 1.2.3"、"当前版本 1.2.3"、"目前版本 1.2.3"、"الإصدار الحالي 1.2.3"、"Phiên bản hiện tại 1.2.3"、"Versão atual 1.2.3"。

缓存与 catalog 修订号

text 内部用 OnceLock<RwLock<HashMap>> 缓存解析结果(lib.rs),并以 locale_code(locale) + "\0" + key 作为缓存键,避免并发窗口间共享可变状态。此外,build.rs 对六份 catalog 文件做 FNV-1a 指纹计算,把结果注入环境变量 BONGOCAT_I18N_CATALOG_REVISION,并声明 cargo:rerun-if-changed。这保证只改 locale JSON 也会触发消费方重建——因为 rust_i18n::i18n! 由 proc macro 在展开时读取 locale 文件,编译器本身不会把这些文件记录为依赖,必须靠 build.rs 的指纹来补足新鲜度。

平台覆盖(platform override):菜单栏与系统托盘

少数文案在两个首版平台(macOS / Windows)上需要不同的表达。最典型的例子是「菜单栏」与「系统托盘」——同一概念在不同操作系统上的命名不同。ADR-0028 的决策是:把这种差异交给 catalog,而不是调用点。

三层实现机制

  1. 在 catalog 里拆分嵌套对象:base key 拆成平台子键,例如

    • settings.application.status_icon.label.macos → "Show menu bar icon"
    • settings.application.status_icon.label.windows → "Show system tray icon"

    实际文件 en-US.json 中该 key 位于 settings.app_system.status_icon.label(第 474-479 行),且 macos/windows 两个子键在两个 locale(en-US 与 zh-CN 等)中同时存在。

  2. UI 不写 cfg(target_os = ...):调用点统一调用 bongocat_i18n::platform_text(locale, base_key),平台分支完全集中在 i18n 层。

  3. 查找顺序:platform_text(lib.rs)的解析顺序为:

    • 先查 <base_key>.<current_platform_id>,例如 ..status_icon.label.macos;
    • 查不到时回退到 <base_key>。

    current_platform_id()(lib.rs)是编译期 const fn,取 target_os 返回 "macos" / "windows" / "unsupported"(在 lib.rs 中未定义平台的 cfg 分支直接编译失败,保证新增平台必须显式表态)。

    一个实现细节值得注意:rust-i18n 在 key 缺失时返回 key 本身,因此 platform_text 用「解析结果是否等于请求的平台 key」来判断是否回退,避免把 ..label.macos 字符串本身当作文案返回。这也让缺失 key 在开发期可见地暴露,而不是静默返回空串。

扩展方式

未来再有需要分平台的项,只需要在两个 locale 文件里新增 <base_key>.macos 和 <base_key>.windows,调用点保持不动。仍不需要分平台的项继续用 bongocat_i18n::text。未知平台(Linux、CI 主机)会回退到 base key,避免生产 UI 暴露 key 字符串。

平台覆盖测试

platform.rs 测试 覆盖三条路径:

  • current_platform_id_is_one_of_the_supported_platforms:平台 id 必须是已知后缀;
  • platform_text_picks_the_current_platform_override:带 override 的 key 返回平台专属文案;
  • platform_text_falls_back_to_the_base_key_when_no_override_exists:无 override 的 key(如 navigation.settings.title)在任意平台都返回共享文案;
  • platform_text_is_safe_for_keys_without_a_platform_override_present:防止回退路径被误改造成返回带后缀的 key。

验证:双向 key 集合校验与覆盖扫描

ADR-0028 的验证章节在源码中有三重落点,全部位于 bongocat-i18n/src/tests/:

1. 语言间 key/占位符一致性

locale_keys_and_placeholders_match_default_language(catalog.rs)以 en-US 为基准,递归展平每个 locale 后:

  • 断言 key 集合(BTreeMap 的 key 序列)完全一致;
  • 对每个 key 断言 %{...} 占位符集合完全一致。

这就是「新增语言必须先复制完整结构,再提交翻译,不得增删字段」的机器保证。

2. 编译产物与磁盘文件一致

compiled_catalog_matches_the_files_on_disk(catalog.rs)故意不用 include_str!(嵌入的副本会与它要检查的 catalog 一起过期),而是从 locales/ 目录现场读取 JSON,逐 key 与 text() 的编译产物比对。该测试的注释记录了一次真实事故:2026-09-21 模型删除确认框显示了重命名前的旧模板 %{status} · %{confirm_deletion},而所有 gate 都通过——这正是因为 proc macro 展开不产生依赖记录,陈旧 catalog 靠 build.rs 指纹与这个测试兜底。

3. 源码引用与 catalog 的双向覆盖扫描

source_referenced_keys_exist_in_the_catalog(coverage.rs)与 catalog_keys_are_referenced_by_source(coverage.rs)构成一对镜像扫描:

  • 前向:扫描 crates/ 下所有 .rs 文件,提取 bongocat_i18n::text(、format_text(、platform_text( 的字面量实参,以及所有形如 a.b.c 的 snake_case 点分字面量(dotted_literals、is_catalog_key_shape),断言它们都能解析到叶子 key。对 platform_text 特例化:base key 只要在任一平台子键存在即算解析成功(resolves 中的 platform_relative 分支);
  • 反向:每个 catalog 叶子 key 必须作为字面量出现在源码中,或作为 <base>.<platform> 且 <base> 被引用。测试注释提到一个细节:设置窗口曾收集了 22 个从未展示的 key,于 2026-09-20 清理;随后一次清理提交又删掉了一个仍被使用的 key——因此两个方向都必须检查。

辅助函数 literal_argument(mod.rs)追踪括号深度来跳过运行时拼接的 key;intends_a_key(mod.rs)用「字面量本身或其前两段是真实 catalog 路径」来排除恰好撞上命名空间的无关点分字符串。rust-i18n 的 fallback 继续使用 en-US,missing_locale_text_falls_back_to_english(catalog.rs)验证了 system、de-DE 等未发布标签回退到英文。

实际使用形态

从仓库源码看,bongocat-i18n 的查询被 bongocat-app 与 bongocat-ui 广泛使用。举例:

这些调用点全部通过同一 facade 查询稳定路径,印证了「UI 不重复嵌入 JSON、不按语言分支写自然语言」的决策落地。

给新语言的接入流程(实际操作)

结合 ADR-0028 的验证契约与 build.rs 的六 catalog 列表,新增语言的完整流程是:

  1. 以 en-US.json 为模板复制完整结构(顶层 _version: 1 与九大领域、所有嵌套层级、所有 %{name} 占位符);
  2. 只翻译叶子值,不得增删任何字段,否则 locale_keys_and_placeholders_match_default_language 会直接失败;
  3. 把新文件放入 crates/bongocat-i18n/locales/;
  4. 在 lib.rs 的 SHIPPED_LOCALES、mod.rs 的测试 LOCALES、mod.rs 的 source() 分发与 build.rs 的 CATALOGS 四处登记(测试注释明确说明:漏掉任何一处都会让比较静默少覆盖一份 catalog,这正是常量存在的意义);
  5. 运行 cargo test -p bongocat-i18n,确认六份 catalog 的 key 集合、占位符集合与磁盘文件全部对齐。

平台 override 的接入则更轻量:只需在既有 locale 文件里为某个 base key 添加 macos/windows 子键,调用点换成 platform_text 即可,无需触碰任何 UI 分支代码。

总结

ADR-0028 为 BongoCat 的 i18n 体系确立了三条核心原则:领域化组织代替扁平 key、平台差异收敛到 catalog 而非调用点、key 与占位符契约由测试强制双向对齐。这套方案同时解决了可读性(源文件层级)、稳定性(编译期展平出的点分路径)与正确性(缺失 key 在开发期可见、过期 catalog 有指纹兜底)三个问题,且从 crates/bongocat-i18n/ 的实际代码看,它已被完整实现并持续运行——六种语言、九大领域、平台覆盖与双向扫描测试共同构成了一个可长期维护的 Rust 桌面应用 i18n 基础设施。

登录后查看全文
BongoCat