Skip to content
bitzorcas
中EN

Reference

I18n 请求语言解析与 Culture

深入解释 LanguageResolutionMiddleware 的真实优先级、Accept-Language 处理、Setting 回退、CultureInfo 同步、AsyncLocal L() 上下文、响应头以及支持语言校验缺口。

Last updated

请求语言不是一个字符串偏好那么简单。它同时影响资源命中、模型验证、日期数字格式、缓存分区、响应语义与日志诊断。当前实现把这些上下文统一起来了,但还没有把“语法合法”“运行时文化存在”“租户允许使用”三个概念分开。

1. 中间件位置

UseRequestLocalization() 先运行,随后经过认证、委托、租户解析和租户模拟,才进入 LanguageResolutionMiddleware。因此自定义中间件能读取租户、当前用户与 Office,并再次覆盖 .NET Culture。

RequestLocalization

Authentication

Tenant Resolution / Impersonation

LanguageResolutionMiddleware

Request Audit

Authorization

Endpoint

组合根注释中较早的总管道摘要没有列出 LanguageResolution,但实际调用顺序以上述代码为准。修改顺序时必须回归代理头、认证失败、租户失败和异常响应是否仍带正确语言。

2. 解析优先级

当前顺序是 Header、Cookie、Query,三者都没有时读取 platform.i18n.defaultLanguage;Setting 失败或返回空值最终使用 zh-CN。

Header 会覆盖 Cookie 与查询参数
GET /api/i18n/languages?lang=en-US HTTP/1.1
Host: api.example.test
Accept-Language: zh-CN,en-US;q=0.9
Cookie: bitzorcas.lang=en-US
# 当前结果是 zh-CN,因为中间件只取 Header 第一项。

这意味着网页语言选择器若只改 Cookie/Query,却没有同步浏览器 Header,选择可能看似不生效。GA 产品应明确是“显式用户偏好优先”还是“每次请求 Header 优先”。

3. Accept-Language 不是完整协商

实现用逗号拆分后取第一项,再去掉分号后的 q 参数。它不排序 q 值、不识别 *、不忽略 q=0、不做 zh-Hans-CN → zh-CN → zh 匹配,也不与启用语言目录求交集。

当前不会选择质量值更高的第二项
GET /api/i18n/resources HTTP/1.1
Accept-Language: fr-FR;q=0.1, en-US;q=1.0
# 当前解析 fr-FR;标准质量值没有参与排序。

若网关或 CDN 按 Accept-Language 缓存响应,应用还要正确设置 Vary 或把语言写入缓存键。当前中间件只设置 Content-Language,没有设置 Vary: Accept-Language。

4. 语言代码正则

正则允许 2–3 个字母主标签,加一个 2–4 位字母数字子标签。它能挡住换行、路径和长输入,但不等于完整 BCP-47:脚本、扩展、私有使用与多段标签不能表达;看似合法但不存在的代码仍可通过。

把三类校验分开
// ① 语法:拒绝控制字符和不受支持的标签形态。
var parsed = LanguageTag.TryParse(requestedTag);
if (parsed.IsFailure)
return Errors.InvalidLanguageTag(requestedTag);
// ② 运行时:确认框架能创建 CultureInfo。
var culture = CultureInfo.GetCultureInfo(parsed.Value.CanonicalName);
// ③ 产品:确认该租户启用了规范化后的语言。
if (!await languageCatalog.IsEnabledAsync(tenantId, culture.Name, cancellationToken))
return Errors.LanguageNotEnabled(culture.Name);

这是目标设计示例。当前实现只做第一类的简化正则;CultureNotFoundException 被吞掉,第三类完全没有执行。

5. 大小写与规范化

输入不会通过 CultureInfo.Name 或专用 BCP-47 类型规范化。ZH-cn 可能成功创建 CultureInfo,但 ILanguageContext、缓存键和数据库查询仍使用原字符串。数据库比较是否区分大小写又取决于 Provider/Collation,可能造成跨环境差异。

所有入口应在进入缓存、设置、数据库和响应头前得到同一个 canonical tag。迁移时还要清理现存 zh-cn、ZH-CN 等重复行,并让唯一索引按规范值工作。

6. 默认语言 Setting

platform.i18n.defaultLanguage 在 Setting Registry 中是 Global Scope,默认 zh-CN;中间件却以当前 TenantId 读取 SettingsManager。最终是否允许租户 override 由 Setting 引擎的 scope 规则决定,不能仅看调用参数断言。

