国际化
本章讲框架的文案与多语言:消息键、每种语言一个文件的语言文件、key.L() 与调用方区域、错误在 Egress 的翻译、Accept-Language 协商,以及把翻译放进数据库。适用于给应用加多语言文案、或给能力包写错误消息的开发者。
完整 API 见 Localization.Abstractions、Localization.InMemory 与 Localization.Database 包页。
设计
面向人的文案不写在代码里。代码只引用消息键(如 security.token.subject_missing),文案按语言放在 Localization/<区域>.json,任何地方用 key.L() 取当前语言的文本。Error 只携带消息键与参数,由宿主在 Egress 按调用方语言翻译。默认语言是英文 en-US,它也是所有语言文件的基准。
调用的语言不靠线程文化传递:框架不设置 CultureInfo.CurrentUICulture,也不替换 IStringLocalizerFactory。区域是调用上下文 Baggage 里的 xihan.locale,HTTP 宿主从 Accept-Language 协商写入,内核在每次调用入口把它经本地化器协商成启用的区域(与 context.Localize、Problem Details 用的规则相同),再据此安装一个消息作用域 MessageScope,于是同一调用链里任何位置的 key.L() 都得到调用方语言,且与失败文案是同一种语言,下游服务经 Baggage 拿到同一个区域。Baggage 里的区域不在启用列表时(上游 baggage 头、命令行 --locale 原样带进来的),即使有它的语言文件也不用,按同语言的启用区域或默认区域出文案。
装配本地化的是能力 localization.resources@1。Profiles.Minimal 与 Profiles.Standard 都装配默认实现 InMemoryLocalizationFeature;需要运营人员在线改译文时换成 DatabaseLocalizationFeature。没有装配本地化时,文案退回键与参数的描述,代码照常运行。
内置区域
| 区域 | 语言 |
|---|---|
en-US | 英语(默认,语言文件的基准) |
zh-CN | 简体中文 |
zh-TW | 繁体中文 |
ja-JP | 日语 |
ko-KR | 韩语 |
de-DE | 德语 |
es-ES | 西班牙语 |
fr-FR | 法语 |
pt-BR | 葡萄牙语(巴西) |
ru-RU | 俄语 |
这 10 种与 XiHan.UI 相同,常量在 SupportedLocales。区域名的文法是 ^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$,最长 35 个字符,比较不区分大小写;LocaleName.Normalize 把它规范成语言小写、地区大写、文字子标签首字母大写(zh-cn → zh-CN,ZH-hant-tw → zh-Hant-TW)。
消息键
消息键是一种专门的代码,文法:
MessageKey = Domain ("." Segment)+
Domain = Segment
Segment = [a-z][a-z0-9_]*即 ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$,最长 128 个字符(MessageTemplate.MaxKeyLength)。首段是领域:框架包用术语表领域词的小写(kernel、persistence、security),应用用自己的小写标识。操作 ID 的资源段带连字符时,键里把连字符换成下划线:操作 tenant-members.list 的说明键是 tenant_members.list.description(ADR-0038)。
每个包或应用一个 <领域>Messages 静态类,只定义键常量,不含文案;键的含义与占位符名写在 XML 文档里。SimpleApp 示例:
namespace SimpleApp;
/// <summary>
/// 示例应用的消息键
/// </summary>
public static class SampleMessages
{
/// <summary>
/// 示例资源不存在
/// </summary>
public const string ResourceMissing = "samples.resource.missing";
/// <summary>
/// 下单操作的标题,即 MCP tool 的 title 与 OpenAPI 的 summary
/// </summary>
public const string CreateOrderTitle = "orders.create.title";
/// <summary>
/// 订单名称只有空白
/// </summary>
public const string OrderNameBlank = "samples.order.name_blank";
}语言文件
每种语言一个文件 Localization/<区域>.json,内容是键到模板的平面 JSON 对象:
{
"orders.create.title": "Place an order",
"samples.order.name_blank": "The order name must not be blank.",
"samples.resource.missing": "The sample resource does not exist."
}{
"orders.create.title": "下单",
"samples.order.name_blank": "订单名称不能只有空白。",
"samples.resource.missing": "示例资源不存在。"
}文件以固定的逻辑名嵌入程序集:
<ItemGroup>
<EmbeddedResource Include="Localization/*.json" LogicalName="Localization/%(Filename)%(Extension)" />
</ItemGroup>LogicalName 不能省。MessageCatalog.FromEmbeddedResources 只认逻辑名以 Localization/ 开头、以 .json 结尾的资源,文件名就是区域名;不写 LogicalName 时资源名会带上根命名空间(SimpleApp.Localization.zh-CN.json),目录装载不到它。
模板语法:
| 写法 | 含义 |
|---|---|
{name} | 按名字取参数 |
{0}、{1} | 按位置取参数 |
{amount:N2}、{when:yyyy-MM-dd} | 带格式说明符,按命中区域的文化格式化({amount:N2} 在 de-DE 得到 1.234,50) |
{{、}} | 输出单个花括号 |
解析是严格的:模板里任一占位符缺值,整条模板都不采用,退回键与参数的描述,不会输出半截文案。
框架自己的包由架构测试 LocalizationTests 保证 10 个文件齐全、各区域的占位符与 en-US 一致、键常量与 en-US.json 一一对应、Error 的调用点只用常量。这些检查只扫描框架源码;应用的语言文件要自己保持同步,缺了某个区域的键时按回退链取到其他区域的文案。
注册应用的目录
应用的语言文件装成一个 MessageCatalog 注册进容器,SimpleApp 的写法:
using SimpleApp;
using XiHan.Framework.Localization.Abstractions;
using XiHan.Framework.Localization.Abstractions.Messages;
// 应用自己的消息模板(SampleMessages)与译文(嵌入的 Localization/<区域>.json)
builder.Services.AddMessageCatalog(MessageCatalog.FromEmbeddedResources("samples", typeof(SampleMessages)));第一个参数是目录名。同名目录合并为一份:内容相同时什么都不变(多个 Provider 可能都带同一个抽象包的目录),互为补充时区域与键取并集(插件握手带来的目录与宿主包里同名目录的其他语言),同一键同一区域的模板不同时 AddMessageCatalog 抛出 LOCALIZATION_CONFLICT_001,应用启动失败。应用与插件的目录名不要与框架的重名。第二个参数是目录所在程序集里的任意类型。目录不合法(JSON 错误、键或区域名不合法、程序集里没有任何语言文件)时抛出 LOCALIZATION_VALIDATION_002,应用启动失败。
各目录由谁注册:
| 目录 | 注册方 |
|---|---|
原语层 primitives、内核 kernel | 本地化特性(InMemoryLocalizationFeature 或 DatabaseLocalizationFeature)代为注册,它们自己不能读嵌入资源 |
子系统抽象包(objectstorage、configuration 等) | 对应的 Provider 在 Configure 里注册 |
应用、Plugins.InProcess 加载的插件 | 应用注册 |
同一个键出现在多个目录里时,在回退链的同一个区域上先注册的目录优先。
取文案
key.L()
消息键常量上的扩展方法 L 按当前 MessageScope 解析。下面的 OrdersMessages 是应用自己的键常量类,en-US.json 里的模板写在注释中:
public static class OrdersMessages
{
public const string NotFound = "orders.order.not_found"; // "Order '{id}' was not found."
public const string Total = "orders.order.total"; // "{0} orders, {1:N2} in total."
public const string Shipped = "orders.order.shipped"; // "Order '{id}' has shipped."
}using XiHan.Framework.Primitives;
var title = SampleMessages.CreateOrderTitle.L(); // 无参数
var missing = OrdersMessages.NotFound.L(("id", orderId)); // 按名字对应 {id}
var total = OrdersMessages.Total.L(count, amount); // 按位置对应 {0}、{1}
var details = MessageArguments.Of(("id", orderId));
var same = OrdersMessages.NotFound.L(details); // 参数字典,也可直接作 Error 的 Details当前作用域按下面的规则确定:
- 调用链里(操作、管道中间件、宿主映射到管道里的端点、消息处理器):内核在
App.InvokeAsync入口按 Baggagexihan.locale安装作用域,得到调用方区域。 - 调用链之外(启动代码、后台线程):本地化特性启动时设置的进程默认作用域,得到默认区域。
- 都没有,或目录里找不到这个键:返回
MessageTemplate.Describe(key, arguments),形如kernel.feature.start_failed (id=…, reason=…),键与参数在日志里仍然可见。
需要在调用链之外按指定语言渲染(例如后台任务按收件人语言发邮件)时,临时安装一个作用域,resolver 传 null 沿用当前的解析器:
using (MessageScope.Use(resolver: null, locale: recipientLocale))
{
var subject = OrdersMessages.Shipped.L(("id", orderId));
}作用域随 AsyncLocal 传到子任务,释放时恢复先前的作用域。
IMessageLocalizer
需要显式区域、或要知道文案实际命中哪个区域时,注入 IMessageLocalizer:
| 成员 | 返回 |
|---|---|
Resolve(key, locale, arguments) | Maybe<LocalizedText>:文案与命中的区域;没有模板或占位符缺值时无值 |
Localize(key, locale, arguments) | 文案;解析不到时是键与参数的描述 |
Localize(key, context, arguments) | 按调用上下文的区域解析 |
Resolve(error, locale) / Localize(error, locale) | 翻译 Error;自由文本错误没有键,Resolve 无值,Localize 返回原文 |
Find(key, locale) | 沿回退链取未格式化的模板 |
Options | 当前的 LocalizationOptions |
调用上下文上有等价的扩展方法 context.Localize(key, arguments) 与 context.Localize(error),没有装配本地化时分别退回描述与 Error.Message。
using XiHan.Framework.Localization.Abstractions.Messages;
var text = localizer.Resolve(SampleMessages.ResourceMissing, "zh-HK", arguments: null);
if (text.TryGetValue(out var localized))
{
// zh-HK 不在启用列表里,按同语言回退到 zh-CN
Console.WriteLine($"{localized.Locale}: {localized.Text}");
}回退链
LocalizationOptions.FallbackChain(locale) 给出查找顺序,IMessageLocalizer 与 key.L() 都按它取模板:
- 请求的区域(规范化;不合法时用默认区域)。
- 启用列表里同语言的其他区域,按启用列表的顺序。
- 默认区域。
en-US:源语言,永远是最后一道回退。
链上去重并保持顺序。默认配置下请求 zh-HK 的链是 zh-HK → zh-CN → zh-TW → en-US,请求 it-IT 的链是 it-IT → en-US。占位符按实际命中区域的文化格式化。
错误的翻译
Error 只携带错误码、消息键与参数,Details 同时是模板参数:
return Error.NotFound("ORDERS_NOTFOUND_001", OrdersMessages.NotFound, MessageArguments.Of(("id", id)));Error.Message按进程默认区域渲染,用于日志与异常;没有默认作用域时是键与参数的描述。- 宿主在 Egress 把失败写成 RFC 9457 Problem Details:
detail是IMessageLocalizer.Resolve(error, locale)的文案,响应头Content-Language是文案实际命中的区域(可能是回退链上的区域),字段级errors中带消息键的违规按同一区域翻译。没有装配本地化或错误是自由文本时,detail是Error.Message,不带Content-Language。 Error.Literal(code, text)没有键、不翻译,只用于线上还原远端错误与把异常转换为错误。WithCode换码时保留键与参数。- 幂等记录保存消息键与字符串化的参数,回放时仍按回放请求的区域翻译。
FrameworkException(配置与编程错误)的消息是英文,不走消息键。
SimpleApp 的 /missing 端点把失败写进调用上下文,由宿主翻译:
app.MapGet("/missing", (HttpContext http) =>
{
// 把失败结果写进调用上下文,Egress 由宿主翻译为 RFC 9457 Problem Details
http.GetXiHanContext().ValueOrThrow().SetOutcome(Error.NotFound("SAMPLES_NOTFOUND_001", SampleMessages.ResourceMissing));
return Results.Empty;
});# 在仓库根运行 SimpleApp(dotnet run --project framework/samples/SimpleApp)后
curl -si -H "Accept-Language: zh-CN" http://localhost:5000/missing
# 404,响应头 Content-Language: zh-CN,detail 为「示例资源不存在。」错误码的分类与 Problem Details 的结构见结果与错误。
区域协商
调用的区域只有一个来源:Baggage xihan.locale。各宿主把调用方的语言写进它:
- HTTP 宿主:请求的
baggage头里已有xihan.locale时不覆盖;否则取Accept-Language的候选,按权重降序(同权重保持原顺序),跳过*、权重为 0 与格式不合法的项,经LocalizationOptions.Negotiate协商成启用的区域写入 Baggage。没有装配本地化时取第一个合法候选并规范化,下游服务仍能拿到调用方语言。 - 命令行宿主:
--locale选项规范化后写入 Baggage。
Negotiate 分两轮:第一轮看任一候选是否精确命中启用列表;第二轮按语言子标签命中启用列表里第一个同语言的区域;都没有时用默认区域。精确命中优先于权重:Accept-Language: fr-CA, de-DE;q=0.8 协商出 de-DE,因为 de-DE 精确命中,而 fr-CA 只能按语言命中 fr-FR。
Baggage 随调用传播:HTTP 的 baggage 头、CloudEvents 的扩展属性 xihanlocale,于是跨服务的调用链用同一个区域。宿主尚未就绪返回 503 时还没有调用上下文,Problem Details 直接从 Accept-Language 协商。
配置
| 键 | 类型 | 缺省 | 说明 |
|---|---|---|---|
XiHan:Localization:DefaultLocale | string | en-US | 协商不到时使用的区域,也是进程默认作用域的区域 |
XiHan:Localization:Locales | string(逗号分隔) | 内置 10 种 | 启用的区域,按优先级排列;同语言回退按这个顺序 |
{
"XiHan": {
"Localization": {
"DefaultLocale": "zh-CN",
"Locales": "zh-CN,zh-TW,en-US"
}
}
}默认区域必须在启用列表里,区域名必须合法,否则装配本地化特性时抛出 ArgumentException,应用启动失败。
操作的标题与说明
操作在各协议上的显示文案取自消息目录里的约定键,键名由操作 ID 推出(连字符换成下划线):
| 约定键 | 用在 |
|---|---|
<操作 ID>.title | OpenAPI 操作的 summary、MCP tool 的 title |
<操作 ID>.description | OpenAPI 操作的 description、MCP tool 的 description、AI 工具的描述 |
permissions.<权限码>.name / .description | OpenAPI 里权限的显示名与说明 |
openapi.tags.<资源段>.name / .description | OpenAPI 顶层 tags 的显示名与说明 |
openapi.groups.<组名>.name / .description | OpenAPI 分组的显示名与说明 |
说明里不写授权要求,适配器按契约里的权限统一呈现。SimpleApp 的 orders.create.title、orders.create.description 就是这类键,十种语言文件各有一份;没有的键对应的字段不输出。各协议的细节见 HTTP 与 OpenAPI 与 gRPC、MCP 与 CloudEvents。
数据库翻译
Localization.Database 把翻译存进关系数据库,运营人员改了译文,应用不重新发布就生效。它是 localization.resources@1 的显式 Provider,装配后顶替默认的内存实现,依赖 persistence.relational@1:
builder.AddXiHan(xihan => xihan
.UseStandardProfile()
.UseFeature(new EntityFrameworkCorePersistenceFeature<AppDbContext>(static (binding, options) => options.UseNpgsql(binding.ValueOrThrow().Value)))
.UseFeature<DatabaseLocalizationFeature>());// AppDbContext.OnModelCreating:同一个键与区域只能有一行,唯一索引由实体上的 [EntityIndex] 建出
modelBuilder.Entity<MessageTranslation>();
modelBuilder.ApplyPersistenceConventions(Database);查找规则:沿回退链的每个区域,先查数据库快照,再查嵌入的目录。数据库里没有的键或区域照常走嵌入目录与回退链。
快照的加载:
- 宿主进入服务状态后,
MessageTranslationLoader经完整管道首次加载:按 ID 分页读取(每页 1000 条),最多MaxTranslations条,超出的不加载并记警告;读完后整份替换快照。宿主就绪之前只有嵌入目录的文案。 - 之后按
RefreshIntervalSeconds周期刷新,0关闭周期刷新。 - 加载失败时保留上一份快照,失败记到
LastError,健康项localization.database.translations报 Degraded,应用仍在服务。
MessageTranslation.Locale 列最长 35 个字符,与区域名文法的上限一致。已有表的这一列更短时要手工放宽(SqlSugar 的 InitTables 不改已有列的长度),做法见 Localization.Database。
写入用作用域服务 MessageTranslationStore,它经仓储读写,只能在调用的 Transaction 阶段内使用,所以放在操作里:
using XiHan.Framework.Kernel.Operations;
using XiHan.Framework.Localization.Database.Translations;
using XiHan.Framework.Primitives;
namespace Admin.Translations;
public sealed record TranslationItem(string Key, string Locale, string Template);
public sealed record SetTranslationsRequest(IReadOnlyList<TranslationItem> Items);
public sealed record SetTranslationsResponse(int Written, int Failed);
[Operation(Id, Permission = "translations.write")]
public sealed class SetTranslations(MessageTranslationStore store) : IOperation<SetTranslationsRequest, SetTranslationsResponse>
{
public const string Id = "translations.set";
public async ValueTask<Result<SetTranslationsResponse>> HandleAsync(SetTranslationsRequest request, InvocationContext context)
{
// 一次批量写入;同一批里重复的键与区域后者覆盖前者,逐项给出结果
var entries = request.Items.Select(static i => new MessageTranslationEntry(i.Key, i.Locale, i.Template)).ToList();
var results = await store.UpsertAsync(entries, context.CancellationToken);
var written = results.Count(static r => r.Outcome.IsSuccess);
return new SetTranslationsResponse(written, results.Count - written);
}
}操作的登记、请求类型的序列化上下文与权限定义见操作契约。MessageTranslationEntry 在构造时校验:键必须符合消息键文法,区域规范化大小写,模板不能为空白,不合法抛出 ArgumentException。RemoveAsync 按键与区域批量删除,不存在的项返回 LOCALIZATION_NOTFOUND_001;ListAsync(locale, skip, take) 按键排序分页列出。
写入要等这次调用的事务提交后才落库。新译文在下一次周期刷新时生效;要立即生效,在写入的调用成功返回之后调用 MessageTranslationLoader.ReloadAsync,不要在同一个操作里调用,那时事务还没提交。
| 键 | 类型 | 缺省 | 说明 |
|---|---|---|---|
XiHan:Localization:Database:RefreshIntervalSeconds | 非负数 | 300 | 周期刷新的间隔,0 只在启动后加载一次 |
XiHan:Localization:Database:MaxTranslations | 正整数 | 100000 | 快照的条数上限 |
错误码
| 错误码 | 含义 |
|---|---|
LOCALIZATION_VALIDATION_002 | 消息目录不合法(JSON、键或区域名错误、程序集里没有语言文件),装载目录时抛出 |
LOCALIZATION_CONFLICT_001 | 同名消息目录里同一键同一区域的模板不一致,AddMessageCatalog 抛出 |
LOCALIZATION_NOTFOUND_001 | 数据库里没有这条翻译(MessageTranslationStore.RemoveAsync 的逐项结果) |
区域名、消息键不合法属于编程错误,在构造处抛出 ArgumentException。
相关
- 结果与错误
- HTTP 与 OpenAPI
- 持久化
- Localization.Abstractions
- Localization.InMemory
- Localization.Database
- 规范:RFC-0010《本地化》、ADR-0038
