.NET??i18n?原理、實(shí)現(xiàn)一個(gè)?i18n?框架的過程詳解
隨著業(yè)務(wù)的國際化,為了滿足不同客戶群體的需要,軟件產(chǎn)品需要支持多種語言,根據(jù)用戶選擇的語言呈現(xiàn)不同語言的界面。ASP.NET Core 或 ABP 等框架都提供了多語言解決方案,盡管配置方法各不相同,但都是通過鍵值對(duì)的方式使用的,開發(fā)者需要為每個(gè) key 提供對(duì)應(yīng)語言的值,框架會(huì)根據(jù)請(qǐng)求上下文自動(dòng)區(qū)配 key 在對(duì)應(yīng)語言下的值。
在本章中,筆者將基于 ASP.NET Core,簡述如何實(shí)現(xiàn)一個(gè) i18n 多語言框架,Maomi.I18n 支持在控制臺(tái)、ASP.NET Core、WPF 等項(xiàng)目下使用,支持自定義多語言資源。
什么是 i18n?
i18n 是 "internationalization"(國際化)的縮寫。之所以寫成 i18n,是因?yàn)槭鬃帜?nbsp;i 和末字母 n 之間有 18 個(gè)字母。
簡單理解:i18n = 國際化,指的是讓應(yīng)用程序能夠適應(yīng)不同語言和地區(qū)的技術(shù)。
類似的縮寫還有:L10n (localization,本地化,L 和 n 之間有 10 個(gè)字母)
i18n vs L10n 的區(qū)別
| 概念 | 英文 | 含義 | 示例 |
|---|---|---|---|
| 國際化 | i18n | 開發(fā)時(shí)讓程序支持多語言的能力 | 使用資源文件、支持 Unicode、處理不同日期格式 |
| 本地化 | L10n | 針對(duì)特定語言/地區(qū)進(jìn)行適配 | 翻譯文本、使用本地貨幣符號(hào)、適配當(dāng)?shù)厝掌诟袷?/td> |
類比理解:
- i18n = 手機(jī)充電口設(shè)計(jì)成 Type-C(通用接口)
- L10n = 給手機(jī)配一個(gè)當(dāng)?shù)匾?guī)格的充電頭
體驗(yàn) Maomi.I18n
Maomi.I18n 是本章完成學(xué)習(xí)后的代碼成果, 在編寫多語言框架之前,先來學(xué)習(xí) Maomi.I18n 的用法。
控制臺(tái)示例
如果要使用 json 文件存儲(chǔ)語言資源包,那么需要根據(jù)項(xiàng)目名稱創(chuàng)建目錄,接下來,我們創(chuàng)建示例項(xiàng)目演示這個(gè)過程。
創(chuàng)建一個(gè) Demo5.Lib 項(xiàng)目,引入 Maomi.I18n 框架。
在 Demo5.Lib 和 Console 兩個(gè)項(xiàng)目中都添加 i18n 目錄和對(duì)應(yīng)的多語言資源文件。

在 i18n/Demo5.Lib 目錄中創(chuàng)建 en-US.json、zh-CN.json 文件。

兩個(gè)文件的內(nèi)容都設(shè)置為:
{
"test": "lib"
}右鍵修改 en-US.json、zh-CN.json 屬性,設(shè)置生成操作為內(nèi)容、始終復(fù)制到輸出目錄。

然后創(chuàng)建一個(gè) Demo5.Console 項(xiàng)目,引用 Demo5.Lib。
在 i18n/Demo5.Console 目錄中創(chuàng)建 en-US.json、zh-CN.json 文件。

