XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

全球化和本地化 ​

这一章讲框架的文案与多语言:消息键、每种语言一个文件的语言文件、key.L() 怎样拿到调用方语言的文案、Error 怎样只带键与参数而由宿主在出口翻译、Accept-Language 怎样协商成启用的区域,以及怎样把翻译放进数据库让运营人员在线修改。读完你能给应用加多语言文案,也能给自己的能力包写出可翻译的错误消息。前置知识是结果与错误处理与调用上下文。

为什么需要 ​

多语言的难点不在翻译本身,而在文案散落各处:异常消息里一句、校验特性里一句、API 文档里一句,加一种语言要改几十个文件;更麻烦的是线程文化(CultureInfo.CurrentUICulture)在异步代码与后台任务里并不可靠,一条消息里的文案与它的错误详情可能是两种语言。

框架的做法是把文案彻底从代码里拿走(RFC-0010)。代码只引用消息键(如 security.token.subject_missing),文案按语言放在 Localization/<区域>.json,任何地方用 key.L() 取当前语言的文本;Error 只携带键与参数,由宿主在 Egress 按调用方语言翻译。调用的语言不靠线程文化传递,而是调用上下文 Baggage 里的一项,随调用链传到下游服务。于是同一次调用里的日志、校验消息、Problem Details 永远是同一种语言,加一种语言只是加一个文件。

设计 ​

  • 默认语言是英文 en-US,它也是所有语言文件的基准。
  • 区域是调用上下文 Baggage 里的 xihan.locale。HTTP 宿主从 Accept-Language 协商写入,内核在每次调用入口把它协商成启用的区域,再安装一个消息作用域 MessageScope,于是同一调用链里任何位置的 key.L() 都得到调用方语言,且与失败文案是同一种语言,下游服务经 Baggage 拿到同一个区域。
  • 框架不设置 CultureInfo.CurrentUICulture,也不替换 IStringLocalizerFactory。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.MaxLength),比较不区分大小写;LocaleName.Normalize 把它规范成语言小写、地区大写、文字子标签首字母大写(zh-cn → zh-CN,ZH-hant-tw → zh-Hant-TW)。

消息键 ​

消息键是一种专门的代码,文法:

text
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。

每个包或应用一个 <领域>Messages 静态类,只定义键常量,不含文案;键的含义与占位符名写在 XML 文档里,翻译的人看注释就知道每个占位符是什么。

语言文件与嵌入方式 ​

每种语言一个文件 Localization/<区域>.json,内容是键到模板的平面 JSON 对象,以固定的逻辑名嵌入程序集:

xml
<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)
{{、}}输出单个花括号

解析是严格的:模板里任一占位符缺值,整条模板都不采用,退回键与参数的描述,不会输出半截文案。

框架自己的包由架构测试保证 10 个文件齐全、各区域的占位符与 en-US 一致、键常量与 en-US.json 一一对应、Error 的调用点只用常量。这些检查只扫描框架源码;应用的语言文件要自己保持同步,缺了某个区域的键时按回退链取到其他区域的文案。

最小示例 ​

我们给一个 Web 应用加一条可翻译的失败消息。四步:定义键常量;写两个语言文件并嵌入;注册目录;在端点里用键构造 Error,由宿主翻译。

csharp
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/en-US.json 与 Localization/zh-CN.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."
}
json
{
  "orders.create.title": "下单",
  "samples.order.name_blank": "订单名称不能只有空白。",
  "samples.resource.missing": "示例资源不存在。"
}
csharp
using SimpleApp;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Kernel.Hosting;
using XiHan.Framework.Localization.Abstractions;
using XiHan.Framework.Localization.Abstractions.Messages;
using XiHan.Framework.Primitives;
using XiHan.Framework.Profiles.Standard;

var builder = WebApplication.CreateBuilder(args);

// Standard 组合已装配默认的内存本地化
builder.AddXiHan(xihan => xihan.UseStandardProfile());

// 应用自己的消息模板(SampleMessages)与译文(嵌入的 Localization/<区域>.json)
builder.Services.AddMessageCatalog(MessageCatalog.FromEmbeddedResources("samples", typeof(SampleMessages)));

var app = builder.Build();

app.UseXiHan();

app.MapGet("/missing", (HttpContext http) =>
{
    // 把失败结果写进调用上下文,Egress 由宿主翻译为 RFC 9457 Problem Details
    http.GetXiHanContext().ValueOrThrow().SetOutcome(Error.NotFound("SAMPLES_NOTFOUND_001", SampleMessages.ResourceMissing));
    return Results.Empty;
});

app.Run();
bash
curl -si -H "Accept-Language: zh-CN" http://localhost:5000/missing

得到 404,响应头 Content-Language: zh-CN,Problem Details 的 detail 是「示例资源不存在。」;不带 Accept-Language 时是英文。代码里没有出现任何一句文案。

注册目录的规则 ​

