Repository Wiki
ChanIok/SpinningMomo

国际化与本地化体系(C++ 与 Web 双端)

SpinningMomo 的多语言能力由两条并行链路组成:C++ 核心端(src/core/i18n/)以编译期嵌入的 JSON 语言包 + reflect-cpp 解析 + AppState 内状态的方式提供文本检索,Web 前端(web/src/)通过 useI18n 组合式函数暴露同构的本地化入口。两端各自维护语言数据,但共享 "category.item" 形式的键名约定与 zh-CN / en-US 的 locale 标识规范。

Purpose and Scope

本页面完整覆盖双端国际化体系的机制层:

  • C++ 端 core::i18n 模块的加载管线、数据模型、错误处理与线程安全注意事项;
  • 嵌入式语言资源(embedded/zh_cn.hpp、embedded/en_us.hpp)的组织方式;
  • Language / TextData / get_text 的类型契约与键名约定;
  • Web 端 useI18n 组合式函数的入口结构与双端对照。

以下相邻主题有意留给兄弟页面,本页不展开:

  • core::AppState 的整体构成与生命周期(见应用状态管理相关页面);
  • utils/logger 日志设施(i18n.cpp 仅引入其头文件);
  • Web 端 @/core/i18n 内部实现(本页仅确认 useI18n 的重导出入口,见下文"已验证边界"说明);
  • UI 层具体组件如何消费翻译文本。

Overview

要解决什么问题

桌面核心(C++)与 Web 前端需要以一致的语义展示菜单、消息等界面文本,且支持在运行时切换语言。体系的设计取向非常明确:

  1. 零外部文件依赖——语言数据在编译期以字符串常量嵌入二进制(embedded_locales::zh_cn_json / en_us_json),运行时不存在语言文件缺失、路径错误或 IO 失败这一整类问题。
  2. 错误即返回值——所有公开函数返回 std::expected<void, std::string>,加载失败不会抛出异常逃逸到调用方(内部仍有 try/catch 兜底,见失败模式)。
  3. 键名即文档——翻译键采用 "category.item" 点分层格式(源码注释示例:"menu.app_main"、"message.window_not_found"),查找失败时 get_text 直接返回键本身,界面永不因缺键而崩溃。
  4. 双端约定对齐——C++ 端用 Language::ZhCN / EnUS 枚举,Web 端与外部输入用 "zh-CN" / "en-US" BCP-47 风格 locale 字符串,load_language_by_locale 负责二者的桥接。

关键概念

概念位置说明
Languagesrc/core/i18n/types.hpp强类型语言枚举:ZhCN、EnUS
TextDatasrc/core/i18n/types.hppstd::unordered_map<std::string, std::string>,键为 "category.item"
I18nStatesrc/core/i18n/state.hpp持有 current_language、texts、is_initialized 的状态体
embedded_locales::*_jsonsrc/core/i18n/embedded/编译期嵌入的 JSON 语言包字符串常量
locale 字符串双端约定"zh-CN" / "en-US",Web 与外部输入的标准形式
useI18nweb/src/composables/useI18n.tsWeb 端组合式函数(重导出自 @/core/i18n)

Architecture

Loading diagram...

架构分层说明:

  • AppState 只是宿主,不是逻辑。core::i18n 的全部函数都以 core::AppState& 为第一参数,模块本身无全局单例;state.i18n 指向 I18nState(见 i18n.cpp 中 if (!state.i18n) 的判空)。这使 i18n 状态可随 AppState 一同创建、传递与销毁。
  • 嵌入层与解析层严格分离。load_embedded_language_data 只做"选包 + 判空",load_language(I18nState&, Language) 只做"解析 + 落状态",两层各自返回独立的错误信息,便于定位是资源问题还是 JSON 问题。
  • types.hpp 是零依赖契约。Language 枚举与 TextData 别名不依赖任何运行时组件,get_text 是 inline 自由函数,任何持有 TextData 的代码都能安全检索。
  • Web 端是平行实现而非绑定。useI18n.ts 仅一行重导出,真实逻辑在 @/core/i18n;两端通过键名格式与 locale 字符串约定对齐,而非共享运行时。

双端职责对照