兩個(gè)文件都設(shè)置內(nèi)容為:
{
"test": "console"
}最后得到的目錄結(jié)構(gòu)如下:
├─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();
// 手動(dòng)設(shè)置當(dāng)前請(qǐng)求語言
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 目錄下可以看到如下文件結(jié)構(gòu):
.
├── Demo5.Console
│ ├── en-US.json
│ └── zh-CN.json
└── Demo5.Lib
├── en-US.json
└── zh-CN.jsonMaomi.I18n 的原理很簡單,每個(gè)項(xiàng)目都設(shè)置多語言文件,編譯后所有文件都會(huì)合并到 i18n 目錄中統(tǒng)一管理和加載,每個(gè)目錄都與項(xiàng)目名稱一致,便于區(qū)分。使用 IStringLocalizer<T> 讀取 key 時(shí),會(huì)自動(dòng)從 T 類型所在的項(xiàng)目名稱目錄下加載 json 文件。
單個(gè)項(xiàng)目管理
在上一個(gè)例子中,每個(gè)項(xiàng)目都有自己的多語言資源文件,當(dāng)然我們?cè)谡麄€(gè)解決方案中共用一個(gè)語言文件,不需要按照目錄劃分。
收到導(dǎo)入多語言資源文件:
ioc.AddI18nResource(options =>
{
options.AddJsonFile("zh-CN", "i18n/zh-CN.json");
options.AddJsonFile("en-US", "i18n/en-US.json");
});或者自動(dòng)掃描目錄,并自動(dòng)使用 json 文件名稱當(dāng)作多語言名稱:
ioc.AddI18nResource(options =>
{
options.AddJsonDirectory("i18n");
});使用時(shí)直接注入 IStringLocalizer ,不需要注入 IStringLocalizer<T>:
// 手動(dòng)設(shè)置當(dāng)前請(qǐng)求語言
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);
}如何設(shè)置當(dāng)前語言
設(shè)置當(dāng)前上下文語言,只需要設(shè)置當(dāng)前文化即可:
CultureInfo.CurrentCulture = new CultureInfo("zh-CN");
另一種方法是跟當(dāng)前容器上下文有個(gè),開發(fā)者可以自由定義如何解析當(dāng)前程序多語言上下文。
public class MyI18nContext : I18nContext
{
public MyI18nContext()
{
base.Culture = ...
}
public void Set(CultureInfo cultureInfo)
{
base.Culture = cultureInfo;
}
}
builder.Services.AddScoped<I18nContext, MyI18nContext>();Web 示例
創(chuàng)建一個(gè) Api 項(xiàng)目,名為 Demo5.Api ,引入 Maomi.I18n.AspNetCore 。
在項(xiàng)目中新建一個(gè) i18n/Demo5.Api 目錄,然后創(chuàng)建兩個(gè) json 文件。

zh-CN.json 文件內(nèi)容:
{
"購物車": {
"商品名稱": "商品名稱",
"加入時(shí)間": "加入時(shí)間",
"清理失效商品": "清理失效商品"
},
"會(huì)員等級(jí)": {
"用戶名": "用戶名",
"積分": "積分:{0}",
"等級(jí)": "等級(jí)"
}
}en-US.json 文件內(nèi)容:
{
"購物車": {
"商品名稱": "Product name",
"加入時(shí)間": "Join date",
"清理失效商品": "Cleaning up failures"
},
"會(huì)員等級(jí)": {
"用戶名": "Username",
"積分": "Member points:{0}",
"等級(jí)": "Level"
}
}Maomi.I18n 框架會(huì)掃描程序集目錄的 json 文件,然后解析 json 文件以鍵值對(duì)的形式存儲(chǔ)到內(nèi)存中,Key 的形式與第三章中提到的 IConfiguration 的 Key 一致,在第四章中也提到了如何解析 json 文件,比如要取得商品名稱的值,可以使用 ["購物車:商品名稱"] 這樣的形式獲取嵌套層次下的值,而且還可以使用字符串插值,如 "積分": "Member points:{0}"。
使用 Maomi.I18n ,只需要兩步,注入 i18n 服務(wù)和導(dǎo)入 i18n 語言資源。
// 添加 i18n 多語言支持
builder.Services.AddI18nAspNetCore(defaultLanguage: "zh-CN");
// 設(shè)置多語言來源-json
builder.Services.AddI18nResource(option =>
{
var basePath = "i18n";
option.AddJson(basePath);
});
接著,添加 i18n 中間件,中間件會(huì)將用戶請(qǐng)求時(shí)的上下文中解析出對(duì)應(yīng)的語言。
var app = builder.Build(); app.UseI18n(); // <- 放到中間件靠前的位置
然后添加控制器或直接編寫中間件進(jìn)行測試,只需要注入 IStringLocalizer 服務(wù)即可。
app.UseRouting();
app.Use(async (HttpContext context, RequestDelegate next) =>
{
var localizer = context.RequestServices.GetRequiredService<IStringLocalizer<Program>>();
await context.Response.WriteAsync(localizer["購物車:商品名稱"]);
return;
});
啟動(dòng)程序,打開地址 http://localhost:5177/test?culture=en-US&ui-culture=en-US 可以觀察到輸出為 Product name。

