.NET i18n 原理、实现方式一个 i18n 框架的过程完整指南

作者:袖梨 2026-08-27

平时做技术实践时,很多问题不是概念不会,而是细节没串起来。拿“.NET i18n 原理、实现方法一个 i18n 框架的过程”来说,它看着像小点,放到项目里常会牵出环境、配置、兼容性和维护成本。下面按实际采用顺序,把思路、关键写法和容易踩坑的地方讲清楚,便于大家直接对照操作。

目录
  • 什么是 i18n?
  • i18n vs L10n 的区别
  • 体验 Maomi.I18n
    • 控制台示例
    • 单个项目管理
    • 如何设置当前语言
    • Web 示例
  • 携带请求语言信息
  • 实现 i18n 框架
    • 抽象接口
    • 实现从 json 读取语言资源
  • 单元测试
  • 基于 Redis 的动态多语言
  • nuget 打包嵌入 json

在这个场景下,随着业务的国际化,为了满足不同客户群体的需,软件产品需兼容多种语言,根据用户选择的语言呈现不同语言的界面。ASP.NET Core 或 ABP 等框架都提供了多语言解决方案,尽管设置方法各不相同,但都是借助键值对的方式采用的,开发者需为每个 key 提供对应语言的值,框架会根据请求上下文自动区配 key 在对应语言下的值。

理解这一步时,在本章里,笔者将基于 ASP.NET Core,简述如何实现一个 i18n 多语言框架,Maomi.I18n 兼容在控制台、ASP.NET Core、WPF 等项目下采用,兼容自定义多语言资源。

什么是 i18n?

i18n 从实现思路看,是 "internationalization"(国际化)的缩写。之所以写成 i18n,是因为首字母 i 和末字母 n 之间有 18 个字母。

轻松理解:i18n = 国际化,指的是让应用程序能够适应不同语言和地区的技术。

类似的缩写还有:L10n (localization,本地化,L 和 n 之间有 10 个字母)

i18n vs L10n 的区别

概念英文含义示例
国际化i18n开发时让程序兼容多语言的能力采用资源文件、兼容 Unicode、处理不同日期格式
本地化L10n针对特定语言/地区进行适配翻译文本、采用本地货币符号、适配当地日期格式

类比理解

  • i18n = 手机充电口设计成 Type-C(通用接口)
  • L10n = 给手机配一个当地规格的充电头

体验 Maomi.I18n

在这个场景下,Maomi.I18n 是本章完成学习后的代码成果, 在编写多语言框架之前,先来学习 Maomi.I18n 的用法。

控制台示例

实际处理时,若要采用 json 文件存储语言资源包,那么需根据项目名称新建目录,下面,我们新建示例项目演示这个过程。
新建一个 Demo5.Lib 项目,引入 Maomi.I18n 框架。

实际处理时,在 Demo5.Lib 和 Console 两个项目中都添加 i18n 目录和对应的多语言资源文件。

落到代码里,在 i18n/Demo5.Lib 目录中新建 en-US.json、zh-CN.json 文件。

两个文件的内容都设置为:

{
  "test": "lib"
}

结合项目来看,右键修改 en-US.json、zh-CN.json 属性,设置生成操作为内容、始终复制到输出目录。

在这个场景下,随后新建一个 Demo5.Console 项目,引用 Demo5.Lib。

落到代码里,在 i18n/Demo5.Console 目录中新建 en-US.json、zh-CN.json 文件。

两个文件都设置内容为:

{
  "test": "console"
}

最后得到的目录结构如下所示:

├─Demo5.Console
│ │ Demo5.Console.csproj
│ │ Program.cs
│ │
│ ├─i18n
│ │ └─Demo5.Console
│ │ en-US.json
│ │ zh-CN.json

├─Demo5.Lib
│ │ Demo5.Lib.csproj
│ │ Extensions.cs
│ │
│ ├─i18n
│ │ └─Demo5.Lib
│ │ en-US.json
│ │ zh-CN.json

理解这一步时,在 Demo5.Console 的 Program 中采用 IStringLocalizer<T> 来拿到 key 在不同语言下的值。

var ioc = new ServiceCollection();
ioc.AddI18n("zh-CN");
ioc.AddI18nResource(options =>
{
    options.ParseDirectory("i18n");
});
ioc.AddLib();
var services = ioc.BuildServiceProvider();
// 手动设置当前请求语言
using (var c = new I18nScope("en-US"))
{
    var l1 = services.GetRequiredService<IStringLocalizer<Program>>();
    var l2 = services.GetRequiredService<IStringLocalizer<Test>>();
    var s1 = l1["test"];
    var s2 = l2["test"];
    Console.WriteLine(s1);
    Console.WriteLine(s2);
}

从实现思路看,编译 Demo5.Console,打开 bin/Debug/net8.0 目录,在 i18n 目录下能够看到如下所示文件结构:

.
├── Demo5.Console
│ ├── en-US.json
│ └── zh-CN.json
└── Demo5.Lib
    ├── en-US.json
    └── zh-CN.json

结合项目来看,Maomi.I18n 的原理很轻松,每个项目都设置多语言文件,编译后所有文件都会合同时到 i18n 目录中统一管理和加载,每个目录都与项目名称一致,便于区分。采用 IStringLocalizer<T> 读取 key 时,会自动从 T 类型所在的项目名称目录下加载 json 文件。

单个项目管理

从实现思路看,在上一个例子里,每个项目都有自己的多语言资源文件,当然我们在整个解决方案z共用一个语言文件,不需按照目录划分。

收到导入多语言资源文件:

ioc.AddI18nResource(options =>
{
    options.AddJsonFile("zh-CN", "i18n/zh-CN.json");
    options.AddJsonFile("en-US", "i18n/en-US.json");
});

或者自动扫描目录,同时自动采用 json 文件名称当作多语言名称:

ioc.AddI18nResource(options =>
{
    options.AddJsonDirectory("i18n");
});

采用时直接注入 IStringLocalizer,不需注入 IStringLocalizer<T>

// 手动设置当前请求语言
using (var c = new I18nScope("en-US"))
{
    var l1 = services.GetRequiredService<IStringLocalizer>();
    var l2 = services.GetRequiredService<IStringLocalizer>();
    var s1 = l1["test"];
    var s2 = l2["test"];
    Console.WriteLine(s1);
    Console.WriteLine(s2);
}

如何设置当前语言

设置当前上下文语言,只需设置当前文化即可:

CultureInfo.CurrentCulture = new CultureInfo("zh-CN");

理解这一步时,另一种方法是跟当前容器上下文有个,开发者能够自由定义如何解析当前程序多语言上下文。

public class MyI18nContext : I18nContext
{
    public MyI18nContext()
    {
        base.Culture = ...
    }
    public void Set(CultureInfo cultureInfo)
    {
        base.Culture = cultureInfo;
    }
}
builder.Services.AddScoped<I18nContext, MyI18nContext>();

Web 示例

从实现思路看,新建一个 Api 项目,名为 Demo5.Api,引入 Maomi.I18n.AspNetCore

结合项目来看,在项目里新建一个 i18n/Demo5.Api 目录,随后新建两个 json 文件。

zh-CN.json 文件内容:

