多语言框架的设计

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

在本章中,笔者将基于 ASP.NET Core,简述如何实现一个 i18n 多语言框架,Maomi.I18n 支持在控制台、ASP.NET Core、WPF 等项目下使用,支持自定义多语言资源。

体验 Maomi.I18n

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

控制台示例

如果要使用 json 文件存储语言资源包,那么需要根据项目名称创建目录,接下来,我们创建示例项目演示这个过程。 创建一个 Demo5.Lib 项目,引入 Maomi.I18n 框架。

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

image-20241013093157869

在 i18n/Demo5.Lib 目录中创建 en-US.json、zh-CN.json 文件。

image-20230810194715106

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

{
  "test": "lib"
}

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

1696845012135.jpg

然后创建一个 Demo5.Console 项目,引用 Demo5.Lib。

在 i18n/Demo5.Console 目录中创建 en-US.json、zh-CN.json 文件。

1696844893098.jpg

两个文件都设置内容为:

{
  "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 文件。

单个项目管理

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

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

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 文件。

image-20240210163750138

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;
});

启动程序,打开地址 http://localhost:5177/test?culture=en-US&ui-culture=en-US 可以观察到输出为 Product name。

image-20230308075028615

携带请求语言信息

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 框架,框架的全部代码如下所示。

image-20241013101420912

部分文件说明如下:

// 当前程序多语言上下文
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 目录,然后创建与当前项目同名的子目录,在子目录下存储自己的语言文件。

image-20230809192958564

这样做的好处时,当编译项目时,主项目下的 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 单元测试项目,项目文件结构如下:

image-20230309071050703

添加对 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,并设置一些键值对,然后在客户端中读取出来。

image-20240210175333270

nuget 打包嵌入 json

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

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

image-20240210175931846

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

    <ItemGroup>
        <Content Include="i18n\Demo5.Nuget\en-US.json" Pack="true">
            <PackageCopyToOutput>true</PackageCopyToOutput>
            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
            <PackagePath>contentFiles\any\any\i18n\Demo5.Nuget\en-US.json</PackagePath>
        </Content>

        <Content Include="i18n\Demo5.Nuget\zh-CN.json" Pack="true">
            <PackageCopyToOutput>true</PackageCopyToOutput>
            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
            <PackagePath>contentFiles\any\any\i18n\Demo5.Nuget\zh-CN.json</PackagePath>
        </Content>
    </ItemGroup>

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

<EnableDefaultContentItems>false</EnableDefaultContentItems>

使用者引入 nuget 包后,可以看到项目中出现了对应的文件。

image-20240210181008898

Copyright © 痴者工良 2024 all right reserved,powered by Gitbook文档最后更新时间: 2024-10-13 13:32:14

results matching ""

    No results matching ""