跳转到内容

国际化

框架的多语言方案:文案放 JSON 文件、按请求头切文化、枚举与异常消息也一并本地化。这一章讲清资源怎么组织、文化怎么解析、以及哪些地方最容易踩空。

完整 API 与全部配置项见 Localization 包Localization.Abstractions 包

整体流程

text
请求进来
  └─ XiHanRequestCultureMiddleware 解析文化 → 设置 CurrentCulture / CurrentUICulture
       └─ 业务代码取 IStringLocalizer["Key"]
            └─ JSON 资源存储(虚拟文件系统加载的 *.json)
                 └─ miss → ResourceManager(.resx)兜底
                      └─ 仍 miss → 原样返回键名

三件事分别由三个东西负责,理解这个分工就不会找错地方:

职责承担者关键点
决定「当前是哪种语言」XiHanRequestCultureMiddleware只在请求范围内生效,请求结束还原线程文化
决定「文案从哪来」JsonLocalizationResourceStore经虚拟文件系统读 JSON,支持热重载
决定「怎么取」XiHanStringLocalizerFactory / XiHanJsonStringLocalizerReplace 覆盖了微软默认工厂

安装与启用

bash
dotnet add package XiHan.Framework.Localization
csharp
[DependsOn(typeof(XiHanLocalizationModule))]
public class MyModule : XiHanModule { }

模块的 ConfigureServices 调用 AddXiHanLocalization(config),从配置节 XiHan:Localization 绑定 XiHanLocalizationOptions,并把 IStringLocalizerFactory 替换为 XiHanStringLocalizerFactory(JSON 优先)、注册 JsonLocalizationResourceStoreIEnumLocalizationService

走 Web.Api 时中间件已经接好

XiHanWebApiModuleOnApplicationInitialization 里已经调用了 app.UseXiHanRequestCulture()(紧跟 TraceId、先于路由与 MVC)。

只有自己搭非 Web.Api 的宿主管线时,才需要手工调 app.UseXiHanRequestCulture(),且必须放在需要文化的中间件之前。

资源文件怎么组织

资源根目录由 ResourcesPath 决定,默认虚拟路径 /Localization;存储会递归枚举其下所有 *.json。虚拟文件系统默认挂载应用基目录与当前工作目录,因此实际落点就是运行目录下的 Localization/

工程里放物理文件时,记得让它随输出走:

xml
<None Update="Localization\**\*.json">
  <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>

也可以把资源做成嵌入式资源,由虚拟文件系统的 AddEmbedded<TAssembly>() 提供,取值方式完全一致。

文件格式

推荐 {Resource}.{culture}.json 命名,并在文件内同时声明 resourceculture

json
{
  "resource": "Errors",
  "culture": "zh-CN",
  "texts": {
    "Auth.UserNotFound": "用户不存在。",
    "Tenant.CodeAlreadyExists": "租户编码已存在。",
    "CodeGeneration.TableAlreadyConfigured": "数据库表“{0}”已配置,请勿重复导入。"
  }
}
  • 文本节点从 texts 读取,没有 texts 时读 resources,再没有就读根对象。
  • 嵌套对象会被扁平化a.b.c;数组按原始文本存,数字与布尔转成字符串。
  • 键的比较大小写不敏感。
  • 同一「资源 + 文化」可以由多个文件贡献,按路径顺序合并,后者覆盖前者。

资源名与文化的解析优先级

情形资源名取自文化取自
JSON 同时有 resourcecultureJSON 的 resourceJSON 的 culture
JSON 无 culture,文件名形如 Errors.zh-CN.json文件名的 Errors文件名的 zh-CN
文件名就是纯文化码,如 zh-CN.json所在目录名文件名
都识别不出所在目录名DefaultCulture

别只声明 resource 不声明 culture

JSON 里写了 resource 但没写 culture 时,会继续按文件名解析文化;一旦文件名带文化后缀,文件名前缀会覆盖 JSON 里的 resource。两处保持一致最省心。

另外,形如 Errors.json(无文化后缀,cultureresource 两个字段也都没写)会被归到默认文化,且资源名取的是目录名而不是 Errors——取值时必然 miss。

取文案

注入 IStringLocalizer<T>IStringLocalizer

csharp
using Microsoft.Extensions.Localization;

public class GreetingService(IStringLocalizer<GreetingService> localizer)
{
    // 资源名 = 类型短名 GreetingService
    public string Hello() => localizer["HelloWorld"];

    // 带格式化参数,按当前文化 string.Format
    public string Welcome(string name) => localizer["Welcome", name];
}

也可以按资源名直接创建,适合与类型无关的公共资源(如 ErrorsApiResponse):

