BongoCat Rust i18n Catalog 结构设计:领域化 key 规划、平台覆盖与编译期验证(ADR-0028 实践解析)
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 展平为查找路径。这意味着开发者可以同时获得两样东西:
- 可读的源文件层级:locale JSON 里用嵌套对象组织文案,翻译时一目了然;
- 稳定的运行时 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,而不是调用点。
三层实现机制
-
在 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 等)中同时存在。 -
UI 不写
cfg(target_os = ...):调用点统一调用bongocat_i18n::platform_text(locale, base_key),平台分支完全集中在 i18n 层。 -
查找顺序:
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 广泛使用。举例:
- bongocat-app/src/startup_permission.rs(4 处)——启动权限提示文案;
- bongocat-app/src/model_titles.rs、bongocat-app/src/system_menu.rs——模型标题与系统菜单文案;
- bongocat-ui/src/window/models.rs(19 处)、bongocat-ui/src/window/about.rs(12 处)——模型库与「关于」页面;
- bongocat-ui/src/window/localization.rs(9 处)——UI 层语言切换逻辑。
这些调用点全部通过同一 facade 查询稳定路径,印证了「UI 不重复嵌入 JSON、不按语言分支写自然语言」的决策落地。
给新语言的接入流程(实际操作)
结合 ADR-0028 的验证契约与 build.rs 的六 catalog 列表,新增语言的完整流程是:
- 以
en-US.json为模板复制完整结构(顶层_version: 1与九大领域、所有嵌套层级、所有%{name}占位符); - 只翻译叶子值,不得增删任何字段,否则
locale_keys_and_placeholders_match_default_language会直接失败; - 把新文件放入 crates/bongocat-i18n/locales/;
- 在 lib.rs 的
SHIPPED_LOCALES、mod.rs 的测试LOCALES、mod.rs 的source()分发与 build.rs 的CATALOGS四处登记(测试注释明确说明:漏掉任何一处都会让比较静默少覆盖一份 catalog,这正是常量存在的意义); - 运行
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 基础设施。