运行时 Localizer 是 I18n 最容易被调用、也最容易被误解的部分。单键、批量和前端资源三个入口目前不是同一个语义;选择 API 前必须先看清回退、错误和缓存合同。
LocalizerService 标注 [BitzCacheArea(translations)]。可选启动 / Host 重建由 I18nCacheWarmupContributor 预热启用语言 × office=0 的有限键空间,见缓存区域目录。
1. 三个入口
| 入口 | 输入 | 当前输出语义 |
|---|---|---|
GetString(key) | 当前 Language/Tenant/Office | 单键完整回退链 |
GetString(key, language, ...) | 显式上下文 | 单键完整回退链 |
GetStringsAsync(keys, ...) | Key 集合 | 只查请求语言 DB;缺失取 Key 最后一段 |
GET /api/i18n/resources | language + prefix | 以空 Keys 调批量;当前结果为空 |
GetTranslations 不经过 Localizer,而是直接返回数据库合并字典。它不会合并 JSON 或 fallback language,但空 Keys 能正确表示“全部”。
2. 单键算法
// 使用稳定、分层的 Key;不要把中文原文当 Key。var title = localizer.GetString( key: "Identity.Login.Title", language: resolvedLanguage, tenantId: currentTenantId, officeId: currentOfficeId);
// 若所有层都未命中,当前返回 "Title",不是 null 或错误。// 因此调用者无法仅靠返回值区分真实翻译与最终兜底。response.Title = title;数据库读取失败会被 LoadDictionaryAsync 转为空字典,然后继续 JSON/fallback/key;这是可用性优先的降级。系统没有同时返回 hit source 或 degraded flag,生产诊断只能靠另加指标。
3. 回退语言
platform.i18n.fallbackLanguage 通过 SettingsManager 读取,传入 TenantId,默认 en-US。若它与请求语言忽略大小写相同,则跳过第二轮。
回退只支持一个精确语言,没有链式 parent fallback。例如 pt-BR 不会先试 pt;zh-HK 不会按配置尝试 zh-Hant。Setting 值也没有先验证 Culture 或启用目录。
4. 最终 Key 文本
ToFallbackText 取最后一个点后的片段:Identity.Login.Title → Title;Key 无点则原样返回,末尾为点也原样返回。
这个策略保证 UI 不出现空字符串,却可能把内部错误码或技术字段直接展示。商业产品应决定:开发环境显示 Key、生产显示安全通用文案、可观测性记录 missing key;不能把“最后一段”当翻译质量保障。
5. 批量算法缺失的两层
GetStringsAsync 先加载请求语言数据库字典,再只遍历传入 Keys。它计算了 fallback 变量却没有使用;也没有访问 JsonResourceLoader。
// Store 的空 Keys 约定是加载整包;这里先得到请求语言的完整 DB 字典。var databaseValues = await LoadDictionaryAsync( requestedLanguage, tenantId, officeId, cancellationToken);
var result = new Dictionary<string, string>();foreach (var key in keys){ // 没有 JSON,也没有 fallback-language 查询。 result[key] = databaseValues.TryGetValue(key, out var value) ? value : ToFallbackText(key);}
return result;单键和批量对同一 Key 可返回不同值,这是 API 一致性缺陷。调用方不应通过换 API 获得不同本地化结果。
6. GetI18nResources 空结果
Handler 调用 localizer.GetStringsAsync(Array.Empty<string>(), ...),期望空集合代表“加载全部”;但批量方法的结果是按输入 Keys 循环构造,空集合自然返回空字典。随后 KeyPrefix 只会过滤这个空结果。
修复不能只把 Store 字典直接返回,否则仍会遗漏 JSON/fallback 合并。应定义“完整资源包”如何处理缺失、冲突、来源与 prefix,并用合同测试固定。
7. 数据库字典缓存
同步单键 API 经 ICacheStore.GetOrCreateAsync 缓存完整语言字典。Key 由:
area=i18n / scope=Global / lang / language / tenant / tenant-or-0 / office / office-or-0Policy TTL 为 30 分钟,AreaTag 为 i18n。虽然 CacheScope 是 Global,tenant/office 已进入 Key;审查工具不要看到 Global 就误判跨租户共享,也不要忽略 Key builder 是否对分隔符、长度和哈希做稳定处理。
8. 同步阻塞异步
GetString 为保持同步签名,内部多次 .GetAwaiter().GetResult() 调 Settings、Cache 和 Store。注释说明 ASP.NET Core 没有经典 SynchronizationContext 死锁,但这不消除线程池阻塞、尾延迟放大或在非 ASP.NET Host 中的风险。
高吞吐路径应优先异步 API;若保留同步 L(),可在请求前异步预热不可变快照,或使用进程内只读快照避免同步等待 I/O。必须以并发基准而非注释证明容量。
9. JSON 资源加载
Loader 扫描 AppDomain.BaseDirectory/Resources/i18n 顶层,文件名匹配 *.{lang}.json,每个文件是扁平字符串字典。多个文件同 Key 时,目录枚举后写覆盖前写,但文件枚举顺序未排序,冲突结果不应视为确定。
{ "Identity.Login.Title": "登录", "Identity.Login.Submit": "继续"}当前源码树没有实际 Resources/i18n 文件。Loader 注释提到嵌入资源,代码却只读取文件系统;没有 Assembly resource 读取。解析异常全部吞掉,无法区分目录不存在、JSON 损坏和空语言包。
10. JSON 缓存与刷新
每种 language 的合并字典保存在 Singleton ConcurrentDictionary。Refresh() 清空全部语言,但没有 FileSystemWatcher、管理 API、LocalResourceSync consumer 或部署钩子调用它。
因此部署后替换 JSON 文件不会自动生效。蓝绿/滚动发布还可能让不同实例运行不同资源版本;资源应随应用构建不可变发布,或具备带版本、校验和与广播的显式热更新协议。
11. 保存后的三种通知
RemoveByTagAsync("i18n"):当前实例本地缓存;- LocalResourceSync
Translation:其他实例收到后清同一 Tag; - Notification
i18n.translations.changed:计划由前端重新拉资源。
消费者捕获非取消异常、记录后吞掉,因此广播处理不会反复毒化队列,但会允许陈旧缓存存活到 TTL。通知没有资源版本/ETag,乱序事件也无法判断新旧;sourceVersion 只是发送时 Unix 毫秒。
12. 精确失效与惊群
保存任意租户、语言、Key 都清除整个 i18n Tag,所有租户/办公室字典在下一次访问重建。高频翻译编辑可制造缓存惊群和数据库尖峰。
目标可按 tenant/language/office 分层 tag,使用版本化 snapshot、single-flight 和抖动 TTL。精确化时要考虑 Platform 变更影响所有租户,而 Tenant 变更只影响一个租户,Office 变更只影响一个办公室。
13. 参数与 HTML 安全
当前 Localizer 返回原始 string,没有格式参数类型、占位符校验、HTML-safe 类型、ICU plural/select 或富文本 sanitizer。调用方自行 string.Format 可能因翻译占位符错误抛异常,也可能把不可信值拼进 HTML。
翻译值默认应作为纯文本并由 UI 编码。富文本必须使用独立资源类型和 allowlist sanitizer;参数应按名称/类型验证,禁止翻译改变模板执行语义。
14. 目标统一解析接口
// 目标接口把完整上下文一次传入,避免三个公开入口分别实现回退链。var resolved = await localizer.ResolveAsync(new LocalizationRequest( Keys: ["Identity.Login.Title", "Identity.Login.Submit"], Language: language, TenantId: tenantId, OfficeId: officeId, IncludeFallback: true), cancellationToken);
// 值与诊断分离,业务日志只记录 Key/来源,不记录可能敏感的翻译正文。foreach (var item in resolved.Items) metrics.RecordHit(item.Key, item.ResolvedLanguage, item.Source);单键、批量和资源包都应委托同一 Resolver;差别只在输入集合和输出投影,不能复制回退算法。
15. 测试矩阵
| 场景 | 必须证明 |
|---|---|
| DB/JSON/fallback/key 四层 | 单键与批量结果相同 |
| 空 Keys 资源包 | 返回完整合并集,不是空 |
| Prefix | DB/JSON/fallback 一致裁剪 |
| DB 失败 | 降级来源可观测且无错误缓存污染 |
| JSON 损坏/冲突 | 确定性策略与告警 |
| Platform/Tenant/Office 变更 | 只失效正确受众 |
| 广播丢失/乱序/重复 | TTL 或版本最终收敛 |
| 高频编辑 | 无惊群,P99 受控 |
| 同步 L() 并发 | 线程池与尾延迟达标 |
| 格式参数/HTML | 占位符安全、输出编码 |
16. 检查命令
# 单键、批量、JSON 与缓存实现。rg -n "GetString\(|GetStringsAsync|GetOrLoadDictionary|JsonResourceLoader|CacheTtl" \ src/Platform/I18n src/Framework/BitzOrcas.Application/Localization -g '*.cs'
# 空 Keys 资源路径与三段失效。rg -n "Array.Empty<string>|RemoveByTagAsync|NotifyAsync|i18n.translations.changed" \ src/Platform/I18n -g '*.cs'