攜帶請(qǐng)求語言信息
Maomi.I18n 本質(zhì)是基于 ASP.NET Core 的多語言接口進(jìn)行擴(kuò)展的,所以 Maomi.I18n 并不需要做太多解析語言的工作,而是依靠 ASP.NET Core 自帶的多語言功能將客戶端請(qǐng)求時(shí)解析出要使用的語言,以及相關(guān)的上下文信息。
ASP.NET Core 中可以使用 app.UseRequestLocalization(); 引入 RequestLocalizationMiddleware 中間件提供一些多語言的處理。RequestLocalizationMiddleware 中間件會(huì)自動(dòng)調(diào)用 IRequestCultureProvider 檢索請(qǐng)求所用語言,然后我們可以通過 context.Features.Get<IRequestCultureFeature>(); 來獲取到對(duì)應(yīng)的語言,簡化我們?cè)O(shè)計(jì)多語言框架的代碼。
ASP.NET Core 定義了一個(gè) IRequestCultureProvider 接口,用于解析客戶端請(qǐng)求時(shí)攜帶的的區(qū)域性信息,從當(dāng)前請(qǐng)求中解析出所用的語言,ASP.NET Core 本身有三個(gè)類型實(shí)現(xiàn)了該接口,所以相當(dāng)于自帶了三種獲取當(dāng)前請(qǐng)求語言的方式,下面我們來了解這三種方式是如何通過請(qǐng)求上下文解析語言標(biāo)識(shí)。
第一種是 URL 路由參數(shù),可以通過 QueryStringRequestCultureProvider 類型解析出來,需要 url 中攜帶兩個(gè)參數(shù) culture 和 ui-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 設(shè)置,也是最常用的設(shè)置方法,提供器是 AcceptLanguageHeaderRequestCultureProvider,其格式示例如下:
Accept-Language: zh-CN,zh;q=0.9
當(dāng)然,開發(fā)者可以根據(jù)需求,修改這三者的配置,以便使用其他請(qǐng)求位置或不同的參數(shù)名稱解析出當(dāng)前請(qǐng)求的文化名稱。
new QueryStringRequestCultureProvider()
{
QueryStringKey = "lan",
UIQueryStringKey = "ui"
}
由于 ASP.NET Core 會(huì)自動(dòng)解析出請(qǐng)求語言,因此我們只需要從 IRequestCultureFeature 服務(wù)中取得語言信息即可,不需要自行解析。
var requestCultureFeature = context.Features.Get<IRequestCultureFeature>(); var requestCulture = requestCultureFeature?.RequestCulture;
當(dāng)客戶端請(qǐng)求時(shí),ASP.NET Core 會(huì)自動(dòng)從 RequestLocalizationOptions 中取出 IRequestCultureProvider 服務(wù)列表,然后逐個(gè)調(diào)用,直到能夠確定用戶請(qǐng)求的語言信息。 ASP.NET Core 默認(rèn)會(huì)按順序執(zhí)行 QueryStringRequestCultureProvider、CookieRequestCultureProvider、AcceptLanguageHeaderRequestCultureProvider 三個(gè)提供器,如果前者解析找不到對(duì)應(yīng)的參數(shù),則會(huì)使用下一個(gè) IRequestCultureProvider 解析,如果默認(rèn)三個(gè)提供器都解析不出來,則會(huì)調(diào)用用戶自定義的服務(wù),如果能夠獲得結(jié)果,則不會(huì)再調(diào)用其它的提供器。當(dāng)然也可以自行修改以上組件的順序,但是這里不再贅述。
要實(shí)現(xiàn)一個(gè)IRequestCultureProvider 很簡單,比如我們要求在 url 中使用 c、uic 兩個(gè)參數(shù)攜帶多語言信息,其示例代碼如下:
// 自定義請(qǐng)求語言提供器
// 或直接繼承 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);
}
}開發(fā)者需要根據(jù) HttpContext 中的請(qǐng)求參數(shù)解析出當(dāng)前請(qǐng)求使用的語言,如果解析不出來,則應(yīng)該返回 NullProviderCultureResult,框架會(huì)繼續(xù)使用下一個(gè)IRequestCultureProvider 解析請(qǐng)求語言。如果找到了請(qǐng)求語言,則需要返回 ProviderCultureResult。
要注意的是,IRequestCultureProvider 接口的服務(wù)是不能通過容器注入的,而是在 RequestLocalizationOptions 中配置。
services.Configure<RequestLocalizationOptions>(options =>
{
// 默認(rèn)自帶了三個(gè)請(qǐng)求語言提供器,會(huì)先從這些提供器識(shí)別要使用的語言。
// QueryStringRequestCultureProvider
// CookieRequestCultureProvider
// AcceptLanguageHeaderRequestCultureProvider
// 自定義請(qǐng)求請(qǐng)求語言提供器
options.RequestCultureProviders.Add(new I18nRequestCultureProvider(defaultLanguage));
});
如果你想調(diào)整提供器的順序,只需要修改 options.RequestCultureProviders 中的 IRequestCultureProvider 集合即可。
實(shí)現(xiàn) i18n 框架
在本節(jié)中,將會(huì)介紹如何設(shè)計(jì)和編寫一個(gè) i18n 框架,框架的全部代碼如下所示。