AddMessageCatalog 的第一个参数是目录名,第二个参数是目录所在程序集里的任意类型。同名目录合并为一份:内容相同时什么都不变(多个 Provider 可能都带同一个抽象包的目录),互为补充时区域与键取并集(插件握手带来的目录与宿主包里同名目录的其他语言),同一键同一区域的模板不同时 AddMessageCatalog 抛出 LOCALIZATION_CONFLICT_001,应用启动失败。应用与插件的目录名不要与框架的重名。目录不合法(JSON 错误、键或区域名不合法、程序集里没有任何语言文件)时抛出 LOCALIZATION_VALIDATION_002,应用启动失败。

目录注册方
原语层 primitives、内核 kernel本地化特性(InMemoryLocalizationFeature 或 DatabaseLocalizationFeature)代为注册,它们自己不能读嵌入资源
子系统抽象包(objectstorage、configuration 等)对应的 Provider 在 Configure 里注册
应用、Plugins.InProcess 加载的插件应用注册

同一个键出现在多个目录里时,在回退链的同一个区域上先注册的目录优先。

取文案:key.L() 与 MessageScope ​

消息键常量上的扩展方法 L(在 XiHan.Framework.Primitives 命名空间)按当前 MessageScope 解析。下面的 OrdersMessages 是应用自己的键常量类,en-US.json 里的模板写在注释中:

csharp
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."
}
csharp
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

当前作用域按下面的规则确定:

  • 调用链里(操作、管道中间件、宿主映射到管道里的端点、消息处理器、作业):内核在调用入口按 Baggage xihan.locale 安装作用域,得到调用方区域。
  • 调用链之外(启动代码、后台线程):本地化特性启动时设置的进程默认作用域,得到默认区域。
  • 都没有,或目录里找不到这个键:返回 MessageTemplate.Describe(key, arguments),形如 kernel.feature.start_failed (id=…, reason=…),键与参数在日志里仍然可见。

需要在调用链之外按指定语言渲染(例如后台任务按收件人语言发邮件)时,临时安装一个作用域,resolver 传 null 沿用当前的解析器:

csharp
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。

csharp
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() 都按它取模板:

  1. 请求的区域(规范化;不合法时用默认区域)。
  2. 启用列表里同语言的其他区域,按启用列表的顺序。
  3. 默认区域。
  4. en-US:源语言,永远是最后一道回退。

链上去重并保持顺序。默认配置下请求 zh-HK 的链是 zh-HK → zh-CN → zh-TW → en-US,请求 it-IT 的链是 it-IT → en-US。占位符按实际命中区域的文化格式化。

错误的翻译 ​

Error 只携带错误码、消息键与参数,Details 同时是模板参数:

csharp
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(配置与编程错误)的消息是英文,不走消息键。

gRPC 的 grpc-message、MCP 的 isError 文本、命令行的 stderr 一行 JSON 都按同一规则翻译,见多协议暴露。错误码的分类与 Problem Details 的结构见结果与错误处理。

区域协商 ​

调用的区域只有一个来源:Baggage xihan.locale。各宿主把调用方的语言写进它:

  • HTTP 宿主:请求的 baggage 头里已有 xihan.locale 时不覆盖;否则取 Accept-Language 的候选,按权重降序(同权重保持原顺序),跳过 *、权重为 0 与格式不合法的项,经 LocalizationOptions.Negotiate 协商成启用的区域写入 Baggage。没有装配本地化时取第一个合法候选并规范化,下游服务仍能拿到调用方语言。
  • 命令行宿主:--locale 选项规范化后写入 Baggage。
  • CloudEvents 入站:扩展属性 xihanlocale。

Negotiate 分两轮:第一轮看任一候选是否精确命中启用列表;第二轮按语言子标签命中启用列表里第一个同语言的区域;都没有时用默认区域。精确命中优先于权重:Accept-Language: fr-CA, de-DE;q=0.8 协商出 de-DE,因为 de-DE 精确命中,而 fr-CA 只能按语言命中 fr-FR。

Baggage 随调用传播:HTTP 的 baggage 头、CloudEvents 的扩展属性,于是跨服务的调用链用同一个区域。宿主尚未就绪返回 503 时还没有调用上下文,Problem Details 直接从 Accept-Language 协商。

配置 ​

键类型缺省说明
XiHan:Localization:DefaultLocalestringen-US协商不到时使用的区域,也是进程默认作用域的区域
XiHan:Localization:Localesstring(逗号分隔)内置 10 种启用的区域,按优先级排列;同语言回退按这个顺序
json
{
  "XiHan": {
    "Localization": {
      "DefaultLocale": "zh-CN",
      "Locales": "zh-CN,zh-TW,en-US"
    }
  }
}

默认区域必须在启用列表里,区域名必须合法,否则装配本地化特性时抛出 ArgumentException,应用启动失败。

操作的标题与说明 ​

操作在各协议上的显示文案取自消息目录里的约定键,键名由操作 ID 推出(连字符换成下划线):