{
  "购物车": {
    "商品名称": "商品名称",
    "加入时间": "加入时间",
    "清理失效商品": "清理失效商品"
  },
  "会员等级": {
    "用户名": "用户名",
    "积分": "积分:{0}",
    "等级": "等级"
  }
}

en-US.json 文件内容:

{
  "购物车": {
    "商品名称": "Product name",
    "加入时间": "Join date",
    "清理失效商品": "Cleaning up failures"
  },
  "会员等级": {
    "用户名": "Username",
    "积分": "Member points:{0}",
    "等级": "Level"
  }
}

Maomi.I18n 框架会扫描程序集目录的 json 文件,随后解析 json 文件以键值对的形式存储到内存中,Key 的形式与第三章中提到的 IConfiguration 的 Key 一致,在第四章中也提到了如何解析 json 文件,比如要取得商品名称的值,能够采用 ["购物车:商品名称"] 这样的形式拿到嵌套层次下的值,而且还能够采用字符串插值,如 "积分": "Member points:{0}"

理解这一步时,采用 Maomi.I18n,只需两步,注入 i18n 服务和导入 i18n 语言资源。

// 添加 i18n 多语言支持
builder.Services.AddI18nAspNetCore(defaultLanguage: "zh-CN");
// 设置多语言来源-json
builder.Services.AddI18nResource(option =>
{
    var basePath = "i18n";
    option.AddJson(basePath);
});

实际处理时,接着,添加 i18n 中间件,中间件会将用户请求时的上下下文解析出对应的语言。

var app = builder.Build();
app.UseI18n(); // <- 放到中间件靠前的位置

实际处理时,随后添加控制器或直接编写中间件进行测试,只需注入 IStringLocalizer 服务即可。

app.UseRouting();
app.Use(async (HttpContext context, RequestDelegate next) =>
{
    var localizer = context.RequestServices.GetRequiredService<IStringLocalizer<Program>>();
    await context.Response.WriteAsync(localizer["购物车:商品名称"]);
    return;
});

结合项目来看,启动程序,打开地址 (链接已移除) 能够观察到输出为 Product name。

携带请求语言信息

Maomi.I18n 本质是基于 ASP.NET Core 的多语言接口进行扩展的,所以 Maomi.I18n 同时不需做太多解析语言的工作,而是依靠 ASP.NET Core 自带的多语言功能将客户端请求时解析出要采用的语言,以及相关的上下文信息。

从实现思路看,ASP.NET Core 中能够采用 app.UseRequestLocalization(); 引入 RequestLocalizationMiddleware 中间件提供一些多语言的处理。RequestLocalizationMiddleware 中间件会自动调用 IRequestCultureProvider 检索请求所用语言,随后我们能够借助 context.Features.Get<IRequestCultureFeature>(); 来拿到到对应的语言,简化我们设计多语言框架的代码。

从实现思路看,ASP.NET Core 定义了一个 IRequestCultureProvider 接口,用来解析客户端请求时携带的的区域性信息,从当前请求中解析出所用的语言,ASP.NET Core 本身有三个类型实现了该接口,所以相当于自带了三种拿到当前请求语言的方式,下面我们来了解这三种方式是如何借助请求上下文解析语言标识。

结合项目来看,第一种是 URL 路由参数,能够借助 QueryStringRequestCultureProvider 类型解析出来,需 url 中携带两个参数 cultureui-culture,其格式示比如下所示:

?culture=en-US&ui-culture=en-US

在这个场景下,第二种是 Cookie,提供器是 CookieRequestCultureProvider,cookie 中需添加名为 .AspNetCore.Culture 的 cookie,其格式示比如下所示:

c=en-US|uic=en-US

示例:

.AspNetCore.Culture=c=en-US|uic=en-US

在这个场景下,第三种是借助 Header 设置,也是最常用的设置方法,提供器是 AcceptLanguageHeaderRequestCultureProvider,其格式示比如下所示:

Accept-Language: zh-CN,zh;q=0.9

在这个场景下,当然,开发者能够根据需求,修改这三者的设置,以便采用其他请求位置或不同的参数名称解析出当前请求的文化名称。

new QueryStringRequestCultureProvider()
{
    QueryStringKey = "lan",
    UIQueryStringKey = "ui"
}

在这个场景下,由于 ASP.NET Core 会自动解析出请求语言,所以我们只需从 IRequestCultureFeature 服务中取得语言信息即可,不需自行解析。

var requestCultureFeature = context.Features.Get<IRequestCultureFeature>();
var requestCulture = requestCultureFeature?.RequestCulture;

落到代码里,客户端请求时,ASP.NET Core 会自动从 RequestLocalizationOptions 中取出 IRequestCultureProvider 服务列表,随后逐个调用,直到能够确定用户请求的语言信息。 ASP.NET Core 默认会按顺序执行 QueryStringRequestCultureProvider、CookieRequestCultureProvider、AcceptLanguageHeaderRequestCultureProvider 三个提供器,如果前者解析找不到对应的参数,则会采用下一个 IRequestCultureProvider 解析,如果默认三个提供器都解析不出来,则会调用用户自定义的服务,如果能够获得结果,则不会再调用其它的提供器。当然也能够自行修改以上组件的顺序,但是这里不再赘述。

落到代码里,要实现一个IRequestCultureProvider 很轻松,比如我们要求在 url 中采用 c、uic 两个参数携带多语言信息,其示例如下所示:

// 自定义请求语言提供器
    // 或直接继承 RequestCultureProvider
    public class I18nRequestCultureProvider : IRequestCultureProvider
    {
        private readonly string _defaultLanguage;
        public I18nRequestCultureProvider(string defaultLanguage)
        {
            _defaultLanguage = defaultLanguage;
        }
        private const string RouteValueKey = "c";
        private const string UIRouteValueKey = "uic";
        public override Task<ProviderCultureResult> DetermineProviderCultureResult(HttpContext httpContext)
        {
            var request = httpContext.Request;
            if (!request.RouteValues.Any())
            {
                return NullProviderCultureResult;
            }
            string? queryCulture = null;
            string? queryUICulture = null;
            // 从路由中解析
            if (!string.IsNullOrWhiteSpace(RouteValueKey))
            {
                queryCulture = request.RouteValues[RouteValueKey]?.ToString();
            }
            // 其他过程省略
            var providerResultCulture = new ProviderCultureResult(queryCulture, queryUICulture);
            return Task.FromResult<ProviderCultureResult?>(providerResultCulture);
        }
    }

结合项目来看,开发者需根据 HttpContext 中的请求参数解析出当前请求采用的语言,如果解析不出来,则应该得到 NullProviderCultureResult,框架会继续采用下一个IRequestCultureProvider 解析请求语言。如果找到了请求语言,则需得到 ProviderCultureResult。

在这个场景下,需留意的是,IRequestCultureProvider 接口的服务是不能借助容器注入的,而是在 RequestLocalizationOptions 中设置。

            services.Configure<RequestLocalizationOptions>(options =>
            {
                // 默认自带了三个请求语言提供器,会先从这些提供器识别要使用的语言。
                // QueryStringRequestCultureProvider
                // CookieRequestCultureProvider
                // AcceptLanguageHeaderRequestCultureProvider
                // 自定义请求请求语言提供器
                options.RequestCultureProviders.Add(new I18nRequestCultureProvider(defaultLanguage));
            });