部分文件說明如下:
// 當(dāng)前程序多語言上下文 I18nContext.cs // 多語言資源接口定義 I18nResource.cs // i18n 語言資源工廠 I18nResourceFactory.cs // 設(shè)置當(dāng)前語言作用域 I18nScope.cs // 服務(wù)注入擴(kuò)展 I18nExtensions.cs // 從 json 文件讀取多語言資源擴(kuò)展 JsonResourceExtensions.cs // 實(shí)現(xiàn) I18nResourceFactory InternalI18nResourceFactory.cs // 自定義請(qǐng)求語言解析 I18nRequestCultureProvider.cs // 實(shí)現(xiàn) IStringLocalizer 接口 I18nStringLocalizer.cs // 實(shí)現(xiàn) IStringLocalizer<T> 接口 I18nStringLocalizer`.cs // 實(shí)現(xiàn) I18nResource,通過 json 文件導(dǎo)入語言資源 JsonResource.cs // 解析 json 的幫助類 ReadJsonHelper.cs
抽象接口
設(shè)計(jì)多語言框架,首先將框架劃分為三個(gè)角色,即使用者、框架自身、多語言提供者,使用者通過抽象接口獲取 key 對(duì)應(yīng)語言的值,多語言提供者通過抽象接口提供多語言鍵值對(duì)數(shù)據(jù)。所以,抽象接口主要是面向使用者和多語言提供者設(shè)計(jì),框架自身則是為使用者和提供者架設(shè)一個(gè)橋梁,此外還需要定義一些上下文類、模型類,以便傳遞信息。
首先思考以何種方式保存多語言資源數(shù)據(jù),比如說嵌入程序集、在項(xiàng)目中攜帶 json 文件、存儲(chǔ)在 redis 中等,i18n 框架不需要關(guān)心多語言存儲(chǔ)在哪里,只需要通過接口加載出來即可。
定義一個(gè) I18nResource 接口,i18n 框架通過該接口加載多語言數(shù)據(jù)。
/// <summary>
/// i18n 語言資源.
/// </summary>
/// <remarks>每個(gè) I18nResource 對(duì)應(yīng)一種語言的一個(gè)資源文件.</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">字符串插值參數(shù).</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>每個(gè) I18nResource 對(duì)應(yīng)一種語言的一個(gè)資源文件.</remarks>
/// <typeparam name="T">類型.</typeparam>
public interface I18nResource<T> : I18nResource
{
}然后創(chuàng)建多語言管理工廠,管理 I18nResource 列表,支持從容器中取出多語言資源文件。
/// <summary>
/// I18n 資源工廠.
/// </summary>
public interface I18nResourceFactory
{
/// <summary>
/// 當(dāng)前支持的語言.
/// </summary>
IList<CultureInfo> SupportedCultures { get; }
/// <summary>
/// 所有資源提供器.
/// </summary>
IList<I18nResource> Resources { get; }
/// <summary>
/// 在容器中的資源服務(wù).
/// </summary>
IList<Type> ServiceResources { get; }
/// <summary>
/// 添加 i18n 語言資源,該類型將會(huì)被從容器中取出.
/// </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);
}接下來是設(shè)計(jì)使用者接口的抽象。
ASP.NET Core 通過 IRequestCultureProvider 檢索出來的當(dāng)前請(qǐng)求語言,為了簡化解析語言標(biāo)識(shí)的代碼,定義一個(gè)I18nContext 類型,用來存儲(chǔ)從請(qǐng)求上下文中解析處理的多語言標(biāo)識(shí),i18n 框架中下游服務(wù)可以通過 I18nContext 獲取當(dāng)前請(qǐng)求語言。
// 記錄當(dāng)前請(qǐng)求的 i18n 信息
public class I18nContext
{
// 當(dāng)前用戶請(qǐng)求的語言
public CultureInfo Culture { get; internal set; } = CultureInfo.CurrentCulture;
}
IStringLocalizer、IStringLocalizer<T> 是 ASP.NET Core 中多語言服務(wù)的接口,使用者可以從這兩個(gè)接口中查詢多語言字符串,我們實(shí)現(xiàn)兩個(gè)對(duì)應(yīng)的服務(wù),從 I18nResource 集合中查找出對(duì)應(yīng)字符串的值。
/// <summary>
/// 表示提供本地化字符串的服務(wù).
/// </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;
}
// 所有的資源都查找不到時(shí),使用默認(rèn)值
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;
}
// 所有的資源都查找不到時(shí),使用默認(rèn)值
return new LocalizedString(name, string.Format(name, arguments));
}
}/// <summary>
/// 表示提供本地化字符串的服務(wù).
/// </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;
}
// 所有的資源都查找不到時(shí),使用默認(rèn)值
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;
}
// 所有的資源都查找不到時(shí),使用默認(rèn)值
return new LocalizedString(name, string.Format(name, arguments));
}
}CultureInfoScope 的作用很簡單,在其作用域之內(nèi)修改 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;
}
}至此,我們已經(jīng)設(shè)計(jì)好 i18n 框架的抽象了,接下來我們會(huì)進(jìn)一步實(shí)現(xiàn) i18n 框架。
實(shí)現(xiàn)從 json 讀取語言資源
Maomi.I18n 本身實(shí)現(xiàn)了一個(gè)從 json 文件讀取多語言資源包的 I18nResource 服務(wù),而無論從哪里讀取的多語言資源,大部分都可以以 kv 的形式存儲(chǔ)到內(nèi)存中,因此,講解開發(fā)者如何實(shí)現(xiàn)一個(gè) DictionaryResource 服務(wù)存儲(chǔ)從不同地方讀取到的多語言資源內(nèi)容。
為了保證每個(gè)項(xiàng)目都可以攜帶自己的語言信息,我們可以要求項(xiàng)目下面創(chuàng)建 i18n 目錄,然后創(chuàng)建與當(dāng)前項(xiàng)目同名的子目錄,在子目錄下存儲(chǔ)自己的語言文件。