关注点C++ 核心端Web 前端
语言标识Language::ZhCN / EnUS 枚举"zh-CN" / "en-US" 字符串
文本存储TextData(unordered_map)useI18n 内部状态(@/core/i18n)
数据来源编译期嵌入 JSON 常量前端模块自有数据(实现未在本页审阅范围)
加载入口initialize / load_language_by_locale组合式函数调用
缺键兜底get_text 返回键本身由 @/core/i18n 决定(未在本页审阅范围)

已验证边界:本页对 Web 端仅验证到 web/src/composables/useI18n.ts 的重导出语句与 web/src/core/i18n/ 目录的存在;其内部实现细节未在本次源码审阅中读取(源码探索预算限制),故不在本页断言任何未验证行为。

Core Flow:语言加载端到端流程

C++ 端一次完整的语言切换涉及三个阶段:选包 → 解析 → 落状态。下图展示从外部输入 locale 字符串到界面可用文本的全过程:

Loading diagram...

流程要点(均对应 i18n.cpp 的真实控制流):

  1. locale → 枚举桥接:load_language_by_locale 用两次字符串比较完成 "zh-CN" / "en-US" 的映射,其余值一律返回 "Unsupported locale: <locale>",不做模糊匹配或回退猜测——这是刻意的显式失败设计,避免静默落错语言。
  2. 判空先于解析:load_embedded_language_data 对嵌入字符串做空检查(zh_cn_json.empty()),空数据返回 "Chinese language data is empty" 等具名错误。这一步发生在 JSON 解析之前,把"资源缺失"与"格式损坏"区分开。
  3. 解析成功才提交状态:rfl::json::read<TextData> 失败时函数立即返回,不会修改 i18n_state 的任何字段。这意味着一次失败的语言切换不会让 UI 停留在半更新状态——旧语言文本完整保留。
  4. 移动语义落盘:成功路径用 i18n_state.texts = std::move(config_result.value()),避免复制整张哈希表。
  5. initialize 的幂等语义:initialize 只比 load_language 多两件事——失败前缀 "Failed to load default language: " 与成功后置 is_initialized = true。重复调用 initialize 是安全的(会重新加载并覆盖文本)。

首次初始化流程

Loading diagram...

注意 default: 分支的存在:当前 Language 枚举只有两个值,但 load_embedded_language_data 的 switch 仍有 default: return std::unexpected("Unsupported language")。这保证未来扩展枚举值而忘记补语言包时,会在运行时被显式拒绝而不是落入未定义行为。

Data Model:类型契约与键名约定

类型定义(types.hpp)

cpp
1enum class Language { ZhCN, EnUS }; 2 3// key 格式: "category.item" (例如: "menu.app_main", "message.window_not_found") 4using TextData = std::unordered_map<std::string, std::string>; 5 6// 辅助函数:安全获取文本,如果不存在返回key本身 7inline auto get_text(const TextData& texts, const std::string& key) -> std::string { 8 auto it = texts.find(key); 9 ... 10}

Source: types.hpp

设计意图解读:

  • TextData 是扁平 map 而非嵌套结构。点分层键直接作为 map 键存储,解析时无需构建树;代价是按类别批量检索需自行过滤,但 UI 检索场景永远是单键取值,扁平结构是最优解。
  • get_text 的缺键兜底返回键本身。这是本地化体系的最后一道防线:即使某个语言包漏了一个键,界面也只会显示 "menu.app_main" 这样可读、可定位的原始键,而不是空串或崩溃。
  • enum class 防止隐式整型转换,ZhCN、EnUS 无法与 int 互通,强制调用方显式使用类型。

嵌入语言包

cpp
1auto load_embedded_language_data(Language lang) -> std::expected<std::string_view, std::string> { 2 switch (lang) { 3 case Language::ZhCN: 4 if (embedded_locales::zh_cn_json.empty()) { 5 return std::unexpected("Chinese language data is empty"); 6 } 7 return embedded_locales::zh_cn_json; 8 9 case Language::EnUS: 10 if (embedded_locales::en_us_json.empty()) { 11 return std::unexpected("English language data is empty"); 12 } 13 return embedded_locales::en_us_json; 14 15 default: 16 return std::unexpected("Unsupported language"); 17 } 18}

Source: i18n.cpp