从实现思路看,若你想调整提供器的顺序,只需修改 options.RequestCultureProviders 中的 IRequestCultureProvider 集合即可。

实现 i18n 框架

落到代码里,在本节里,将会介绍如何设计和编写一个 i18n 框架,框架的全部代码如下所示所示。

部分文件说明如下所示:

// 当前程序多语言上下文
I18nContext.cs
// 多语言资源接口定义
I18nResource.cs
// i18n 语言资源工厂
I18nResourceFactory.cs
// 设置当前语言作用域
I18nScope.cs
// 服务注入扩展
I18nExtensions.cs
// 从 json 文件读取多语言资源扩展
JsonResourceExtensions.cs
// 实现 I18nResourceFactory
InternalI18nResourceFactory.cs
// 自定义请求语言解析
I18nRequestCultureProvider.cs
// 实现 IStringLocalizer 接口
I18nStringLocalizer.cs
// 实现 IStringLocalizer<T> 接口
I18nStringLocalizer`.cs
// 实现 I18nResource,通过 json 文件导入语言资源
JsonResource.cs
// 解析 json 的帮助类
ReadJsonHelper.cs

抽象接口

在这个场景下,设计多语言框架,首先将框架划分为三个角色,即采用者、框架自身、多语言提供者,采用者借助抽象接口拿到 key 对应语言的值,多语言提供者借助抽象接口提供多语言键值对数据。所以,抽象接口主要是面向采用者和多语言提供者设计,框架自身则是为采用者和提供者架设一个桥梁,此外还需定义一些上下文类、模型类,以便传递信息。

实际处理时,首先思考以何种方式保存多语言资源数据,例如嵌入程序集、在项目里携带 json 文件、存储在 redis 中等,i18n 框架不需关心多语言存储在哪里,只需借助接口加载出来即可。

落到代码里,定义一个 I18nResource 接口,i18n 框架借助该接口加载多语言数据。

/// <summary>
/// i18n 语言资源.
/// </summary>
/// <remarks>每个 I18nResource 对应一种语言的一个资源文件.</remarks>
public interface I18nResource
{
    /// <summary>
    /// 该资源提供的语言.
    /// </summary>
    CultureInfo SupportedCulture { get; }
    /// <summary>
    /// 获取具有给定名称的字符串资源.
    /// </summary>
    /// <param name="culture">语言名字.</param>
    /// <param name="name">字符串名称.</param>
    /// <returns><see cref="LocalizedString"/>.</returns>
    LocalizedString Get(string culture, string name);
    /// <summary>
    /// 获取具有给定名称的字符串资源.
    /// </summary>
    /// <param name="culture">语言名字.</param>
    /// <param name="name">字符串名称.</param>
    /// <param name="arguments">字符串插值参数.</param>
    /// <returns><see cref="LocalizedString"/>.</returns>
    LocalizedString Get(string culture, string name, params object[] arguments);
    /// <summary>
    /// 从 i18n 资源文件中获取所有字符串.
    /// </summary>
    /// <param name="includeParentCultures"></param>
    /// <returns><see cref="LocalizedString"/>.</returns>
    public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures);
}
/// <summary>
/// i18n 语言资源.
/// </summary>
/// <remarks>每个 I18nResource 对应一种语言的一个资源文件.</remarks>
/// <typeparam name="T">类型.</typeparam>
public interface I18nResource<T> : I18nResource
{
}

从实现思路看,随后新建多语言管理工厂,管理 I18nResource 列表,兼容从容器中取出多语言资源文件。

/// <summary>
/// I18n 资源工厂.
/// </summary>
public interface I18nResourceFactory
{
    /// <summary>
    /// 当前支持的语言.
    /// </summary>
    IList<CultureInfo> SupportedCultures { get; }
    /// <summary>
    /// 所有资源提供器.
    /// </summary>
    IList<I18nResource> Resources { get; }
    /// <summary>
    /// 在容器中的资源服务.
    /// </summary>
    IList<Type> ServiceResources { get; }
    /// <summary>
    /// 添加 i18n 语言资源,该类型将会被从容器中取出.
    /// </summary>
    /// <param name="resourceType">i18n 语言资源.</param>
    /// <returns><see cref="I18nResourceFactory"/>.</returns>
    I18nResourceFactory AddServiceType(Type resourceType);
    /// <summary>
    /// 添加 i18n 语言资源.
    /// </summary>
    /// <param name="resource">i18n 语言资源.</param>
    /// <returns><see cref="I18nResourceFactory"/>.</returns>
    I18nResourceFactory Add(I18nResource resource);
    /// <summary>
    /// 添加 i18n 语言资源.
    /// </summary>
    /// <typeparam name="T">类型.</typeparam>
    /// <param name="resource">i18n 语言资源.</param>
    /// <returns><see cref="I18nResourceFactory"/>.</returns>
    I18nResourceFactory Add<T>(I18nResource<T> resource);
}

下面是设计采用者接口的抽象。

从实现思路看,ASP.NET Core 借助 IRequestCultureProvider 检索出来的当前请求语言,为了简化解析语言标识的代码,定义一个I18nContext 类型,用来存储从请求上下下文解析处理的多语言标识,i18n 框架中下游服务能够借助 I18nContext 拿到当前请求语言。

// 记录当前请求的 i18n 信息
public class I18nContext
{
    // 当前用户请求的语言
    public CultureInfo Culture { get; internal set; } = CultureInfo.CurrentCulture;
}

IStringLocalizerIStringLocalizer<T> 是 ASP.NET Core 中多语言服务的接口,采用者能够从这两个接口中查询多语言字符串,我们实现两个对应的服务,从 I18nResource 集合中查找出对应字符串的值。

/// <summary>
/// 表示提供本地化字符串的服务.
/// </summary>
public class I18nStringLocalizer : IStringLocalizer
{
    private readonly IServiceProvider _serviceProvider;
    private readonly I18nContext _context;
    private readonly I18nResourceFactory _resourceFactory;
    /// <summary>
    /// Initializes a new instance of the <see cref="I18nStringLocalizer"/> class.
    /// </summary>
    /// <param name="context"></param>
    /// <param name="resourceFactory"></param>
    /// <param name="serviceProvider"></param>
    public I18nStringLocalizer(I18nContext context, I18nResourceFactory resourceFactory, IServiceProvider serviceProvider)
    {
        _context = context;
        _resourceFactory = resourceFactory;
        _serviceProvider = serviceProvider;
    }
    /// <inheritdoc/>
    public LocalizedString this[string name] => Find(name);
    /// <inheritdoc/>
    public LocalizedString this[string name, params object[] arguments] => Find(name, arguments);
    /// <inheritdoc/>
    public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
    {
        foreach (var serviceType in _resourceFactory.ServiceResources)
        {
            var resource = _serviceProvider.GetRequiredService(serviceType) as I18nResource;
            if (resource == null)
            {
                continue;
            }
            foreach (var item in resource.GetAllStrings(includeParentCultures))
            {
                yield return item;
            }
        }
        foreach (var resource in _resourceFactory.Resources)
        {
            foreach (var item in resource.GetAllStrings(includeParentCultures))
            {
                yield return item;
            }
        }
    }
    private LocalizedString Find(string name)
    {
        foreach (var serviceType in _resourceFactory.ServiceResources)
        {
            var resource = _serviceProvider.GetRequiredService(serviceType) as I18nResource;
            if (resource == null)
            {
                continue;
            }
            if (_context.Culture.Name != resource.SupportedCulture.Name)
            {
                continue;
            }
            var result = resource.Get(_context.Culture.Name, name);
            if (result == null || result.ResourceNotFound)
            {
                continue;
            }
            return result;
        }
        foreach (var resource in _resourceFactory.Resources)
        {
            if (_context.Culture.Name != resource.SupportedCulture.Name)
            {
                continue;
            }
            var result = resource.Get(_context.Culture.Name, name);
            if (result == null || result.ResourceNotFound)
            {
                continue;
            }
            return result;
        }
        // 所有的资源都查找不到时,使用默认值
        return new LocalizedString(name, name);
    }
    private LocalizedString Find(string name, params object[] arguments)
    {
        foreach (var serviceType in _resourceFactory.ServiceResources)
        {
            var resource = _serviceProvider.GetRequiredService(serviceType) as I18nResource;
            if (resource == null)
            {
                continue;
            }
            if (_context.Culture.Name != resource.SupportedCulture.Name)
            {
                continue;
            }
            var result = resource.Get(_context.Culture.Name, name, arguments);
            if (result == null || result.ResourceNotFound)
            {
                continue;
            }
            return result;
        }
        foreach (var resource in _resourceFactory.Resources)
        {
            if (_context.Culture.Name != resource.SupportedCulture.Name)
            {
                continue;
            }
            var result = resource.Get(_context.Culture.Name, name, arguments);
            if (result == null || result.ResourceNotFound)
            {
                continue;
            }
            return result;
        }
        // 所有的资源都查找不到时,使用默认值
        return new LocalizedString(name, string.Format(name, arguments));
    }
}
/// <summary>
/// 表示提供本地化字符串的服务.
/// </summary>
/// <typeparam name="T">类型.</typeparam>
public class I18nStringLocalizer<T> : IStringLocalizer<T>
{
    private readonly IServiceProvider _serviceProvider;
    private readonly I18nContext _context;
    private readonly I18nResourceFactory _resourceFactory;
    /// <summary>
    /// Initializes a new instance of the <see cref="I18nStringLocalizer{T}"/> class.
    /// </summary>
    /// <param name="context"></param>
    /// <param name="resourceFactory"></param>
    /// <param name="serviceProvider"></param>
    public I18nStringLocalizer(I18nContext context, I18nResourceFactory resourceFactory, IServiceProvider serviceProvider)
    {
        _context = context;
        _resourceFactory = resourceFactory;
        _serviceProvider = serviceProvider;
    }
    /// <inheritdoc/>
    public LocalizedString this[string name] => Find(name);
    /// <inheritdoc/>
    public LocalizedString this[string name, params object[] arguments] => Find(name, arguments);
    /// <inheritdoc/>
    public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
    {
        foreach (var serviceType in _resourceFactory.ServiceResources)
        {
            var resource = _serviceProvider.GetRequiredService(serviceType) as I18nResource;
            if (resource == null)
            {
                continue;
            }
            foreach (var item in resource.GetAllStrings(includeParentCultures))
            {
                yield return item;
            }
        }
        foreach (var resource in _resourceFactory.Resources)
        {
            foreach (var item in resource.GetAllStrings(includeParentCultures))
            {
                yield return item;
            }
        }
    }
    private LocalizedString Find(string name)
    {
        var resourceType = typeof(I18nResource<T>);
        foreach (var serviceType in _resourceFactory.ServiceResources)
        {
            if (!serviceType.IsGenericType && serviceType.GenericTypeArguments[0].Assembly != typeof(T).Assembly)
            {
                continue;
            }
            var resource = _serviceProvider.GetRequiredService(serviceType) as I18nResource;
            if (resource == null)
            {
                continue;
            }
            if (_context.Culture.Name != resource.SupportedCulture.Name)
            {
                continue;
            }
            var result = resource.Get(_context.Culture.Name, name);
            if (result == null || result.ResourceNotFound)
            {
                continue;
            }
            return result;
        }
        foreach (var resource in _resourceFactory.Resources)
        {
            if (_context.Culture.Name != resource.SupportedCulture.Name)
            {
                continue;
            }
            // I18nResource<T>
            if (!resource.GetType().IsGenericType || resource.GetType().GenericTypeArguments[0].Assembly != typeof(T).Assembly)
            {
                continue;
            }
            var result = resource.Get(_context.Culture.Name, name);
            if (result == null || result.ResourceNotFound)
            {
                continue;
            }
            return result;
        }
        // 所有的资源都查找不到时,使用默认值
        return new LocalizedString(name, name);
    }
    private LocalizedString Find(string name, params object[] arguments)
    {
        var resourceType = typeof(I18nResource<T>);
        foreach (var serviceType in _resourceFactory.ServiceResources)
        {
            if (!serviceType.IsGenericType && serviceType.GenericTypeArguments[0].Assembly != typeof(T).Assembly)
            {
                continue;
            }
            var resource = _serviceProvider.GetRequiredService(serviceType) as I18nResource;
            if (resource == null)
            {
                continue;
            }
            if (_context.Culture.Name != resource.SupportedCulture.Name)
            {
                continue;
            }
            var result = resource.Get(_context.Culture.Name, name, arguments);
            if (result == null || result.ResourceNotFound)
            {
                continue;
            }
            return result;
        }
        foreach (var resource in _resourceFactory.Resources)
        {
            if (_context.Culture.Name != resource.SupportedCulture.Name)
            {
                continue;
            }
            // I18nResource<T>
            if (!resource.GetType().IsGenericType || resource.GetType().GenericTypeArguments[0].Assembly != typeof(T).Assembly)
            {
                continue;
            }
            var result = resource.Get(_context.Culture.Name, name, arguments);
            if (result == null || result.ResourceNotFound)
            {
                continue;
            }
            return result;
        }
        // 所有的资源都查找不到时,使用默认值
        return new LocalizedString(name, string.Format(name, arguments));
    }
}

落到代码里,CultureInfoScope 的作用很轻松,在其作用域之内修改 CultureInfo.CurrentCulture 的值。

/// <summary>
/// i18n 作用域.
/// </summary>
public class I18nScope : IDisposable
{
    private readonly CultureInfo _defaultCultureInfo;
    /// <summary>
    /// Initializes a new instance of the <see cref="I18nScope"/> class.
    /// </summary>
    /// <param name="language"></param>
    public I18nScope(string language)
    {
        _defaultCultureInfo = CultureInfo.CurrentCulture;
        CultureInfo.CurrentCulture = CultureInfo.CreateSpecificCulture(language);
    }
    /// <inheritdoc/>
    public void Dispose()
    {
        CultureInfo.CurrentCulture = _defaultCultureInfo;
    }
}

结合项目来看,至此,我们已经设计好 i18n 框架的抽象了,下面我们会进一步实现 i18n 框架。

实现从 json 读取语言资源

实际处理时,Maomi.I18n 本身实现了一个从 json 文件读取多语言资源包的 I18nResource 服务,而无论从哪里读取的多语言资源,大部分都能够以 kv 的形式存储到内存中,所以,讲解开发者如何实现一个 DictionaryResource 服务存储从不同地方读取到的多语言资源内容。

落到代码里,为便于保证每个项目都能够携带自己的语言信息,我们能够要求项目下面新建 i18n 目录,随后新建与当前项目同名的子目录,在子目录下存储自己的语言文件。

实际处理时,这样做的好处时,当编译项目时,主项目下的 i18n 会收集到所有项目的语言文件,而且不会发生冲突。而且当我们采用 nuget 打包项目时,nuget 包还会携带这些文件,采用这个拉取 nuget 包后也能够采用到这些多语言文件。

/// <summary>
/// 字典存储多语言文件资源.
/// </summary>
public class DictionaryResource : I18nResource
{
    /// <inheritdoc/>
    public CultureInfo SupportedCulture => _cultureInfo;
    private readonly CultureInfo _cultureInfo;
    private readonly IReadOnlyDictionary<string, LocalizedString> _kvs;
    /// <summary>
    /// Initializes a new instance of the <see cref="DictionaryResource"/> class.
    /// </summary>
    /// <param name="cultureInfo"></param>
    /// <param name="kvs"></param>
    public DictionaryResource(CultureInfo cultureInfo, IReadOnlyDictionary<string, object> kvs)
    {
        _cultureInfo = cultureInfo;
        _kvs = kvs.ToDictionary(x => x.Key, x => new LocalizedString(x.Key, x.Value.ToString()!));
    }
    /// <inheritdoc/>
    public virtual LocalizedString Get(string culture, string name)
    {
        if (culture != _cultureInfo.Name)
        {
            return new LocalizedString(name, name, resourceNotFound: true);
        }
        var value = _kvs.GetValueOrDefault(name);
        if (value == null)
        {
            return new LocalizedString(name, name, resourceNotFound: true);
        }
        return value;
    }
    /// <inheritdoc/>
    public virtual LocalizedString Get(string culture, string name, params object[] arguments)
    {
        if (culture != _cultureInfo.Name)
        {
            return new LocalizedString(name, name, resourceNotFound: true);
        }
        var value = _kvs.GetValueOrDefault(name);
        if (value == null)
        {
            return new LocalizedString(name, name, resourceNotFound: true);
        }
        return new LocalizedString(name, string.Format(value, arguments));
    }
    /// <inheritdoc/>
    public virtual IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
    {
        return _kvs.Values;
    }
}
/// <summary>
/// 字典存储多语言文件资源.
/// </summary>
/// <typeparam name="TResource">类型.</typeparam>
public class DictionaryResource<TResource> : DictionaryResource, I18nResource<TResource>
{
    private readonly Assembly _assembly;
    /// <summary>
    /// Initializes a new instance of the <see cref="DictionaryResource{TResource}"/> class.
    /// </summary>
    /// <param name="cultureInfo"></param>
    /// <param name="kvs"></param>
    /// <param name="assembly"></param>
    public DictionaryResource(CultureInfo cultureInfo, IReadOnlyDictionary<string, object> kvs, Assembly assembly)
        : base(cultureInfo, kvs)
    {
        _assembly = assembly;
    }
    /// <inheritdoc/>
    public override LocalizedString Get(string culture, string name)
    {
        if (typeof(TResource).Assembly != _assembly)
        {
            return new LocalizedString(name, name, resourceNotFound: true);
        }
        return base.Get(culture, name);
    }
    /// <inheritdoc/>
    public override LocalizedString Get(string culture, string name, params object[] arguments)
    {
        if (typeof(TResource).Assembly != _assembly)
        {
            return new LocalizedString(name, name, resourceNotFound: true);
        }
        return base.Get(culture, name, arguments);
    }
}

从实现思路看,编写扩展方法注入 json 语言资源,该扩展方法只会加载与 T 类型所在程序集的相同命名目录下的 json 文件。

/// <summary>
/// Json 多语言文件资源.
/// </summary>
public static class JsonResourceExtensions
{
    /// <summary>
    /// 扫描目录下的所有子目录,自动区配对应的项目/程序集下,json 文件名称会被动作语言名称.
    /// </summary>
    /// <param name="resourceFactory"></param>
    /// <param name="basePath"></param>
    /// <returns><see cref="I18nResourceFactory"/>.</returns>
    public static I18nResourceFactory ParseDirectory(
        this I18nResourceFactory resourceFactory,
        string basePath)
    {
        var basePathDirectoryInfo = new DirectoryInfo(basePath);
        Queue<DirectoryInfo> directoryInfos = new Queue<DirectoryInfo>();
        var assemblies = AppDomain.CurrentDomain.GetAssemblies();
        var basePathFullName = basePathDirectoryInfo.FullName;
        directoryInfos.Enqueue(basePathDirectoryInfo);
        while (directoryInfos.Count > 0)
        {
            var curDirectory = directoryInfos.Dequeue();
            var lanDir = curDirectory.GetDirectories();
            foreach (var lan in lanDir)
            {
                directoryInfos.Enqueue(lan);
            }
            var files = curDirectory.GetFiles().Where(x => x.Name.EndsWith(".json")).ToArray();
            if (files.Length == 0)
            {
                continue;
            }
            // 移除路径的前部分
            var curPath = curDirectory.FullName[basePathFullName.Length..].Trim('/', '\');
            var assembly = assemblies.FirstOrDefault(x => string.Equals(curPath, x.GetName().Name, StringComparison.CurrentCultureIgnoreCase));
            if (assembly == null)
            {
                continue;
            }
            foreach (var file in files)
            {
                var language = Path.GetFileNameWithoutExtension(file.Name);
                var text = File.ReadAllText(file.FullName);
                var dic = ReadJsonHelper.Read(new ReadOnlySequence<byte>(Encoding.UTF8.GetBytes(text)), new JsonReaderOptions { AllowTrailingCommas = true });
                DictionaryResource jsonResource = (Activator.CreateInstance(
                    typeof(DictionaryResource<>).MakeGenericType(assembly.GetTypes()[0]),
                    new object[] { new CultureInfo(language), dic, assembly }) as DictionaryResource)!;
                resourceFactory.Add(jsonResource);
            }
        }
        return resourceFactory;
    }
    /// <summary>
    /// 添加 json 文件资源,json 文件名称会被当作语言名称.
    /// </summary>
    /// <param name="resourceFactory"></param>
    /// <param name="basePath">基础路径.</param>
    /// <returns><see cref="I18nResourceFactory"/>.</returns>
    public static I18nResourceFactory AddJsonDirectory(
        this I18nResourceFactory resourceFactory,
        string basePath)
    {
        var rootDir = new DirectoryInfo(basePath);
        var files = rootDir.GetFiles().Where(x => x.Name.EndsWith(".json"));
        foreach (var file in files)
        {
            var language = Path.GetFileNameWithoutExtension(file.Name);
            var text = File.ReadAllText(file.FullName);
            var dic = ReadJsonHelper.Read(new ReadOnlySequence<byte>(Encoding.UTF8.GetBytes(text)), new JsonReaderOptions { AllowTrailingCommas = true });
            DictionaryResource jsonResource = new DictionaryResource(new CultureInfo(language), dic);
            resourceFactory.Add(jsonResource);
        }
        return resourceFactory;
    }
    /// <summary>
    /// 添加 json 文件资源,将目录下的所有 json 文件都归类到此程序集下,json 文件名称会被当作语言名称.
    /// </summary>
    /// <typeparam name="T">类型.</typeparam>
    /// <param name="resourceFactory"></param>
    /// <param name="basePath">基础路径.</param>
    /// <returns><see cref="I18nResourceFactory"/>.</returns>
    public static I18nResourceFactory AddJsonDirectory<T>(
        this I18nResourceFactory resourceFactory,
        string basePath)
        where T : class
    {
        var rootDir = new DirectoryInfo(basePath);
        var files = rootDir.GetFiles().Where(x => x.Name.EndsWith(".json"));
        foreach (var file in files)
        {
            var language = Path.GetFileNameWithoutExtension(file.Name);
            var text = File.ReadAllText(file.FullName);
            var dic = ReadJsonHelper.Read(new ReadOnlySequence<byte>(Encoding.UTF8.GetBytes(text)), new JsonReaderOptions { AllowTrailingCommas = true });
            DictionaryResource<T> jsonResource = new DictionaryResource<T>(new CultureInfo(language), dic, typeof(T).Assembly);
            resourceFactory.Add(jsonResource);
        }
        return resourceFactory;
    }
    /// <summary>
    /// 添加 json 文件资源.
    /// </summary>
    /// <param name="resourceFactory"></param>
    /// <param name="language">语言.</param>
    /// <param name="jsonFile">json 文件路径.</param>
    /// <returns><see cref="I18nResourceFactory"/>.</returns>
    public static I18nResourceFactory AddJsonFile(this I18nResourceFactory resourceFactory, string language, string jsonFile)
    {
        string s = File.ReadAllText(jsonFile);
        Dictionary<string, object> kvs = ReadJsonHelper.Read(new ReadOnlySequence<byte>(Encoding.UTF8.GetBytes(s)), new JsonReaderOptions
        {
            AllowTrailingCommas = true
        });
        DictionaryResource resource = new DictionaryResource(new CultureInfo(language), kvs);
        resourceFactory.Add(resource);
        return resourceFactory;
    }
    /// <summary>
    /// 添加 json 文件资源.
    /// </summary>
    /// <typeparam name="T">类型.</typeparam>
    /// <param name="resourceFactory"></param>
    /// <param name="language">语言.</param>
    /// <param name="jsonFile">json 文件路径.</param>
    /// <returns><see cref="I18nResourceFactory"/>.</returns>
    public static I18nResourceFactory AddJsonFile<T>(this I18nResourceFactory resourceFactory, string language, string jsonFile)
        where T : class
    {
        string s = File.ReadAllText(jsonFile);
        Dictionary<string, object> kvs = ReadJsonHelper.Read(new ReadOnlySequence<byte>(Encoding.UTF8.GetBytes(s)), new JsonReaderOptions
        {
            AllowTrailingCommas = true
        });
        DictionaryResource<T> resource = new DictionaryResource<T>(new CultureInfo(language), kvs, typeof(T).Assembly);
        resourceFactory.Add(resource);
        return resourceFactory;
    }
}

理解这一步时,中间件准备完毕之后,我们开始写管理 I18nResourceFactory 接口的实现,以便管理好各种语言资源服务。

/// <summary>
/// i18n 语言资源管理器.
/// </summary>
public class InternalI18nResourceFactory : I18nResourceFactory
{
    private readonly List<CultureInfo> _supportedCultures;
    private readonly List<I18nResource> _resources;
    private readonly List<Type> _serviceResources;
    /// <summary>
    /// Initializes a new instance of the <see cref="InternalI18nResourceFactory"/> class.
    /// </summary>
    public InternalI18nResourceFactory()
    {
        _supportedCultures = new();
        _resources = new();
        _serviceResources = new();
    }
    /// <inheritdoc/>
    public IList<CultureInfo> SupportedCultures => _supportedCultures;
    /// <inheritdoc/>
    public IList<I18nResource> Resources => _resources;
    /// <inheritdoc/>
    public IList<Type> ServiceResources => _serviceResources;
    /// <inheritdoc/>
    public I18nResourceFactory Add(I18nResource resource)
    {
        _supportedCultures.Add(resource.SupportedCulture);
        _resources.Add(resource);
        return this;
    }
    /// <inheritdoc/>
    public I18nResourceFactory Add<T>(I18nResource<T> resource)
    {
        _supportedCultures.Add(resource.SupportedCulture);
        _resources.Add(resource);
        return this;
    }
    /// <inheritdoc/>
    public I18nResourceFactory AddServiceType(Type resourceType)
    {
        _serviceResources.Add(resourceType);
        return this;
    }
}

理解这一步时,实现 IStringLocalizerFactory 接口,根据泛型类型新建 IStringLocalizer 对象。

/// <summary>
/// 表示创建<see cref="IStringLocalizer"/> 实例的工厂.
/// </summary>
public class I18nStringLocalizerFactory : IStringLocalizerFactory
{
    private readonly I18nResourceFactory _i18nResourceFactory;
    private readonly IServiceProvider _serviceProvider;
    /// <summary>
    /// Initializes a new instance of the <see cref="I18nStringLocalizerFactory"/> class.
    /// </summary>
    /// <param name="i18nResourceFactory"></param>
    /// <param name="serviceProvider"></param>
    public I18nStringLocalizerFactory(I18nResourceFactory i18nResourceFactory, IServiceProvider serviceProvider)
    {
        _i18nResourceFactory = i18nResourceFactory;
        _serviceProvider = serviceProvider;
    }
    /// <inheritdoc/>
    public IStringLocalizer Create(Type resourceSource)
    {
        var type = typeof(I18nStringLocalizer<>).MakeGenericType(resourceSource);
        return (_serviceProvider.GetRequiredService(type) as IStringLocalizer)!;
    }
    /// <inheritdoc/>
    public IStringLocalizer Create(string baseName, string location)
    {
        return _serviceProvider.GetRequiredService<IStringLocalizer>();
    }
}

从实现思路看,最后在 I18nExtensions 中添加 AddI18n 扩展方法注入相关的服务。

/// <summary>
/// i18n 扩展.
/// </summary>
public static class I18nExtensions
{
    /// <summary>
    /// 添加 i18n 支持服务.
    /// </summary>
    /// <param name="services"></param>
    /// <param name="defaultLanguage">默认语言.</param>
    public static void AddI18n(this IServiceCollection services, string defaultLanguage = "zh-CN")
    {
        InternalI18nResourceFactory resourceFactory = new InternalI18nResourceFactory();
        // i18n 上下文
        services.AddScoped<I18nContext, DefaultI18nContext>();
        // 注入 i18n 服务
        services.AddSingleton<I18nResourceFactory>(s => resourceFactory);
        services.AddScoped<IStringLocalizerFactory, I18nStringLocalizerFactory>();
        services.AddScoped<IStringLocalizer, I18nStringLocalizer>();
        services.TryAddEnumerable(new ServiceDescriptor(typeof(IStringLocalizer<>), typeof(I18nStringLocalizer<>), ServiceLifetime.Scoped));
    }
    /// <summary>
    /// 添加 i18n 资源.
    /// </summary>
    /// <param name="services"></param>
    /// <param name="resourceFactory"></param>
    public static void AddI18nResource(this IServiceCollection services, Action<I18nResourceFactory> resourceFactory)
    {
        var service = services.BuildServiceProvider().GetRequiredService<I18nResourceFactory>();
        resourceFactory.Invoke(service);
    }
}

对于 ASP.NET Core 应用,则需额外添加一些扩展函数:

/// <summary>
/// i18n 扩展.
/// </summary>
public static class I18nExtensions
{
    /// <summary>
    /// 添加 i18n 支持服务.
    /// </summary>
    /// <param name="services"></param>
    /// <param name="defaultLanguage">默认语言.</param>
    public static void AddI18nAspNetCore(this IServiceCollection services, string defaultLanguage = "zh-CN")
    {
        services.AddI18n(defaultLanguage);
        var resourceFactory = services.BuildServiceProvider().GetRequiredService<I18nResourceFactory>();
        // ASP.NET Core 自带的
        services.AddLocalization();
        // 配置 ASP.NET Core 的本地化服务
        services.Configure<RequestLocalizationOptions>(options =>
        {
            options.ApplyCurrentCultureToResponseHeaders = true;
            options.DefaultRequestCulture = new RequestCulture(culture: defaultLanguage, uiCulture: defaultLanguage);
            options.SupportedCultures = resourceFactory.SupportedCultures;
            options.SupportedUICultures = resourceFactory.SupportedCultures;
            // 默认自带了三个请求语言提供器,会先从这些提供器识别要使用的语言。
            // QueryStringRequestCultureProvider
            // CookieRequestCultureProvider
            // AcceptLanguageHeaderRequestCultureProvider
            // 自定义请求请求语言提供器
            options.RequestCultureProviders.Add(new InternalRequestCultureProvider(options));
        });
        // i18n 上下文
        services.AddScoped<I18nContext, HttpI18nContext>();
    }
    /// <summary>
    /// i18n 中间件.
    /// </summary>
    /// <param name="app"></param>
    public static void UseI18n(this IApplicationBuilder app)
    {
        var options = app.ApplicationServices.GetRequiredService<IOptions<RequestLocalizationOptions>>();
        app.UseRequestLocalization(options.Value);
    }
}
/// <summary>
/// 模型验证使用多语言.
/// </summary>
public static partial class DataAnnotationsExtensions
{
    /// <summary>
    /// 为 API 模型验证注入 i18n 服务.
    /// </summary>
    /// <param name="builder"></param>
    /// <returns><see cref="IMvcBuilder"/>.</returns>
    public static IMvcBuilder AddI18nDataAnnotation(this IMvcBuilder builder)
    {
        builder
            .AddDataAnnotationsLocalization(options =>
            {
                options.DataAnnotationLocalizerProvider = (modelType, stringLocalizerFactory) =>
                stringLocalizerFactory.Create(modelType);
            });
        return builder;
    }
}

在这个场景下,在本章里,讲解了如何编写一个全球化语言的类库,其实现比较轻松,也许同时不满足业务系统的需求,那么你能够根据本章的内容,结合业务系统的需求,实现一个更好用的 i18n 类库。

单元测试

结合项目来看,编写单元测试和性能测试是开发者需掌握的技能之一,在本书中的第四章介绍了性能测试的编写示例,在本章中继续介绍单元测试的编写方法,读者还能够从从 Maomi 仓库源码中了解看到更多的单元测试示例。

新建一个 xUnit 单元测试项目,项目文件结构如下所示:

在这个场景下,添加对 Maomi.I18n、Microsoft.AspNetCore.Mvc.Testing 两个类库的引用。

在 I18nTest 中新建一个测试方法:

[Fact]
public async Task I18n_Request()
{
}

从实现思路看,首先构建一个用来测试的 Web Host,同时且注入相关的服务,用来模拟启动 Web 服务。

using var host = await new HostBuilder()
    .ConfigureWebHost(webBuilder =>
    {
        webBuilder
            .UseTestServer()
            .ConfigureServices(services =>
            {
                services.AddControllers();
                services.AddI18n(defaultLanguage: "zh-CN");
                services.AddI18nResource(option =>
                {
                    var basePath = "i18n";
                    option.AddJson(basePath);
                });
            })
            .Configure(app =>
            {
                app.UseI18n();
                app.UseRouting();
                app.Use(async (HttpContext context, RequestDelegate next) =>
                {
                    var localizer = context.RequestServices.GetRequiredService<IStringLocalizer<Program>>();
                    await context.Response.WriteAsync(localizer["购物车:商品名称"]);
                    return;
                });
            });
    })
    .StartAsync();

结合项目来看,由于该 Host 不会真的启动一个 Web 服务,所以无法直接发起 HttpClient 请求进行测试,需从 Host 中新建一个 HttpClient 对象:

var httpClient = host.GetTestClient();

下面给出分别测试路由、Cookie、Accept-Language 标头的三种解析语言标识测试 i18n 框架的示例:

httpClient.DefaultRequestHeaders.AcceptLanguage.Clear();
var response = await httpClient.GetStringAsync("/test?culture=en-US&ui-culture=en-US");
Assert.Equal("Product name", response);
response = await httpClient.GetStringAsync("/test?culture=zh-CN&ui-culture=zh-CN");
Assert.Equal("商品名称", response);
httpClient.DefaultRequestHeaders.Add("Cookie", ".AspNetCore.Culture=c=en-US|uic=en-US");
response = await httpClient.GetStringAsync("/test");
Assert.Equal("Product name", response);
httpClient.DefaultRequestHeaders.Add("Cookie", ".AspNetCore.Culture=c=zh-CN|uic=zh-CN");
response = await httpClient.GetStringAsync("/test");
Assert.Equal("商品名称", response);
httpClient.DefaultRequestHeaders.Remove("Cookie");
httpClient.DefaultRequestHeaders.AcceptLanguage.Clear();
httpClient.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("zh-CN"));
httpClient.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("zh", 0.9));
response = await httpClient.GetStringAsync("/test");
Assert.Equal("商品名称", response);
httpClient.DefaultRequestHeaders.AcceptLanguage.Clear();
httpClient.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("en-US"));
httpClient.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("en", 0.9));
response = await httpClient.GetStringAsync("/test");
Assert.Equal("Product name", response);
httpClient.DefaultRequestHeaders.AcceptLanguage.Clear();
httpClient.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("sv"));
httpClient.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("en-US", 0.9));
response = await httpClient.GetStringAsync("/test");
Assert.Equal("Product name", response);

基于 Redis 的动态多语言

在这个场景下,本节示例在 Maomi.I18n.Redis 中,该扩展库借助实现 I18nResource 接口,从 Redis 中加载多语言数据缓存到本地客户端中,当 Redis 中的数据变化时,客户端自动拉取最新的值。由于数据会被缓存到本地,所以该扩展库能够实时更新多语言数据,同时保持高性能。

新建一个 Maomi.I18n.Redis 项目,引用 Maomi.I18n 库 和 FreeRedis 库,新建 RedisI18nResource 类实现 I18nResource 接口,从 Redis 中读取值。

// i18n redis 资源
public class RedisI18nResource : I18nResource
{
    private readonly RedisClient _redisClient;
    private readonly string _pathPrefix;
    internal RedisI18nResource(RedisClient redisClient, string pathPrefix, TimeSpan expired, int capacity = 10)
    {
        _redisClient = redisClient;
        _pathPrefix = pathPrefix;
        // Redis client-side 模式
        redisClient.UseClientSideCaching(new ClientSideCachingOptions
        {
            Capacity = capacity,
            KeyFilter = key => key.StartsWith(pathPrefix),
            CheckExpired = (key, dt) => DateTime.Now.Subtract(dt) > expired
        });
        // FreeRedis 的 client-side 模式,使用 Hash 类型时,
        // 第一次需要先 HGetAll() ,框架将缓存拉取到本地
        GetAllStrings(default);
    }
    public IReadOnlyList<CultureInfo> SupportedCultures => _redisClient
        .Keys(_pathPrefix)
        .Select(x => new CultureInfo(x.Remove(0, _pathPrefix.Length + 1))).ToList();
    public IReadOnlyList<CultureInfo> SupportedUICultures => _redisClient
        .Keys(_pathPrefix)
        .Select(x => new CultureInfo(x.Remove(0, _pathPrefix.Length + 1))).ToList();
    public LocalizedString Get(string culture, string name)
    {
        var key = $"{_pathPrefix}:{culture}";
        var value = _redisClient.HGet<string>(key, name);
        if (string.IsNullOrEmpty(value)) return new LocalizedString(name, name, resourceNotFound: true);
        return new LocalizedString(name, value);
    }
    public LocalizedString Get(string culture, string name, params object[] arguments)
    {
        var key = $"{_pathPrefix}:{culture}";
        var value = _redisClient.HGet<string>(key, name);
        if (string.IsNullOrEmpty(value)) return new LocalizedString(name, name, resourceNotFound: true);
        var v = string.Format(value, arguments);
        return new LocalizedString(name, v);
    }
    public LocalizedString Get<T>(string culture, string name)
    {
        var key = $"{_pathPrefix}:{culture}";
        var value = _redisClient.HGet<string>(key, name);
        if (string.IsNullOrEmpty(value)) return new LocalizedString(name, name, resourceNotFound: true);
        return new LocalizedString(name, value);
    }
    public LocalizedString Get<T>(string culture, string name, params object[] arguments)
    {
        var key = $"{_pathPrefix}:{culture}";
        var value = _redisClient.HGet<string>(key, name);
        if (string.IsNullOrEmpty(value)) return new LocalizedString(name, name, resourceNotFound: true);
        var v = string.Format(value, arguments);
        return new LocalizedString(name, v);
    }
    public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
    {
        var keys = _redisClient.Keys(_pathPrefix);
        foreach (var key in keys)
        {
            var vs = _redisClient.HGetAll<string>(key);
            foreach (var item in vs)
            {
                yield return new LocalizedString(item.Key, item.Value);
            }
        }
    }
}

落到代码里,然后编写一个扩展类,借助 FreeRedis 中的 API,将 Redis 中的缓存拉取到本地内存中,同时且 Redis 中的缓存变化时,FreeRedis 会自动拉取到本地。基于这个特性,我们虽然采用了 Redis 存储多语言,但是每次读取时实际上都是在本地内存读取的,因此具有极高的性能和速度,也避免了网络开销。

public static class Extensions
{
    // 添加 i18n redis 资源
    public static I18nResourceFactory AddRedis(this I18nResourceFactory resourceFactory,
        RedisClient.DatabaseHook redis,
        string pathPrefix,
        TimeSpan expired,
        int capacity = 10
        )
    {
        redis.UseClientSideCaching(new ClientSideCachingOptions
        {
            Capacity = capacity,
            KeyFilter = key => key.StartsWith(pathPrefix),
            CheckExpired = (key, dt) => DateTime.Now.Subtract(dt) > expired
        });
        var keys = redis.Keys(pathPrefix);
        resourceFactory.Add(new RedisI18nResource(redis, pathPrefix));
        return resourceFactory;
    }
}

实际处理时,Demo6.Redis 项目示范了该扩展的采用方法,关键部分代码示比如下所示:

WRedisClient cli = new RedisClient("127.0.0.1:6379,defaultDatabase=0");
builder.Services.AddI18n(defaultLanguage: "zh-CN");
builder.Services.AddI18nResource(option =>
{
    option.AddRedis(cli, "language", TimeSpan.FromMinutes(100), 10);
    option.AddJson<Program>("i18n");
});

落到代码里,在 Redis 中新建 Hash 类型的 key,同时设置一些键值对,然后在客户端中读取出来。

nuget 打包嵌入 json

理解这一步时,在企业内部开发时,可能要将项目打包为 nuget 提供给其他开发者采用,所以多语言资源文件也需打包到 nuget 包中。

新建 Demo5.Nuget 类库项目,其目录结构如下所示:

修改 .csproj 文件,将相关属性修改为如下所示所示设置:

<ItemGroup>
        <Content Include="i18nDemo5.Nugeten-US.json" Pack="true">
            <PackageCopyToOutput>true</PackageCopyToOutput>
            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
            <PackagePath>contentFilesanyanyi18nDemo5.Nugeten-US.json</PackagePath>
        </Content>
        <Content Include="i18nDemo5.Nugetzh-CN.json" Pack="true">
            <PackageCopyToOutput>true</PackageCopyToOutput>
            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
            <PackagePath>contentFilesanyanyi18nDemo5.Nugetzh-CN.json</PackagePath>
        </Content>
    </ItemGroup>

理解这一步时,而在 Web 项目中,由于编译器已经自动设置了 EnableDefaultContentItems 属性,自动设置给 web.config、 .json.cshtml 文件设置 <Content></Content> 属性,所以自定义设置 Content 属性时会冲突,我们需在 <PropertyGroup> </PropertyGroup> 属性中关闭此设置。

<EnableDefaultContentItems>false</EnableDefaultContentItems>

采用者引入 nuget 包后,能够看到项目中出现了对应的文件。

到此这篇关于.NET 高级开发 | i18n 原理、实现一个 i18n 框架的文章就介绍到这了,更多相关.NET 高级开发 | i18n 原理、实现一个 i18n 框架内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多兼容脚本之家!

您可能感兴趣的文章:
  • .NET i18n 原理、实现一个 i18n 框架的过程详解
  • Vue应用国际化(i18n)的核心方案与实践技巧
  • SpringBoot实现i18n国际化的两种企业级方案
  • vue3 i18n动态拿到key的实现方法
  • vue-i18n采用$t导致的Typescript报错问题及解决
  • 一文带你搞懂Vue中i18n的采用方法
  • SpringBoot实现国际化i18n详解
  • 前端i18n Ally插件采用方法轻松介绍
  • Vue3中采用i18n,this.$t报错问题及解决

相关文章

精彩推荐