這樣做的好處時(shí),當(dāng)編譯項(xiàng)目時(shí),主項(xiàng)目下的 i18n 會(huì)收集到所有項(xiàng)目的語言文件,而且不會(huì)發(fā)生沖突。而且當(dāng)我們使用 nuget 打包項(xiàng)目時(shí),nuget 包還會(huì)攜帶這些文件,使用這個(gè)拉取 nuget 包后也可以使用到這些多語言文件。
/// <summary>
/// 字典存儲(chǔ)多語言文件資源.
/// </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>
/// 字典存儲(chǔ)多語言文件資源.
/// </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);
}
}編寫擴(kuò)展方法注入 json 語言資源,該擴(kuò)展方法只會(huì)加載與 T 類型所在程序集的相同命名目錄下的 json 文件。
/// <summary>
/// Json 多語言文件資源.
/// </summary>
public static class JsonResourceExtensions
{
/// <summary>
/// 掃描目錄下的所有子目錄,自動(dòng)區(qū)配對(duì)應(yīng)的項(xiàng)目/程序集下,json 文件名稱會(huì)被動(dòng)作語言名稱.
/// </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 文件名稱會(huì)被當(dāng)作語言名稱.
/// </summary>
/// <param name="resourceFactory"></param>
/// <param name="basePath">基礎(chǔ)路徑.</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 文件都?xì)w類到此程序集下,json 文件名稱會(huì)被當(dāng)作語言名稱.
/// </summary>
/// <typeparam name="T">類型.</typeparam>
/// <param name="resourceFactory"></param>
/// <param name="basePath">基礎(chǔ)路徑.</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;
}
}中間件準(zhǔn)備完畢之后,我們開始寫管理 I18nResourceFactory 接口的實(shí)現(xiàn),以便管理好各種語言資源服務(wù)。
/// <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;
}
}實(shí)現(xiàn) IStringLocalizerFactory 接口,根據(jù)泛型類型創(chuàng)建 IStringLocalizer 對(duì)象。
/// <summary>
/// 表示創(chuàng)建<see cref="IStringLocalizer"/> 實(shí)例的工廠.
/// </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 擴(kuò)展方法注入相關(guān)的服務(wù)。
/// <summary>
/// i18n 擴(kuò)展.
/// </summary>
public static class I18nExtensions
{
/// <summary>
/// 添加 i18n 支持服務(wù).
/// </summary>
/// <param name="services"></param>
/// <param name="defaultLanguage">默認(rèn)語言.</param>
public static void AddI18n(this IServiceCollection services, string defaultLanguage = "zh-CN")
{
InternalI18nResourceFactory resourceFactory = new InternalI18nResourceFactory();
// i18n 上下文
services.AddScoped<I18nContext, DefaultI18nContext>();
// 注入 i18n 服務(wù)
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);
}
}對(duì)于 ASP.NET Core 應(yīng)用,則需要額外添加一些擴(kuò)展函數(shù):
/// <summary>
/// i18n 擴(kuò)展.
/// </summary>
public static class I18nExtensions
{
/// <summary>
/// 添加 i18n 支持服務(wù).
/// </summary>
/// <param name="services"></param>
/// <param name="defaultLanguage">默認(rèn)語言.</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 的本地化服務(wù)
services.Configure<RequestLocalizationOptions>(options =>
{
options.ApplyCurrentCultureToResponseHeaders = true;
options.DefaultRequestCulture = new RequestCulture(culture: defaultLanguage, uiCulture: defaultLanguage);
options.SupportedCultures = resourceFactory.SupportedCultures;
options.SupportedUICultures = resourceFactory.SupportedCultures;
// 默認(rèn)自帶了三個(gè)請(qǐng)求語言提供器,會(huì)先從這些提供器識(shí)別要使用的語言。
// QueryStringRequestCultureProvider
// CookieRequestCultureProvider
// AcceptLanguageHeaderRequestCultureProvider
// 自定義請(qǐng)求請(qǐng)求語言提供器
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>
/// 模型驗(yàn)證使用多語言.
/// </summary>
public static partial class DataAnnotationsExtensions
{
/// <summary>
/// 為 API 模型驗(yàn)證注入 i18n 服務(wù).
/// </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;
}
}在本章中,講解了如何編寫一個(gè)全球化語言的類庫,其實(shí)現(xiàn)比較簡單,也許并不滿足業(yè)務(wù)系統(tǒng)的需求,那么你可以根據(jù)本章的內(nèi)容,結(jié)合業(yè)務(wù)系統(tǒng)的需求,實(shí)現(xiàn)一個(gè)更好用的 i18n 類庫。
單元測試
編寫單元測試和性能測試是開發(fā)者需要掌握的技能之一,在本書中的第四章介紹了性能測試的編寫示例,在本章中繼續(xù)介紹單元測試的編寫方法,讀者還可以從從 Maomi 倉庫源碼中了解看到更多的單元測試示例。
創(chuàng)建一個(gè) xUnit 單元測試項(xiàng)目,項(xiàng)目文件結(jié)構(gòu)如下:

添加對(duì) Maomi.I18n 、Microsoft.AspNetCore.Mvc.Testing 兩個(gè)類庫的引用。
在 I18nTest 中創(chuàng)建一個(gè)測試方法:
[Fact]
public async Task I18n_Request()
{
}
首先構(gòu)建一個(gè)用于測試的 Web Host,并且注入相關(guān)的服務(wù),用來模擬啟動(dòng) Web 服務(wù)。
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 不會(huì)真的啟動(dòng)一個(gè) Web 服務(wù),因此無法直接發(fā)起 HttpClient 請(qǐng)求進(jìn)行測試,需要從 Host 中創(chuàng)建一個(gè) HttpClient 對(duì)象:
var httpClient = host.GetTestClient();
下面給出分別測試路由、Cookie、Accept-Language 標(biāo)頭的三種解析語言標(biāo)識(shí)測試 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 的動(dòng)態(tài)多語言
本節(jié)示例代碼在 Maomi.I18n.Redis 中,該擴(kuò)展庫通過實(shí)現(xiàn) I18nResource 接口,從 Redis 中加載多語言數(shù)據(jù)緩存到本地客戶端中,當(dāng) Redis 中的數(shù)據(jù)變化時(shí),客戶端自動(dòng)拉取最新的值。由于數(shù)據(jù)會(huì)被緩存到本地,所以該擴(kuò)展庫可以實(shí)時(shí)更新多語言數(shù)據(jù),同時(shí)保持高性能。
新建一個(gè) Maomi.I18n.Redis 項(xiàng)目,引用 Maomi.I18n 庫 和 FreeRedis 庫,創(chuàng)建 RedisI18nResource 類實(shí)現(xiàn) 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 類型時(shí),
// 第一次需要先 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);
}
}
}
}然后編寫一個(gè)擴(kuò)展類,利用 FreeRedis 中的 API,將 Redis 中的緩存拉取到本地內(nèi)存中,并且 Redis 中的緩存變化時(shí),F(xiàn)reeRedis 會(huì)自動(dòng)拉取到本地?;谶@個(gè)特性,我們雖然使用了 Redis 存儲(chǔ)多語言,但是每次讀取時(shí)實(shí)際上都是在本地內(nèi)存讀取的,因此具有極高的性能和速度,也避免了網(wǎng)絡(luò)開銷。
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 項(xiàng)目示范了該擴(kuò)展的使用方法,關(guān)鍵部分代碼示例如下:
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 中創(chuàng)建 Hash 類型的 key,并設(shè)置一些鍵值對(duì),然后在客戶端中讀取出來。