要点:

  • 返回 std::string_view 而非 std::string——嵌入常量生命周期贯穿整个进程,无需拷贝,也表明数据源是静态存储。
  • default 分支的防御性返回见上文流程说明。

状态落盘(load_language 核心路径)

cpp
1auto load_language(I18nState& i18n_state, Language lang) -> std::expected<void, std::string> { 2 try { 3 // 获取嵌入的语言数据 4 auto data_result = load_embedded_language_data(lang); 5 if (!data_result) { 6 return std::unexpected(data_result.error()); 7 } 8 9 // 解析JSON 10 auto config_result = rfl::json::read<TextData>(data_result.value()); 11 if (!config_result) { 12 return std::unexpected("Failed to parse text data: " + config_result.error().what()); 13 } 14 15 // 更新传入的状态 16 i18n_state.current_language = lang; 17 i18n_state.texts = std::move(config_result.value()); 18 19 return {}; 20 } catch (const std::exception& e) { 21 return std::unexpected("Exception during language loading: " + std::string(e.what())); 22 } 23}

Source: i18n.cpp

这段是整个体系的事务性核心:所有可能失败的操作(选包、解析)全部完成后,才一次性写入 current_language 与 texts 两个字段。任何中途失败都保持旧状态不变。

Usage Examples

基础用法:初始化并读取文本

cpp
1#include "core/i18n/i18n.hpp" 2 3// 1. 程序启动时初始化(默认英语) 4if (auto result = core::i18n::initialize(state); !result) { 5 // 错误信息示例: "Failed to load default language: English language data is empty" 6 logger.error(result.error()); 7} 8 9// 2. 检查就绪状态 10if (core::i18n::is_initialized(state)) { 11 // 3. 通过 locale 字符串切换(Web 层或外部输入常用入口) 12 core::i18n::load_language_by_locale(state, "zh-CN"); 13 14 // 4. 读取翻译文本(缺键时返回键本身) 15 const auto text = core::i18n::get_text( 16 state.i18n->texts, "menu.app_main"); 17}

Source: i18n.hpp

调用方契约(源自头文件签名):

  • initialize / load_language / load_language_by_locale 均要求 state.i18n 已被构造,否则返回 "I18nState is not initialized"。
  • 所有函数无异常逃逸(返回 std::expected),调用方只需检查返回值。

进阶用法:错误链路与语言状态查询

cpp
1// 完整错误链路演示:不支持的 locale 2auto r1 = core::i18n::load_language_by_locale(state, "fr-FR"); 3// r1.error() == "Unsupported locale: fr-FR" 4// 注意:此时 state.i18n 中的旧语言与文本完全未受影响 5 6// 未初始化状态下的安全降级 7core::AppState empty_state{}; // i18n 指针为空 8core::i18n::get_current_language(empty_state); 9// 返回 Language::EnUS(默认值,不崩溃) 10core::i18n::is_initialized(empty_state); 11// 返回 false 12 13// 正常状态查询 14const auto current = core::i18n::get_current_language(state); 15// current == Language::ZhCN(若此前已切换)

Source: i18n.cpp

get_current_language 与 is_initialized 对 state.i18n 为空的情形不报错而是返回默认值(EnUS / false),与写路径的显式失败形成互补:读路径面向高频查询,必须零成本降级;写路径面向一次性配置,必须暴露真实错误。

Web 端入口

typescript
// Re-export from core i18n module export { useI18n } from '@/core/i18n'

Source: useI18n.ts

Web 端的 composables/useI18n.ts 是一个薄重导出层:组合式函数命名空间(composables/)只负责提供 Vue 生态惯用的调用入口,真正的 i18n 实现归拢在 @/core/i18n(即 web/src/core/i18n/),与 C++ 端 src/core/i18n/ 的目录结构形成镜像。这种"双 core"布局让翻译逻辑与框架绑定解耦——未来若更换前端框架,core/i18n 可整体平移。

Configuration Options

体系没有运行时配置文件,全部"配置"以常量形式固化在代码中:

配置项类型默认值位置说明
default_langLanguageLanguage::EnUSinitialize 函数参数初始化时的默认语言;调用方可显式传入 Language::ZhCN 覆盖
zh_cn_jsonstd::string_view(常量)编译期生成src/core/i18n/embedded/zh_cn.hpp中文语言包(JSON 文本)
en_us_jsonstd::string_view(常量)编译期生成src/core/i18n/embedded/en_us.hpp英语语言包(JSON 文本)
支持的 locale字符串集合"zh-CN"、"en-US"load_language_by_locale 内联比较白名单式硬编码,无外部扩展点

新增语言的操作路径(基于已验证的结构推断 + 源码证据):

  1. 在 types.hpp 的 Language 枚举追加值;
  2. 新建 embedded/<locale>.hpp 嵌入 JSON 常量并在 i18n.hpp / i18n.cpp 引入;
  3. 在 load_embedded_language_data 的 switch 中补 case;
  4. 在 load_language_by_locale 中补 locale 字符串映射。

若遗漏第 3 步,运行时会得到 "Unsupported language"(default 分支兜底);遗漏第 4 步会得到 "Unsupported locale: <locale>"。两条错误信息即为集成验证的自检点。

API Reference

initialize(state: core::AppState&, default_lang: Language = Language::EnUS) -> std::expected<void, std::string>

初始化 i18n 子系统:加载默认语言并在成功后置位 is_initialized。

Parameters:

  • state (core::AppState&):应用状态宿主,要求 state.i18n 已构造。
  • default_lang (Language):默认语言,缺省 EnUS。

Returns: 成功为 expected 的空值;失败携带错误描述字符串。

错误信息(源码实证):

  • "I18nState is not initialized" — state.i18n 为空。
  • "Failed to load default language: <内部错误>" — 底层加载失败,带前缀拼接。
  • "Exception during I18n initialization: <what>" — 异常兜底。

Source: i18n.hpp · i18n.cpp


load_language(state: core::AppState&, lang: Language) -> std::expected<void, std::string>

切换到指定语言并整体替换 I18nState::texts。

Parameters:

  • state (core::AppState&):应用状态宿主。
  • lang (Language):目标语言枚举。

Returns: 同上。失败时旧语言状态原样保留(事务性写入)。

Source: i18n.cpp


load_language_by_locale(state: core::AppState&, locale: std::string_view) -> std::expected<void, std::string>

以 locale 字符串加载语言(例如 "zh-CN" / "en-US")。Web 端与外部输入的标准入口。

Parameters:

  • state (core::AppState&):应用状态宿主。
  • locale (std::string_view):BCP-47 风格 locale 字符串。

Returns: 同上。

错误信息: "Unsupported locale: <locale>" — 白名单外的任何值。

Source: i18n.hpp · i18n.cpp


get_current_language(state: const core::AppState&) -> Language

读取当前语言。

Parameters:

  • state (const core::AppState&):只读状态引用。

Returns: 当前 Language;若 state.i18n 为空则返回 Language::EnUS(安全降级,不报错)。

Source: i18n.cpp


is_initialized(state: const core::AppState&) -> bool

查询初始化状态。

Parameters:

  • state (const core::AppState&):只读状态引用。

Returns: state.i18n->is_initialized;state.i18n 为空时返回 false。

Source: i18n.cpp


get_text(texts: const TextData&, key: const std::string&) -> std::string

安全检索翻译文本,缺键时返回键本身。

Parameters:

  • texts (const TextData&):语言文本表。
  • key (const std::string&):形如 "category.item" 的键。

Returns: 对应翻译;未命中时返回 key 的拷贝(保证调用方永远拿到非空可用字符串)。

Source: types.hpp


useI18n(Web 端组合式函数)

typescript
export { useI18n } from '@/core/i18n'

Web 端本地化统一入口,重导出自 @/core/i18n。签名与内部行为属于 web/src/core/i18n/ 模块,未在本页审阅范围内(见已验证边界)。

Source: useI18n.ts

失败模式、边界情况与并发

错误传播模型

Loading diagram...

已从源码验证的失败模式清单:

#失败场景错误信息状态影响
1state.i18n 未构造I18nState is not initialized无变化
2中文包为空Chinese language data is empty无变化
3英文包为空English language data is empty无变化
4枚举无对应分支Unsupported language无变化
5JSON 格式损坏Failed to parse text data: <what>无变化
6解析过程抛异常Exception during language loading: <what>部分写入风险,见下文
7非白名单 localeUnsupported locale: <locale>无变化
8查询缺键无错误,get_text 返回键本身不适用

事务性边界的一个例外

对照 load_language(I18nState&, Language) 的语句顺序:i18n_state.current_language = lang; 与 i18n_state.texts = std::move(...) 是两条独立赋值。若某个极端场景(如移动构造期间抛异常,理论上 unordered_map 移动不抛)在两语句之间失败,可能出现 current_language 已更新而 texts 未更新的瞬时不一致。源码中 catch 块的存在说明作者预期了异常路径,但该 catch 捕获的异常发生在状态写入之前(选包与解析阶段),写入阶段本身不在 try 保护的有意义范围内。实践中 unordered_map 的移动赋值不抛异常,该风险为理论性。

并发注意事项(源码无同步原语)

core::i18n 全部函数均无锁、无原子操作、无 mutex 成员,模块设计为单线程或外部同步使用:

  • 读路径(get_current_language / is_initialized / get_text)在另一线程执行写路径(load_language)期间被调用,属于数据竞争(std::move 整表替换与并发 find 不同步)。
  • 若应用需要后台切换语言,调用方需自行保证"写时无读"。常见做法是 UI 主线程串行调用语言切换。
  • 设计意图:语言切换是低频用户操作,加锁成本高于收益;模块把同步责任上移给宿主,换取读路径的零开销。

初始化幂等性

initialize 可重复调用且安全:每次都会重新加载语言并覆盖 texts,is_initialized 重复置 true 无副作用。失败后再调用 initialize 会重新尝试完整加载流程。

Performance & Operational Notes

操作复杂度说明
load_embedded_language_dataO(1)常量选择 + 空检查,无 IO
rfl::json::read<TextData>O(n)n 为键值对总数;每次切换语言完整重建哈希表
get_textO(1) 均摊unordered_map::find
get_current_language / is_initializedO(1)指针判空 + 字段读取

运营层面的要点:

  • 语言包体积即二进制体积。JSON 以字符串常量嵌入,中英双语的增加量与翻译条目数线性相关;构建产物无独立资源文件,部署物是自包含的。
  • 运行时无语言包热加载。数据源在编译期固化,更新翻译必须重新编译。这是"零外部文件依赖"设计的直接代价。
  • 内存峰值:同一时刻仅持有当前语言的 TextData,切换时旧表被移动覆盖后释放,不会同时驻留两份语言数据。
  • 诊断路径:错误字符串自带层级(资源层 / 解析层 / 初始化层 / locale 层),日志中出现 Failed to parse text data 可直接定位到嵌入 JSON 本身的合法性,无需区分是文件缺失还是格式问题。

Extension Points

  1. 新增语言:见上文 Configuration Options 中的四步操作路径,switch 的 default 分支与 locale 白名单会在遗漏时提供运行时自检错误。
  2. TextData 作为纯数据契约:任何新模块只需持有 TextData 引用即可复用 get_text,无需依赖 core::i18n 其余部分——types.hpp 零依赖的特性使检索能力可独立分发。
  3. Web 端 @/core/i18n:C++ 端 locale 白名单(zh-CN / en-US)是双端协议的事实基准,Web 端扩展语言时必须同步 C++ 端,否则 load_language_by_locale 将拒绝。
  4. 不存在的扩展点(如实说明):源码中没有插件式语言源(如运行时目录扫描、远程语言包下载)的接口抽象,load_embedded_language_data 是唯一数据入口且为自由函数,无法在不修改源码的情况下替换数据源。

Tests

本次源码审阅(受探索预算限制)未读取到 src/core/i18n 的专属测试文件;上文对错误路径的断言均直接来自 i18n.cpp 实现代码中的错误字符串与分支结构,而非测试用例。若仓库存在相关测试,建议在后续版本补充指向。

  • C++ i18n 公开 API 声明:i18n.hpp
  • C++ i18n 实现:i18n.cpp
  • 类型契约(Language / TextData / get_text):types.hpp
  • i18n 状态定义:state.hpp
  • Web 端组合式函数入口:useI18n.ts
  • 相关兄弟主题:core::AppState 应用状态管理、utils/logger 日志体系(分别见各自目录页)

Sources

(3 files)
src/core/i18n
web/src/composables