Setting 读取失败被当作无值并回退 zh-CN,没有诊断响应或降级标记。商业运行至少应记录结构化指标:setting failure、invalid configured culture、not-enabled culture 与 fallback reason,且不要把 Cookie/Header 原文无界写入日志。

7. 四个同步上下文

解析成功后中间件更新:

  1. Scoped ILanguageContext.CurrentLanguage;
  2. CultureInfo.CurrentUICulture;
  3. CultureInfo.CurrentCulture;
  4. 静态 L.SetContext(language, tenant, office, localizer) 的 AsyncLocal。

CurrentUICulture 适合资源查找,CurrentCulture 影响日期、数字和货币解析/格式化。把二者设置成同一语言很方便,但用户“界面语言”和“地区格式”可能不同;例如英文界面与中国时区/人民币格式不应被强绑定。

8. 全局 L() 生命周期

L.SetContext 让下游同步代码使用 L("Error.Key")。请求结束在 finally 中调用 L.ClearContext(),降低线程池复用导致上下文泄漏的风险。

在展示边界解析稳定错误码
// Domain/Application 保留稳定机器码;不要把中文文案写入聚合规则。
var result = await sender.Send(command, cancellationToken);
if (result.IsFailure)
{
// 只在 API/UI 映射边界解析人类可读文本。
var message = L(result.Error.Code);
return Results.Problem(title: message, extensions: new { result.Error.Code });
}

不要在脱离 HTTP 请求的后台 Job、并行 fire-and-forget 或测试中假设 AsyncLocal 已设置。它也不替代显式 tenant/language 参数;跨消息边界应携带规范化语言合同。

9. 响应头

中间件用 OnStarting 设置 Content-Language,但如果下游已设置则保留下游值。即使 CultureInfo 创建失败,响应头仍可能是无效/不支持的输入代码。

错误响应、认证拒绝与中间件前置短路是否经过这段 OnStarting 取决于管道位置。要用真实 Host 测 400/401/403/404/429/500,不要只测成功 Endpoint。

10. 输入安全与缓存污染

正则限制输入长度和字符,能减少直接缓存键注入,但大量不同合法形态仍可制造 language 维度高基数。Localizer 会为每个 language/tenant/office 组合创建 30 分钟缓存项。

GA 应先与启用语言 allowlist 求交集,再进入缓存;对无效语言负缓存或统一回退,限制每租户语言数量,并监控 cache-key cardinality。Header 也应有总长度限制,避免解析与日志放大。

11. 目标协商算法

是否

显式用户偏好 / Cookie / Header / Tenant default

解析并按 q 排序

规范化 BCP-47

与租户启用语言求交集

精确命中?

ResolvedLanguage + Reason

按配置尝试 parent / region fallback

租户默认 → 平台默认

结果对象应同时给出 Requested、Resolved、FallbackReason 和 FormattingCulture,便于响应头、指标和审计保持一致。不要让各端点自行再解析一次。

12. 测试矩阵

场景必须断言
Header/Cookie/Query 冲突与公开优先级完全一致
q 值、通配符、q=0标准或明确限制行为
大小写与别名canonical tag 唯一
正则通过但 Culture 不存在明确回退/Validation,不回显脏值
语言已禁用不进入 Localizer 缓存
Setting 失败/无效fallback reason 与指标
UI Culture 与 Format Culture产品规则可独立配置
并行请求AsyncLocal 不串租户/语言
401/403/429/500Content-Language 合同一致
Header 高基数攻击bounded cache cardinality

13. 检查命令

Terminal window
# 解析优先级、正则、Culture 与 AsyncLocal 生命周期。
rg -n "ResolveFromHeader|ResolveFromCookie|ResolveFromQuery|LanguageCodePattern|L\.SetContext|L\.ClearContext" \
src/Hosts/BitzOrcas.Api/Middleware/LanguageResolutionMiddleware.cs
# 完整 q 协商、启用语言检查与 Vary 当前预期无命中。
rg -n "Quality|q=|GetEnabledAsync|Vary|ResolvedLanguage" \
src/Hosts/BitzOrcas.Api/Middleware/LanguageResolutionMiddleware.cs

I18n 总览 · 翻译作用域与持久化 · 测试与 GA

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%