csharp
public class ErrorTextProvider(IStringLocalizerFactory factory)
{
    public string NotFound() => factory.Create("Errors", "Errors")["Auth.UserNotFound"];
}
创建方式资源名取值规则
Create(Type) / IStringLocalizer<T>类型短名(Type.Name
Create(baseName, location)baseName. / \ 拆分后的最后一段

纯 JSON 资源不需要 .resx

Create(baseName, location) 的 ResourceManager 兜底会尝试把 location 当程序集加载。像 Errors 这种没有 backing 程序集的纯 JSON 资源,工厂会把兜底降级为「无兜底」,取值仍走 JSON,不会因为加载程序集失败而崩。

取值链与回退

一次取值按下面顺序找,命中即返回:

  1. 文化回退链逐个文化查;每个文化下先查指定资源,miss 再查 DefaultResourceName(默认 Default)。
  2. JSON 全 miss → 回退 IStringLocalizer(ResourceManager 或空实现)。
  3. 仍 miss → 返回 LocalizedString(name, name, resourceNotFound: true),即页面上看到的是键名本身

文化回退链的构成:

顺序内容受控开关
1当前文化(CurrentUICultureWithCulture 指定的固定文化)
2逐级父文化(zh-CNzhFallbackToParentCultures
3DefaultCultureFallbackToDefaultCulture

请求文化与 X-Language

text
X-Language 头(头名可配) ──在受支持文化内?──▶ 用它
        └─否─▶ Accept-Language(按 q 权重降序逐个试) ──命中?──▶ 用它
                        └─否─▶ DefaultCulture
  • 头名由 CultureHeaderName 配置,默认 X-Language
  • SupportedCultures 非空时,只接受列表内的文化(大小写不敏感);列表为空表示不限制,只要求是合法文化码。
  • 解析结果写入 HttpContext.Items,键为常量 XiHanRequestCultureMiddleware.CultureItemKey"__XiHanCulture"),方便日志与请求上下文取用。
  • 中间件在 finally 中还原线程的 CurrentCulture / CurrentUICulture,避免线程池复用时残留请求级文化。

白名单是精确匹配,不做父文化归并

SupportedCultures["zh-CN", "en-US"] 时,客户端发 X-Language: en 不会命中 en-US——它会继续往下走 Accept-Language,最终多半落到默认文化。

要么让前端发完整文化码,要么把 en 也加进白名单。

后台任务没有请求文化

中间件只覆盖请求管线。定时任务、队列消费者等后台线程用的是进程默认文化,需要指定语言时显式创建带固定文化的本地化器(XiHanJsonStringLocalizer.WithCulture(culture)),或在枚举查询里传 CultureName

枚举本地化

枚举展示文案的单一事实源是本地化资源,默认资源名由 EnumResourceName 决定(默认 Enums):

json
{
  "resource": "Enums",
  "culture": "en-US",
  "texts": {
    "EnableStatus.Disabled": "Disabled",
    "EnableStatus.Enabled": "Enabled",
    "UserGender.Unknown": "Unknown"
  }
}

通过 IEnumLocalizationService 取:

csharp
public class EnumMetadataService(IEnumLocalizationService enumLocalization)
{
    // 按类型取,未指定文化时用当前 UI 文化
    public LocalizedEnumDefinition ByType()
        => enumLocalization.Get(typeof(EnableStatus));

    // 按类型名取(短名或完整名皆可)
    public LocalizedEnumDefinition ByName()
        => enumLocalization.Get("EnableStatus", new EnumLocalizationQuery
        {
            CultureName = "en-US",
            IncludeHidden = false,
            Ordered = true
        });

    // 批量与安全版本
    public IReadOnlyDictionary<string, LocalizedEnumDefinition> Many()
        => enumLocalization.GetMany(["EnableStatus", "UserGender"]);
}

返回的 LocalizedEnumDefinition.Items 每项都带 NameValueLabelDescriptionThemeIconOrderHiddenDisabledExtra,前端可以直接渲染下拉与状态标签。

键的候选顺序

对每个枚举项,按下表依次尝试,第一个命中的即为 Label;全部 miss 则回退到该项的 Description(来自 [EnumDisplay] / [Description] 等特性)。

顺序候选键来源
1字段显式指定的键[EnumLocalizationKey("...")][EnumDisplay(LocalizationKey = "...")]
2{前缀}.{枚举名}.{字段名}前缀取 [EnumLocalizationResource(KeyPrefix = "...")],为空时取 EnumLocalizationKeyPrefix;两者都为空则跳过本行
3{枚举名}.{字段名}约定(推荐用这一种
4{枚举名}_{字段名}约定
5{字段名}约定

资源名的选取顺序:字段上的 [EnumLocalizationResource] → 枚举类型上的 [EnumLocalizationResource]EnumResourceName;解析结果为空时兜底 DefaultResourceName

短名冲突会解析失败

首次按名称取值时才扫描已加载程序集建立枚举索引(Get(Type) 不走该索引)。若两个不同命名空间存在同名枚举,该短名会被判为二义并从短名索引中移除——此时 Get("XxxStatus")KeyNotFoundException,必须改用完整名或 Get(Type)

TryGet 可以拿到 false 而不是异常。

可本地化异常

面向用户的错误消息不要硬编码中文。抛异常时传一个 ResourceLocalizableString,同时给一句回退文案:

csharp
using XiHan.Framework.Core.Exceptions;
using XiHan.Framework.Localization.Abstractions;

// 无参
throw new UserFriendlyException(
    new ResourceLocalizableString("Errors", "Auth.UserNotFound"),
    "用户不存在。");

// 带格式化参数(对应 JSON 里的 {0})
throw new UserFriendlyException(
    new ResourceLocalizableString("Errors", "CodeGeneration.TableAlreadyConfigured", tableName),
    $"数据库表“{tableName}”已配置,请勿重复导入。");

机制:

  • BusinessException.LocalizableMessageobject 弱类型承载可本地化消息,UserFriendlyException 的第二个构造函数负责写入。
  • 真正解析发生在 XiHanApiResponseResultFilter:识别出 ILocalizableString 后按当前请求文化解析,缺键时回退到异常的 Message(也就是你传的那句回退文案)。
  • 未携带可本地化消息的 500 响应,会按资源 ApiResponse、键 ServerError 本地化通用提示;正常响应的默认消息按业务码名(如 SuccessBadRequest)在同一资源里查。

回退文案不是可选项

第二个参数同时充当「本地化键缺失时的展示文案」和「日志里的异常消息」。省掉它,键一旦写错,用户看到的就是空消息。

其它可本地化构件:

类型用途
FixedLocalizableString固定文本,直接返回原值;"文本".ToFixedLocalizableString() 可快速构造
ResourceLocalizableString(Type, name, args)按资源类型解析,等价于 IStringLocalizer<T>
IHasNameWithLocalizableDisplayName「有标识名 + 可本地化显示名」的对象契约,配合 GetLocalizedDisplayName(factory) 使用
LocalizeOrFallback(factory, fallback)解析并在缺键时回退,避免到处判 ResourceNotFound

动态热重载

EnableDynamicJsonReload 默认 true。资源存储同时监听两个来源:

触发源条件
虚拟文件系统 OnFileChanged变更文件在 ResourcesPath 之下且扩展名为 .json
IOptionsMonitor<XiHanLocalizationOptions> 变更重算资源路径、重挂监听

命中后重置整份缓存并递增 JsonLocalizationResourceStore.Version,下次取值时按需重新加载。Version 可以作为上层缓存(例如前端字典缓存)的失效依据。

改文案不用重启,改配置也不用

开发期直接改运行目录下的 JSON 即可即时生效。生产若不希望文件监听开销,把 EnableDynamicJsonReloadfalse

配置

配置节 XiHan:Localization,常用几项:

json
{
  "XiHan": {
    "Localization": {
      "ResourcesPath": "/Localization",
      "DefaultCulture": "zh-CN",
      "SupportedCultures": ["zh-CN", "en-US"],
      "CultureHeaderName": "X-Language",
      "DefaultResourceName": "Default",
      "EnumResourceName": "Enums",
      "EnableDynamicJsonReload": true
    }
  }
}
默认值什么时候需要改
ResourcesPath/Localization资源不放默认目录时
DefaultCulturezh-CN主语言不是中文时
SupportedCultures["zh-CN", "en-US"]新增语言时必须同步加,否则请求头切不过去
CultureHeaderNameX-Language前端约定了别的头名时
DefaultResourceNameDefault想换通用文案的兜底资源名时
EnumResourceNameEnums枚举文案不放 Enums 资源时
EnableDynamicJsonReloadtrue生产想关掉文件监听时

完整配置表见 Localization 包

常见问题

现象原因处理
页面显示的是键名本身JSON 与 ResourceManager 都没命中该键核对资源名、文化码、扁平化后的完整键
文件放了但完全不生效未随输出拷贝,或不在 ResourcesPath 之下CopyToOutputDirectory,确认运行目录下确有该文件
资源名对不上文件名无文化后缀,资源名回落成了目录名{Resource}.{culture}.json,或在 JSON 内声明 resource + culture
X-Language: en 不切换白名单精确匹配,enen-US发完整文化码,或把 en 加入 SupportedCultures
切了语言但枚举标签还是中文枚举键写法与候选顺序不符统一用 {枚举名}.{字段名},并确认资源名是 EnumResourceName
Get("XxxStatus")KeyNotFoundException不同命名空间存在同名枚举,短名二义用完整名或 Get(Type);不确定时用 TryGet
异常消息没被翻译抛的是普通异常,或没传 ILocalizableStringUserFriendlyException(new ResourceLocalizableString(...), 回退文案)
后台任务的文案永远是默认语言后台线程不经过请求文化中间件显式指定文化(WithCultureEnumLocalizationQuery.CultureName
自定义的 IStringLocalizerFactory 不生效本模块用 Replace 覆盖了该服务在本模块之后再次 Replace

下一步

Released under The MIT License