nuget 打包嵌入 json
在企業(yè)內(nèi)部開發(fā)時(shí),可能要將項(xiàng)目打包為 nuget 提供給其他開發(fā)者使用,所以多語言資源文件也需要打包到 nuget 包中。
創(chuàng)建 Demo5.Nuget 類庫項(xiàng)目,其目錄結(jié)構(gòu)如下:

修改 .csproj 文件,將相關(guān)屬性修改為如下所示配置:
<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 項(xiàng)目中,由于編譯器已經(jīng)自動(dòng)設(shè)置了 EnableDefaultContentItems 屬性,自動(dòng)設(shè)置給 web.config、 .json 、 .cshtml 文件設(shè)置 <Content></Content> 屬性,所以自定義配置 Content 屬性時(shí)會(huì)沖突,我們需要在 <PropertyGroup> </PropertyGroup> 屬性中關(guān)閉此配置。
<EnableDefaultContentItems>false</EnableDefaultContentItems>
使用者引入 nuget 包后,可以看到項(xiàng)目中出現(xiàn)了對(duì)應(yīng)的文件。

到此這篇關(guān)于.NET 高級(jí)開發(fā) | i18n 原理、實(shí)現(xiàn)一個(gè) i18n 框架的文章就介紹到這了,更多相關(guān).NET 高級(jí)開發(fā) | i18n 原理、實(shí)現(xiàn)一個(gè) i18n 框架內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- .NET??i18n?原理、實(shí)現(xiàn)一個(gè)?i18n?框架的過程詳解
- Vue應(yīng)用國際化(i18n)的核心方案與實(shí)踐技巧
- SpringBoot實(shí)現(xiàn)i18n國際化的兩種企業(yè)級(jí)方案
- vue3 i18n動(dòng)態(tài)獲取key的實(shí)現(xiàn)方式
- vue-i18n使用$t導(dǎo)致的Typescript報(bào)錯(cuò)問題及解決
- 一文帶你搞懂Vue中i18n的使用方法
- SpringBoot實(shí)現(xiàn)國際化i18n詳解
- 前端i18n?Ally插件使用方法簡單介紹
- Vue3中使用i18n,this.$t報(bào)錯(cuò)問題及解決
相關(guān)文章
輕松解決asp.net用戶ASPNET登錄失敗問題的方法分享
這篇文章介紹了asp.net用戶ASPNET登錄失敗問題的方法,有需要的朋友可以參考一下2013-11-11
ASP.NET動(dòng)態(tài)生成靜態(tài)頁面的實(shí)例代碼
生成靜態(tài)頁有很多好處,可以緩解服務(wù)器壓力、方便搜索網(wǎng)站搜索等等,下面介紹一下生成靜態(tài)頁的實(shí)例代碼,有需要的朋友可以參考一下2013-07-07
.NET下通過HttpListener實(shí)現(xiàn)簡單的Http服務(wù)
這篇文章主要為大家詳細(xì)介紹了.NET下通過HttpListener實(shí)現(xiàn)簡單Http服務(wù)的相關(guān)資料,感興趣的小伙伴們可以參考一下2016-09-09
詳解可跨域的單點(diǎn)登錄(SSO)實(shí)現(xiàn)方案【附.net代碼】
本篇文章主要介紹了可跨域的單點(diǎn)登錄(SSO)實(shí)現(xiàn)方案,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2016-11-11
Visual Studio 2017中找回消失的“在瀏覽器中查看”命令
這篇文章主要為大家詳細(xì)介紹了如何在Visual Studio 2017中找回消失的“在瀏覽器中查看”命令,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-03-03
使用updatepanel局部刷新實(shí)現(xiàn)注冊(cè)時(shí)對(duì)用戶名的檢測示例
這篇文章主要介紹了使用updatepanel局部刷新實(shí)現(xiàn)注冊(cè)時(shí)對(duì)用戶名的檢測示例,需要的朋友可以參考下2014-03-03

