国际化与本地化体系(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 前端需要以一致的语义展示菜单、消息等界面文本,且支持在运行时切换语言。体系的设计取向非常明确:
- 零外部文件依赖——语言数据在编译期以字符串常量嵌入二进制(
embedded_locales::zh_cn_json/en_us_json),运行时不存在语言文件缺失、路径错误或 IO 失败这一整类问题。 - 错误即返回值——所有公开函数返回
std::expected<void, std::string>,加载失败不会抛出异常逃逸到调用方(内部仍有try/catch兜底,见失败模式)。 - 键名即文档——翻译键采用
"category.item"点分层格式(源码注释示例:"menu.app_main"、"message.window_not_found"),查找失败时get_text直接返回键本身,界面永不因缺键而崩溃。 - 双端约定对齐——C++ 端用
Language::ZhCN / EnUS枚举,Web 端与外部输入用"zh-CN" / "en-US"BCP-47 风格 locale 字符串,load_language_by_locale负责二者的桥接。
关键概念
| 概念 | 位置 | 说明 |
|---|---|---|
Language | src/core/i18n/types.hpp | 强类型语言枚举:ZhCN、EnUS |
TextData | src/core/i18n/types.hpp | std::unordered_map<std::string, std::string>,键为 "category.item" |
I18nState | src/core/i18n/state.hpp | 持有 current_language、texts、is_initialized 的状态体 |
embedded_locales::*_json | src/core/i18n/embedded/ | 编译期嵌入的 JSON 语言包字符串常量 |
locale 字符串 | 双端约定 | "zh-CN" / "en-US",Web 与外部输入的标准形式 |
useI18n | web/src/composables/useI18n.ts | Web 端组合式函数(重导出自 @/core/i18n) |
Architecture
架构分层说明:
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 字符串到界面可用文本的全过程:
流程要点(均对应 i18n.cpp 的真实控制流):
- locale → 枚举桥接:
load_language_by_locale用两次字符串比较完成"zh-CN"/"en-US"的映射,其余值一律返回"Unsupported locale: <locale>",不做模糊匹配或回退猜测——这是刻意的显式失败设计,避免静默落错语言。 - 判空先于解析:
load_embedded_language_data对嵌入字符串做空检查(zh_cn_json.empty()),空数据返回"Chinese language data is empty"等具名错误。这一步发生在 JSON 解析之前,把"资源缺失"与"格式损坏"区分开。 - 解析成功才提交状态:
rfl::json::read<TextData>失败时函数立即返回,不会修改i18n_state的任何字段。这意味着一次失败的语言切换不会让 UI 停留在半更新状态——旧语言文本完整保留。 - 移动语义落盘:成功路径用
i18n_state.texts = std::move(config_result.value()),避免复制整张哈希表。 initialize的幂等语义:initialize只比load_language多两件事——失败前缀"Failed to load default language: "与成功后置is_initialized = true。重复调用initialize是安全的(会重新加载并覆盖文本)。
首次初始化流程
注意
default:分支的存在:当前Language枚举只有两个值,但load_embedded_language_data的 switch 仍有default: return std::unexpected("Unsupported language")。这保证未来扩展枚举值而忘记补语言包时,会在运行时被显式拒绝而不是落入未定义行为。
Data Model:类型契约与键名约定
类型定义(types.hpp)
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互通,强制调用方显式使用类型。
嵌入语言包
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 核心路径)
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
基础用法:初始化并读取文本
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),调用方只需检查返回值。
进阶用法:错误链路与语言状态查询
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 端入口
// 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_lang | Language | Language::EnUS | initialize 函数参数 | 初始化时的默认语言;调用方可显式传入 Language::ZhCN 覆盖 |
zh_cn_json | std::string_view(常量) | 编译期生成 | src/core/i18n/embedded/zh_cn.hpp | 中文语言包(JSON 文本) |
en_us_json | std::string_view(常量) | 编译期生成 | src/core/i18n/embedded/en_us.hpp | 英语语言包(JSON 文本) |
| 支持的 locale | 字符串集合 | "zh-CN"、"en-US" | load_language_by_locale 内联比较 | 白名单式硬编码,无外部扩展点 |
新增语言的操作路径(基于已验证的结构推断 + 源码证据):
- 在
types.hpp的Language枚举追加值; - 新建
embedded/<locale>.hpp嵌入 JSON 常量并在i18n.hpp/i18n.cpp引入; - 在
load_embedded_language_data的 switch 中补 case; - 在
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>"— 异常兜底。
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>" — 白名单外的任何值。
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 端组合式函数)
export { useI18n } from '@/core/i18n'Web 端本地化统一入口,重导出自 @/core/i18n。签名与内部行为属于 web/src/core/i18n/ 模块,未在本页审阅范围内(见已验证边界)。
Source: useI18n.ts
失败模式、边界情况与并发
错误传播模型
已从源码验证的失败模式清单:
| # | 失败场景 | 错误信息 | 状态影响 |
|---|---|---|---|
| 1 | state.i18n 未构造 | I18nState is not initialized | 无变化 |
| 2 | 中文包为空 | Chinese language data is empty | 无变化 |
| 3 | 英文包为空 | English language data is empty | 无变化 |
| 4 | 枚举无对应分支 | Unsupported language | 无变化 |
| 5 | JSON 格式损坏 | Failed to parse text data: <what> | 无变化 |
| 6 | 解析过程抛异常 | Exception during language loading: <what> | 部分写入风险,见下文 |
| 7 | 非白名单 locale | Unsupported 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_data | O(1) | 常量选择 + 空检查,无 IO |
rfl::json::read<TextData> | O(n) | n 为键值对总数;每次切换语言完整重建哈希表 |
get_text | O(1) 均摊 | unordered_map::find |
get_current_language / is_initialized | O(1) | 指针判空 + 字段读取 |
运营层面的要点:
- 语言包体积即二进制体积。JSON 以字符串常量嵌入,中英双语的增加量与翻译条目数线性相关;构建产物无独立资源文件,部署物是自包含的。
- 运行时无语言包热加载。数据源在编译期固化,更新翻译必须重新编译。这是"零外部文件依赖"设计的直接代价。
- 内存峰值:同一时刻仅持有当前语言的
TextData,切换时旧表被移动覆盖后释放,不会同时驻留两份语言数据。 - 诊断路径:错误字符串自带层级(资源层 / 解析层 / 初始化层 / locale 层),日志中出现
Failed to parse text data可直接定位到嵌入 JSON 本身的合法性,无需区分是文件缺失还是格式问题。
Extension Points
- 新增语言:见上文 Configuration Options 中的四步操作路径,switch 的
default分支与 locale 白名单会在遗漏时提供运行时自检错误。 TextData作为纯数据契约:任何新模块只需持有TextData引用即可复用get_text,无需依赖core::i18n其余部分——types.hpp零依赖的特性使检索能力可独立分发。- Web 端
@/core/i18n:C++ 端 locale 白名单(zh-CN/en-US)是双端协议的事实基准,Web 端扩展语言时必须同步 C++ 端,否则load_language_by_locale将拒绝。 - 不存在的扩展点(如实说明):源码中没有插件式语言源(如运行时目录扫描、远程语言包下载)的接口抽象,
load_embedded_language_data是唯一数据入口且为自由函数,无法在不修改源码的情况下替换数据源。
Tests
本次源码审阅(受探索预算限制)未读取到 src/core/i18n 的专属测试文件;上文对错误路径的断言均直接来自 i18n.cpp 实现代码中的错误字符串与分支结构,而非测试用例。若仓库存在相关测试,建议在后续版本补充指向。
Related Links
- 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日志体系(分别见各自目录页)