约定键用在
<操作 ID>.titleOpenAPI 操作的 summary、MCP tool 的 title
<操作 ID>.descriptionOpenAPI 操作的 description、MCP tool 的 description、AI 工具的描述
permissions.<权限码>.name / .descriptionOpenAPI 里权限的显示名与说明
openapi.tags.<资源段>.name / .descriptionOpenAPI 顶层 tags 的显示名与说明
openapi.groups.<组名>.name / .descriptionOpenAPI 分组的显示名与说明

说明里不写授权要求,适配器按契约里的权限统一呈现。SimpleApp 的 orders.create.title、orders.create.description 就是这类键,十种语言文件各有一份;没有的键对应的字段不输出。各协议的细节见规范化接口文档与 MCP。

数据库翻译 Provider ​

Localization.Database 把翻译存进关系数据库,运营人员改了译文,应用不重新发布就生效。它是 localization.resources@1 的显式 Provider,装配后顶替默认的内存实现,依赖 persistence.relational@1:

csharp
builder.AddXiHan(xihan => xihan
    .UseStandardProfile()
    .UseFeature(new EntityFrameworkCorePersistenceFeature<AppDbContext>(static (binding, options) => options.UseNpgsql(binding.ValueOrThrow().Value)))
    .UseFeature<DatabaseLocalizationFeature>());
csharp
// AppDbContext.OnModelCreating:同一个键与区域只能有一行,唯一索引由实体上的 [EntityIndex] 建出
modelBuilder.Entity<MessageTranslation>();
modelBuilder.ApplyPersistenceConventions(Database);

查找规则:沿回退链的每个区域,先查数据库快照,再查嵌入的目录。数据库里没有的键或区域照常走嵌入目录与回退链。

快照的加载:

  1. 宿主进入服务状态后,MessageTranslationLoader 经完整管道首次加载:按 ID 分页读取(每页 1000 条),最多 MaxTranslations 条,超出的不加载并记警告;读完后整份替换快照。宿主就绪之前只有嵌入目录的文案。
  2. 之后按 RefreshIntervalSeconds 周期刷新,0 关闭周期刷新。
  3. 加载失败时保留上一份快照,失败记到 LastError,健康项 localization.database.translations 报 Degraded,应用仍在服务。

MessageTranslation.Locale 列最长 35 个字符,与区域名文法的上限一致。已有表的这一列更短时要手工放宽(SqlSugar 的 InitTables 不改已有列的长度),做法见 Localization.Database。

写入用作用域服务 MessageTranslationStore,它经仓储读写,只能在调用的 Transaction 阶段内使用,所以放在操作里:

csharp
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快照的条数上限

持久化 Provider 的装配见持久化与 Provider。

错误码 ​

错误码含义
LOCALIZATION_VALIDATION_002消息目录不合法(JSON、键或区域名错误、程序集里没有语言文件),装载目录时抛出
LOCALIZATION_CONFLICT_001同名消息目录里同一键同一区域的模板不一致,AddMessageCatalog 抛出
LOCALIZATION_NOTFOUND_001数据库里没有这条翻译(MessageTranslationStore.RemoveAsync 的逐项结果)

区域名、消息键不合法属于编程错误,在构造处抛出 ArgumentException。

常见问题 ​

能不能继续用 IStringLocalizer 与 .resx ​

框架不替换 IStringLocalizerFactory,你的 .resx 照常可用,但它靠线程文化,与调用上下文的区域是两套。要让两边一致,在读取 IStringLocalizer 之前按 MessageScope.Current?.Locale 设置 CultureInfo.CurrentUICulture;更省事的做法是把文案迁到 Localization/<区域>.json,用 key.L() 统一。

应用只需要中文,能不配 10 种语言吗 ​

可以。XiHan:Localization:Locales 设成 zh-CN、DefaultLocale 设成 zh-CN,应用只写 zh-CN.json(en-US.json 建议保留作基准)。框架自己的文案仍有 10 种,不影响;没启用的区域即使有文件也不用。

key.L() 返回的是 kernel.feature.start_failed (id=…) 这样的描述 ​

说明当前没有消息作用域,或目录里没有这个键。在调用链之外(启动代码、Task.Run 出去的后台线程)取文案时要用 MessageScope.Use 临时安装作用域;目录没有键时检查语言文件是否嵌入成功、AddMessageCatalog 是否注册、键是否拼错。描述里保留了键与参数,日志不会丢信息。

数字与日期的格式为什么不随请求变 ​

框架不改线程文化,string.Format、ToString() 用的仍是进程文化。只有消息模板里的占位符按命中区域的文化格式化({amount:N2}),要按调用方语言格式化数字与日期,把它们放进模板,或者用 LocaleName.CultureOf(locale) 取 CultureInfo 显式传给格式化方法。

响应头 Content-Language 与请求的 Accept-Language 不一样 ​

Content-Language 是文案实际命中的区域。请求 zh-HK 而启用列表里只有 zh-CN、zh-TW 时,按回退链命中 zh-CN,响应头就是 zh-CN。这是有意的:告诉客户端拿到的是哪种语言的文案。

相关包与下一步 ​

Released under The MIT License