注:本文隸屬於《理解ASP.NET Core》系列文章,請檢視置頂部落格或點選此處檢視全文目錄
在眾多知名品牌的網站中,比如微軟官網、YouTube等,我們經常可以見到「切換頁面語言」的功能,我們可以選擇最適合的語言瀏覽頁面內容。毫無疑問,為網站提供多種語言,頁面內容在地化,大大擴充套件了受眾範圍,提升了使用者體驗。
為了更好地理解下面的內容,我們先來了解一下行業內通用的名詞術語:
一般情況下,統一使用英文作為多語言的字典Key,在 Web 剛進入開發階段時,最好就支援多語言,否則後續改造的工作量會比較大。當然,你可以選擇使用中文作為 Key,不過並不太推薦,畢竟你總不能要求懂阿拉伯語的人要懂中文。
ASP.NET Core 提供了多種在地化工具:
IStringLocalizer
和IStringLocalizer<>
可以在執行時提供區域性資源,使用非常簡單,就像操作字典一樣,提供一個 Key,就能獲取到指定區域的資源。另外,它還允許 Key 在資源中不存在,此時返回的就是 Key 自身。我們下面稱這個 Key 為資源名。
下面是他們的結構定義:
public interface IStringLocalizer
{
// 通過資源名獲取在地化文字,如果資源不存在,則返回 name 自身
LocalizedString this[string name] { get; }
// 通過資源名獲取在地化文字,並允許將引數值填充到文字中,如果資源不存在,則返回 name 自身
LocalizedString this[string name, params object[] arguments] { get; }
// 獲取所有的在地化資源文字
IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures);
}
public interface IStringLocalizer<out T> : IStringLocalizer
{
}
var builder = WebApplication.CreateBuilder(args);
// 註冊服務
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
var app = builder.Build();
// 啟用中介軟體
app.UseRequestLocalization(options =>
{
var cultures = new[] { "zh-CN", "en-US", "zh-TW" };
options.AddSupportedCultures(cultures);
options.AddSupportedUICultures(cultures);
options.SetDefaultCulture(cultures[0]);
// 當Http響應時,將 當前區域資訊 設定到 Response Header:Content-Language 中
options.ApplyCurrentCultureToResponseHeaders = true;
});
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
首先,我們通過AddLocalization
註冊了IStringLocalizerFactory
和IStringLocalizer<>
,並指定了資源的根目錄為「Resources」。
然後,我們又通過UseRequestLocalization
啟用了中介軟體RequestLocalizationMiddleware
。預設情況下,該中介軟體支援的區域文化僅為當前區域文化,即CultureInfo.CurrentCulture
和CultureInfo.CurrentUICulture
,我們可以通過AddSupportedCultures
和AddSupportedUICultures
自定義設定多個支援的區域文化:
Culture
:影響日期、時間、數位或貨幣的展示格式UICulture
:影響查詢哪些區域文化資源(如.resx、json檔案等),也就是說,如果這裡未新增某區域文化A,即使新增了對應區域文化A的資原始檔,也無發生效。一般 Culture 和 UICulture 保持一致。另外,當我們的服務接收到一個請求時,如果該請求未指明當前的區域文化,就會使用預設的,這裡我們通過SetDefaultCulture
指定了預設區域文化為 zh-CN
最後,通過設定ApplyCurrentCultureToResponseHeaders
為true
,將當前區域資訊設定到Http響應頭的Content-Language
中。
HomeController
類的資原始檔,目錄結構如下:- Resources
- Controllers
- HomeController.en-US.resx
- HomeController.zh-CN.resx
- SharedResource.en-US.resx
- SharedResource.zh-CN.resx
並填充內容如下:
名稱 | 值 |
---|---|
CurrentTime | Current Time: |
名稱 | 值 |
---|---|
CurrentTime | 當前時間: |
名稱 | 值 |
---|---|
HelloWorld | Hello, World! |
名稱 | 值 |
---|---|
HelloWorld | 你好,世界! |
這些檔案預設為「嵌入的資源」
SharedResource
偽類,用來代理共用資源。public class SharedResource
{
// 裡面是空的
}
HomeController
中嘗試一下效果public class HomeController : Controller
{
// 用於提供 HomeController 的區域性資源
private readonly IStringLocalizer<HomeController> _localizer;
// 通過代理偽類提供共用資源
private readonly IStringLocalizer<SharedResource> _sharedLocalizer;
public HomeController(
IStringLocalizer<HomeController> localizer,
IStringLocalizer<SharedResource> sharedLocalizer
)
{
_localizer = localizer;
_sharedLocalizer = sharedLocalizer;
}
[HttpGet]
public IActionResult GetString()
{
var content = $"當前區域文化:{CultureInfo.CurrentCulture.Name}\n" +
$"{_localizer["HelloWorld"]}\n" +
$"{_sharedLocalizer["CurrentTime"]}{DateTime.Now.ToLocalTime()}\n";
return Content(content);
}
}
存取{your-host}/home/getstring
,使用預設的區域文化zh-CN
,獲取結果如下:
當前區域文化:zh-CN
你好,世界!
當前時間:2023/6/2 11:19:08
此時檢視響應頭資訊,可以發現
Content-Language: zh-CN
下面,我們通過 url 傳遞引數culture
,指定區域文化為en-US
,存取{your-host}/home/getstring?culture=en-US
,獲取結果如下:
當前區域文化:en-US
Hello, World!
Current Time:6/2/2023 11:47:50 AM
此時的響應頭資訊:
Content-Language: en-US
如果你的在地化果並不是預期的,並且當前區域文化沒問題的情況下,可以通過
SearchedLocation
檢視資源搜尋位置(如_localizer["HelloWord"].SearchedLocation
),檢查資源放置位置是否有誤。
好了,我們已經掌握了在地化在服務類中的使用方法,接下來,一起來看下在模型驗證中如何使用在地化。
AddDataAnnotationsLocalization
註冊資料註解在地化服務:builder.Services
.AddControllersWithViews()
.AddDataAnnotationsLocalization();
RegisterDto
模型類:public class RegisterDto
{
[Required(ErrorMessage = "UserNameIsRequired")]
public string UserName { get; set; }
[Required(ErrorMessage = "PasswordIsRequired")]
[StringLength(8, ErrorMessage = "PasswordLeastCharactersLong", MinimumLength = 6)]
public string Password { get; set; }
[Compare("Password", ErrorMessage = "PasswordDoNotMatch")]
public string ConfirmPassword { get; set; }
}
其中 ErroMessage 賦值的均為在地化資源Key
3. 然後在「Resources/Dtos」目錄下新增資原始檔:
名稱 | 值 |
---|---|
PasswordDoNotMatch | The password and confirmation password do not match |
PasswordIsRequired | The Password field is required |
PasswordLeastCharactersLong | The Password must be at least {2} characters long |
UserNameIsRequired | The UserName field is required |
名稱 | 值 |
---|---|
PasswordDoNotMatch | 兩次密碼輸入不一致 |
PasswordIsRequired | 請輸入密碼 |
PasswordLeastCharactersLong | 密碼長度不能小於 {2} |
UserNameIsRequired | 請輸入使用者名稱 |
HomeController
中新增一個Register
方法:[HttpPost]
public IActionResult Register([FromBody] RegisterDto dto)
{
if (!ModelState.IsValid)
{
return Content($"當前區域文化:{CultureInfo.CurrentCulture.Name}\n" +
"模型狀態無效:" + Environment.NewLine +
string.Join(Environment.NewLine, ModelState.Values.SelectMany(v => v.Errors.Select(e => e.ErrorMessage))));
}
return Ok();
}
測試結果就不貼了,趕緊自己試一試吧!
另外,如果你覺得每一個模型類都要建立一個資原始檔太麻煩了,可以通過DataAnnotationLocalizerProvider
來手動指定IStringLocalizer
範例,例如設定所有模型類僅從 SharedResource 中尋找在地化資源:
builder.Services
.AddControllersWithViews()
.AddDataAnnotationsLocalization(options =>
{
options.DataAnnotationLocalizerProvider = (type, factory) => factory.Create(typeof(SharedResource));
});
有時,我們可能想要使用一些沒有代理類或代理類無法使用的區域資源,無法直接通過IStringLocalizer<>
進行注入,那IStringLocalizerFactory
就可以幫助我們獲取對應的IStringLocalizer
,該介面結構如下:
public interface IStringLocalizerFactory
{
IStringLocalizer Create(Type resourceSource);
IStringLocalizer Create(string baseName, string location);
}
下面我們通過IStringLocalizerFactory
來獲取HomeController
資源範例:
public class HomeController : Controller
{
private readonly IStringLocalizer _localizer;
private readonly IStringLocalizer _localizer2;
public HomeController(IStringLocalizerFactory localizerFactory)
{
_localizer = localizerFactory.Create(typeof(HomeController));
_localizer2 = localizerFactory.Create("Controllers.HomeController", Assembly.GetExecutingAssembly().FullName);
}
[HttpGet]
public IActionResult GetString()
{
var content = $"當前區域文化:{CultureInfo.CurrentCulture.Name}\n" +
$"{_localizer["HelloWorld"]}\n" +
$"{_localizer2["HelloWorld"]}\n";
return Content(content);
}
}
這裡演示了兩種建立方式:
是時候明確一下資原始檔的命名規則了,很簡單:類的資源名稱 = 類的完整型別名 - 程式集名稱。
還是拿HomeController
來舉例,假設所屬程式集名稱為LocalizationWeb.dll
,預設根名稱空間與程式集同名,那麼它的全名稱為LocalizationWeb.Controllers.HomeController
,資原始檔就需要命名為Controllers.HomeController.XXX.resx
,而我們在註冊在地化服務時,通過ResourcesPath
指定了資源的根目錄為 Resources,所以資原始檔相對專案根目錄的相對路徑為Resources/Controllers.HomeController.XXX.resx
。由於這樣做可能會導致資原始檔名字較長,並且不便於歸類,所以我們可以將 Controllers 提取為目錄,變為Resources/Controllers/HomeController.XXX.resx
。
強烈建議程式的程式集名稱與根名稱空間保持一致,這樣可以省很多事。如果不一致,當然也有解決辦法,例如有個DifferentController
,它位於Different.Controllers
名稱空間下,那麼資原始檔需要放置於Resources/Different/Controllers
目錄下。
最後,如果你願意,可以把SharedResource
類放到 Resources 資料夾下,讓它和它的資原始檔在一起,不過要注意它的名稱空間,確保該類夠按照上述規則對應到資原始檔上。你可能還需要在.csproj檔案中進行如下設定(二選一,具體原因參考此檔案):
<PropertyGroup>
<EmbeddedResourceUseDependentUponConvention>false</EmbeddedResourceUseDependentUponConvention>
</PropertyGroup>
或
<ItemGroup>
<EmbeddedResource Include="Resources/SharedResource.en-US.resx" DependentUpon="SharedResources" />
<EmbeddedResource Include="Resources/SharedResource.zh-CN.resx" DependentUpon="SharedResources" />
</ItemGroup>
相對於IStringLocalizer
, IHtmlLocalizer
和IHtmlLocalizer<>
中的資源可以包含 HTML 程式碼,並使其能夠在前端頁面中正常渲染出來。
通常情況下,我們僅僅需要在地化文字內容,而不會包含 HTML。不過這裡還是簡單介紹一下。
AddViewLocalization
註冊服務builder.Services
.AddControllersWithViews()
.AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix);
此處我們註冊了IHtmlLocalizerFactory
、IHtmlLocalizer<>
,以及接下來要講的IViewLocalizer
共3個服務,並且通過LanguageViewLocationExpanderFormat.Suffix
指定了檢視(View)語言資源命名格式為字尾,即 <view-name>.<language>.resx
。
名稱 | 值 |
---|---|
Welcome | <b>Welcome {0}!</b> |
名稱 | 值 |
---|---|
Welcome | <b>歡迎 {0}!</b> |
@inject IHtmlLocalizer<SharedResource> HtmlSharedResource
<div class="text-center">
@HtmlSharedResource["Welcome", "jjj"]
</div>
IViewLocalizer
是專門服務於檢視的,他沒有泛型版本,也沒有工廠類,所以它只能用來獲取當前檢視資原始檔中的資源,如果想要使用其他資源,可以使用IStringLocalizer
或IHtmlLocalizer
。
它繼承自IHtmlLocalizer
,所以它也支援資源中包含 HTML 程式碼:
public interface IViewLocalizer : IHtmlLocalizer { }
下面我們在Views/Home/Index.cshtml
中演示一下效果。
上面我們已經通過
AddViewLocalization
將IViewLocalizer
服務註冊到容器中了。
Resources/Views/Home
目錄下增加以下兩個資原始檔,並設定內容:名稱 | 值 |
---|---|
Welcome | Welcome {0} !!! |
名稱 | 值 |
---|---|
Welcome | 歡迎 {0} !!! |
@inject IViewLocalizer L
<div class="text-center">
<h1>@L["Welcome", "jjj"]</h1>
</div>
當請求的區域資源未找到時,會回退到該區域的父區域資源,例如檔區域文化為 zh-CN 時,HomeController
資原始檔查詢順序如下:
如果都沒找到,則會返回資源 Key 本身。
上面,我們通過在 url 中新增引數 culture
來設定當前請求的區域資訊,實際上,ASP.NET Core 是通過IRequestCultureProvider
介面來為我們提供區域的設定方式。
可以通過以下程式碼檢視已新增的 Provider:
app.UseRequestLocalization(options =>
{
var cultureProviders = options.RequestCultureProviders;
}
可以看到,ASP.NET Core 框架預設新增了3種 Provider,分別為:
QueryStringRequestCultureProvider
:通過在 Query 中設定"culture"、"ui-culture"的值,例如 ?culture=zh-CN&ui-culture=zh-CNCookieRequestCultureProvider
:通過Cookie中設定名為 ".AspNetCore.Culture" Key 的值,值形如 c=zh-CN|uic=zh-CNAcceptLanguageHeaderRequestCultureProvider
:從請求頭中設定 "Accept-Language" 的值如果只傳了 culture 或 ui-culture,則會將該值同時賦值給 culture 或 ui-culture。我們可以通過以下程式碼檢視
我們也可以在這3個的基礎上進行自定義設定,例如通過在 Query 中設定"lang"的值來設定區域:
options.AddInitialRequestCultureProvider(new QueryStringRequestCultureProvider() { QueryStringKey = "lang" });
AddInitialRequestCultureProvider
預設將新新增的 Provider 放置在首位。
內建的還有一個RouteDataRequestCultureProvider
,不過它並沒有被預設新增到提供器列表中。它預設可以通過在路由中設定 culture 的值來設定區域,就像微軟官方檔案一樣。需要注意的是,一定要在 app.UseRouting()
之後再呼叫 app.UseRequestLocalization()
。
實現自定義RequestCultureProvider
的方式有兩種,分別是通過委託和繼承抽象類RequestCultureProvider
。
下面,我們實現一個從自定義 Header 中獲取區域文化資訊的自定義RequestCultureProvider
。
RequestCultureProviders
app.UseRequestLocalization(options =>
{
var cultures = new[] { "zh-CN", "en-US", "zh-TW" };
options.AddSupportedCultures(cultures);
options.AddSupportedUICultures(cultures);
options.SetDefaultCulture(cultures[0]);
options.RequestCultureProviders.Insert(0, new CustomRequestCultureProvider(context =>
{
ArgumentException.ThrowIfNullOrEmpty(nameof(context));
// 從請求頭「X-Lang」中獲取區域文化資訊
var acceptLanguageHeader = context.Request.GetTypedHeaders().GetList<StringWithQualityHeaderValue>("X-Lang");
if (acceptLanguageHeader == null || acceptLanguageHeader.Count == 0)
{
return Task.FromResult(default(ProviderCultureResult));
}
var languages = acceptLanguageHeader.AsEnumerable();
// 如果值包含多,我們只取前3個
languages = languages.Take(3);
var orderedLanguages = languages.OrderByDescending(h => h, StringWithQualityHeaderValueComparer.QualityComparer)
.Select(x => x.Value).ToList();
if (orderedLanguages.Count > 0)
{
return Task.FromResult(new ProviderCultureResult(orderedLanguages));
}
return Task.FromResult(default(ProviderCultureResult));
}));
}
需要注意的是,當未獲取到區域文化資訊時,若想要接著讓後面的RequestCultureProvider
繼續解析獲取,則記得一定要返回default(ProviderCultureResult)
,否則建議直接返回預設區域文化,即new ProviderCultureResult(options.DefaultRequestCulture.Culture.Name
。
RequestCultureProvider
public interface IRequestCultureProvider
{
// 確定當前請求的區域性,我們要實現這個介面
Task<ProviderCultureResult?> DetermineProviderCultureResult(HttpContext httpContext);
}
public abstract class RequestCultureProvider : IRequestCultureProvider
{
// 指代空區域性結果
protected static readonly Task<ProviderCultureResult?> NullProviderCultureResult = Task.FromResult(default(ProviderCultureResult));
// 中介軟體 RequestLocalizationMiddleware 的選項
public RequestLocalizationOptions? Options { get; set; }
public abstract Task<ProviderCultureResult?> DetermineProviderCultureResult(HttpContext httpContext);
}
public class CustomHeaderRequestCultureProvider : RequestCultureProvider
{
// Header 名稱,預設為 Accept-Language
public string HeaderName { get; set; } = HeaderNames.AcceptLanguage;
// 當 Header 值有多個時,最多取前 n 個
public int MaximumHeaderValuesToTry { get; set; } = 3;
public override Task<ProviderCultureResult> DetermineProviderCultureResult(HttpContext httpContext)
{
ArgumentException.ThrowIfNullOrEmpty(nameof(httpContext));
ArgumentException.ThrowIfNullOrEmpty(nameof(HeaderName));
var acceptLanguageHeader = httpContext.Request.GetTypedHeaders().GetList<StringWithQualityHeaderValue>(HeaderName);
if (acceptLanguageHeader == null || acceptLanguageHeader.Count == 0)
{
return NullProviderCultureResult;
}
var languages = acceptLanguageHeader.AsEnumerable();
if (MaximumHeaderValuesToTry > 0)
{
languages = languages.Take(MaximumHeaderValuesToTry);
}
var orderedLanguages = languages.OrderByDescending(h => h, StringWithQualityHeaderValueComparer.QualityComparer)
.Select(x => x.Value).ToList();
if (orderedLanguages.Count > 0)
{
return Task.FromResult(new ProviderCultureResult(orderedLanguages));
}
return NullProviderCultureResult;
}
}
app.UseRequestLocalization(options =>
{
var cultures = new[] { "zh-CN", "en-US", "zh-TW" };
options.AddSupportedCultures(cultures);
options.AddSupportedUICultures(cultures);
options.SetDefaultCulture(cultures[0]);
options.RequestCultureProviders.Insert(0, new CustomHeaderRequestCultureProvider { HeaderName = "X-Lang" });
}
你可能和我一樣,不太喜歡 .resx 資原始檔,想要將多語言設定到 json 檔案中,雖然微軟並沒有提供完整地實現,但是社群已經有大佬根據介面規範為我們寫好了,這裡推薦使用My.Extensions.Localization.Json
。
ASP.NET Core 也支援 PO 檔案,如果有興趣,請自行了解。
只需要將AddLocalization
替換為AddJsonLocalization
即可:
builder.Services.AddJsonLocalization(options => options.ResourcesPath = "JsonResources");
後面就是在 json 檔案中設定多語言了,例如:
{
"HelloWorld": "Hello,World!"
}
{
"HelloWorld": "你好,世界!"
}
現在,基礎用法我們已經瞭解了,接下來就一起學習一下它背後的原理吧。
鑑於涉及到的原始碼較多,所以為了控制文章長度,下面只列舉核心程式碼。
先來看下AddLocalization
中註冊的預設實現:
public static class LocalizationServiceCollectionExtensions
{
internal static void AddLocalizationServices(IServiceCollection services)
{
services.TryAddSingleton<IStringLocalizerFactory, ResourceManagerStringLocalizerFactory>();
services.TryAddTransient(typeof(IStringLocalizer<>), typeof(StringLocalizer<>));
}
}
一共註冊了兩個實現,分別是ResourceManagerStringLocalizerFactory
和StringLocalizer<>
,先來看一下工廠:
public interface IStringLocalizerFactory
{
IStringLocalizer Create(Type resourceSource);
IStringLocalizer Create(string baseName, string location);
}
public class ResourceManagerStringLocalizerFactory : IStringLocalizerFactory
{
private readonly IResourceNamesCache _resourceNamesCache = new ResourceNamesCache();
private readonly ConcurrentDictionary<string, ResourceManagerStringLocalizer> _localizerCache =
new ConcurrentDictionary<string, ResourceManagerStringLocalizer>();
private readonly string _resourcesRelativePath;
public ResourceManagerStringLocalizerFactory(
IOptions<LocalizationOptions> localizationOptions)
{
_resourcesRelativePath = localizationOptions.Value.ResourcesPath ?? string.Empty;
if (!string.IsNullOrEmpty(_resourcesRelativePath))
{
// 將目錄分隔符「/」和「\」全部替換為「.」
_resourcesRelativePath = _resourcesRelativePath.Replace(Path.AltDirectorySeparatorChar, '.')
.Replace(Path.DirectorySeparatorChar, '.') + ".";
}
}
protected virtual string GetResourcePrefix(TypeInfo typeInfo)
{
// 程式碼不列了,直接說一下邏輯吧:
// 1. 如果資源根路徑(_resourcesRelativePath)為空,即專案的根目錄,那麼直接返回 typeInfo.FullName
// 2. 如果資源根路徑(_resourcesRelativePath)不為空,那麼需要將資源根目錄拼接在 typeInfo.FullName 中間, 按照如下格式拼接(注意裡面的是減號):"{RootNamespace}.{ResourceLocation}.{FullTypeName - RootNamespace}"
}
protected virtual string GetResourcePrefix(string baseResourceName, string baseNamespace)
{
// 邏輯同上
}
public IStringLocalizer Create(Type resourceSource)
{
var typeInfo = resourceSource.GetTypeInfo();
var baseName = GetResourcePrefix(typeInfo);
var assembly = typeInfo.Assembly;
return _localizerCache.GetOrAdd(baseName, _ => CreateResourceManagerStringLocalizer(assembly, baseName));
}
public IStringLocalizer Create(string baseName, string location)
{
return _localizerCache.GetOrAdd($"B={baseName},L={location}", _ =>
{
var assemblyName = new AssemblyName(location);
var assembly = Assembly.Load(assemblyName);
baseName = GetResourcePrefix(baseName, location);
return CreateResourceManagerStringLocalizer(assembly, baseName);
});
}
protected virtual ResourceManagerStringLocalizer CreateResourceManagerStringLocalizer(
Assembly assembly,
string baseName)
{
return new ResourceManagerStringLocalizer(
new ResourceManager(baseName, assembly), // 指定了資源的基礎名和所屬程式集
assembly,
baseName,
_resourceNamesCache);
}
}
可以看到,Create(Type resourceSource)
和Create(string baseName, string location)
的實現都是通過CreateResourceManagerStringLocalizer
來建立的,並且範例型別就是ResourceManagerStringLocalizer
。另外,還通過_localizerCache
將已建立的資源範例快取了下來,避免了重複建立的開銷,只不過由於快取 Key 的構造規則不同,兩者建立的範例並不能共用。
如果你現在就想要驗證一下 HomeController 中的 Localizer 是否是相同的,你會發現通過建構函式直接注入的 IStringLocalizer<>._localizer 才是真正幹活,你可以參考這段程式碼來獲取它:
typeof(Microsoft.Extensions.Localization.StringLocalizer<GlobalizationAndLocalization.SharedResource>).GetField("_localizer", BindingFlags.NonPublic | BindingFlags.GetField | BindingFlags.Instance).GetValue(mySharedLocalizer)
接著看ResourceManagerStringLocalizer
的實現細節:
public interface IStringLocalizer
{
LocalizedString this[string name] { get; }
LocalizedString this[string name, params object[] arguments] { get; }
IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures);
}
public class ResourceManagerStringLocalizer : IStringLocalizer
{
// 將不存在的資源 Key 進行快取
private readonly ConcurrentDictionary<string, object?> _missingManifestCache = new ConcurrentDictionary<string, object?>();
// 用於操作 .resx 資原始檔
private readonly ResourceManager _resourceManager;
private readonly IResourceStringProvider _resourceStringProvider;
private readonly string _resourceBaseName;
public ResourceManagerStringLocalizer(
ResourceManager resourceManager,
Assembly resourceAssembly,
string baseName, // 資源的基礎名稱,類似於 xxx.xxx.xxx
IResourceNamesCache resourceNamesCache)
: this(
resourceManager,
new AssemblyWrapper(resourceAssembly),
baseName,
resourceNamesCache)
{
}
internal ResourceManagerStringLocalizer(
ResourceManager resourceManager,
AssemblyWrapper resourceAssemblyWrapper,
string baseName,
IResourceNamesCache resourceNamesCache
: this(
resourceManager,
new ResourceManagerStringProvider(
resourceNamesCache,
resourceManager,
resourceAssemblyWrapper.Assembly,
baseName),
baseName,
resourceNamesCache)
{
}
internal ResourceManagerStringLocalizer(
ResourceManager resourceManager,
IResourceStringProvider resourceStringProvider,
string baseName,
IResourceNamesCache resourceNamesCache)
{
_resourceStringProvider = resourceStringProvider;
_resourceManager = resourceManager;
_resourceBaseName = baseName;
_resourceNamesCache = resourceNamesCache;
}
public virtual LocalizedString this[string name]
{
get
{
var value = GetStringSafely(name, culture: null);
// LocalizedString 包含了 資源名、資源值、資源是否不存在、資源搜尋位 等資訊
return new LocalizedString(name, value ?? name, resourceNotFound: value == null, searchedLocation: _resourceBaseName);
}
}
public virtual LocalizedString this[string name, params object[] arguments]
{
get
{
var format = GetStringSafely(name, culture: null);
var value = string.Format(CultureInfo.CurrentCulture, format ?? name, arguments);
return new LocalizedString(name, value, resourceNotFound: format == null, searchedLocation: _resourceBaseName);
}
}
public virtual IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) =>
GetAllStrings(includeParentCultures, CultureInfo.CurrentUICulture);
protected IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures, CultureInfo culture)
{
// 通過 culture 獲取所有資源,原理與通過資源名獲取類似
// 需要注意的是,它是通過 yield return 返回的
}
// 所謂 Safely,就是當 資源名 不存在時,不會丟擲異常,而是返回 null
protected string? GetStringSafely(string name, CultureInfo? culture)
{
var keyCulture = culture ?? CultureInfo.CurrentUICulture;
var cacheKey = $"name={name}&culture={keyCulture.Name}";
// 資源已快取為不存在,直接返回 null
if (_missingManifestCache.ContainsKey(cacheKey))
{
return null;
}
try
{
// 通過 ResourceManager 獲取資源
return _resourceManager.GetString(name, culture);
}
catch (MissingManifestResourceException)
{
// 若資源不存在,則快取
_missingManifestCache.TryAdd(cacheKey, null);
return null;
}
}
}
好了,資源的載入流程我們已經清楚了,還有一個StringLocalizer<>
需要看一下:
public interface IStringLocalizer<out T> : IStringLocalizer
{
}
public class StringLocalizer<TResourceSource> : IStringLocalizer<TResourceSource>
{
private readonly IStringLocalizer _localizer;
public StringLocalizer(IStringLocalizerFactory factory)
{
_localizer = factory.Create(typeof(TResourceSource));
}
public virtual LocalizedString this[string name] => _localizer[name];
public virtual LocalizedString this[string name, params object[] arguments] => _localizer[name, arguments];
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) =>
_localizer.GetAllStrings(includeParentCultures);
}
其實很簡單,本質上還是通過工廠建立的在地化範例,真正幹活的其實是它的私有變數_localizer
,泛型只是一層包裝。
現在StringLocalizer
的原理我們已經搞清楚了,但是資料註解在地化是如何實現的呢?它啊,其實也是通過StringLocalizer
實現的,看:
public static IMvcCoreBuilder AddDataAnnotationsLocalization(
this IMvcCoreBuilder builder,
Action<MvcDataAnnotationsLocalizationOptions>? setupAction)
{
AddDataAnnotationsLocalizationServices(services, setupAction);
return builder;
}
public static void AddDataAnnotationsLocalizationServices(
IServiceCollection services,
Action<MvcDataAnnotationsLocalizationOptions>? setupAction)
{
services.AddLocalization();
// 如果傳入的 setup 委託不為空則使用該委託設定 MvcDataAnnotationsLocalizationOptions,
if (setupAction != null)
{
services.Configure(setupAction);
}
// 否則使用預設的 MvcDataAnnotationsLocalizationOptionsSetup 進行設定
else
{
services.TryAddEnumerable(
ServiceDescriptor.Transient
<IConfigureOptions<MvcDataAnnotationsLocalizationOptions>,
MvcDataAnnotationsLocalizationOptionsSetup>());
}
}
internal class MvcDataAnnotationsLocalizationOptionsSetup : IConfigureOptions<MvcDataAnnotationsLocalizationOptions>
{
public void Configure(MvcDataAnnotationsLocalizationOptions options)
{
options.DataAnnotationLocalizerProvider = (modelType, stringLocalizerFactory) =>
stringLocalizerFactory.Create(modelType);
}
}
可以看到,MvcDataAnnotationsLocalizationOptions
提供了一個委託DataAnnotationLocalizerProvider
,它接收兩個引數,Type
和IStringLocalizerFactory
,返回一個IStringLocalizer
。從這裡我們就可以看出來,它的在地化就是通過IStringLocalizer
來實現的。
預設情況下,它的在地化器指向當前模型類資源,我上面提到過,可以將其自定義為從共用資源中獲取,這下你就理解為啥所有模類都會受影響了吧。
IViewLocalizer
、IHtmlLocalizer
和IHtmlLocalizer<>
這裡就不再深入了,畢竟現在前端更多的是用三大前端框架,等用到的時候再去了解吧。
RequestLocalizationMiddleware
的作用主要是解析並設定當前請求的區域文化,以便於在地化器可以正常工作。
我們可以通過RequestLocalizationOptions
對該中介軟體進行設定,可設定項如下:
public class RequestLocalizationOptions
{
private RequestCulture _defaultRequestCulture =
new RequestCulture(CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture);
public RequestLocalizationOptions()
{
RequestCultureProviders = new List<IRequestCultureProvider>
{
new QueryStringRequestCultureProvider { Options = this },
new CookieRequestCultureProvider { Options = this },
new AcceptLanguageHeaderRequestCultureProvider { Options = this }
};
}
// 預設請求區域文化,預設值:當前區域文化
public RequestCulture DefaultRequestCulture
{
get => _defaultRequestCulture;
set
{
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
_defaultRequestCulture = value;
}
}
// 是否允許回退到父區域文化,預設值:true
public bool FallBackToParentCultures { get; set; } = true;
// 是否允許回退到父UI區域文化,預設值:true
public bool FallBackToParentUICultures { get; set; } = true;
// 是否要將當前請求的區域文化設定到響應頭 Content-Language 中,預設值:false
public bool ApplyCurrentCultureToResponseHeaders { get; set; }
// 受支援的區域文化列表,預設僅支援當前區域文化
public IList<CultureInfo>? SupportedCultures { get; set; } = new List<CultureInfo> { CultureInfo.CurrentCulture };
// 受支援的UI區域文化列表,預設僅支援當前UI區域文化
public IList<CultureInfo>? SupportedUICultures { get; set; } = new List<CultureInfo> { CultureInfo.CurrentUICulture };
// 請求區域文化提供器列表
public IList<IRequestCultureProvider> RequestCultureProviders { get; set; }
// 設定受支援的區域文化(注意,它的行為是 Set,而不是 Add)
public RequestLocalizationOptions AddSupportedCultures(params string[] cultures)
{
var supportedCultures = new List<CultureInfo>(cultures.Length);
foreach (var culture in cultures)
{
supportedCultures.Add(new CultureInfo(culture));
}
SupportedCultures = supportedCultures;
return this;
}
// 設定受支援的UI區域文化(注意,它的行為是 Set,而不是 Add)
public RequestLocalizationOptions AddSupportedUICultures(params string[] uiCultures)
{
var supportedUICultures = new List<CultureInfo>(uiCultures.Length);
foreach (var culture in uiCultures)
{
supportedUICultures.Add(new CultureInfo(culture));
}
SupportedUICultures = supportedUICultures;
return this;
}
// 設定預設區域文化
public RequestLocalizationOptions SetDefaultCulture(string defaultCulture)
{
DefaultRequestCulture = new RequestCulture(defaultCulture);
return this;
}
}
下面看一下RequestLocalizationMiddleware
中介軟體的實現:
public class RequestLocalizationMiddleware
{
// 區域文化回退最大深度,5 層已經很足夠了
private const int MaxCultureFallbackDepth = 5;
private readonly RequestDelegate _next;
private readonly RequestLocalizationOptions _options;
public RequestLocalizationMiddleware(RequestDelegate next, IOptions<RequestLocalizationOptions> options)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_options = options.Value;
}
public async Task Invoke(HttpContext context)
{
// 預設當前請求區域文化為 options 中設定的預設值
var requestCulture = _options.DefaultRequestCulture;
IRequestCultureProvider? winningProvider = null;
// 如果存在 Provider,則通過 Provider 解析當前請求中設定的區域文化
if (_options.RequestCultureProviders != null)
{
foreach (var provider in _options.RequestCultureProviders)
{
var providerResultCulture = await provider.DetermineProviderCultureResult(context);
// 如果解析出來為 null,則繼續讓後續的 Provider 繼續解析
if (providerResultCulture == null)
{
continue;
}
var cultures = providerResultCulture.Cultures;
var uiCultures = providerResultCulture.UICultures;
CultureInfo? cultureInfo = null;
CultureInfo? uiCultureInfo = null;
if (_options.SupportedCultures != null)
{
// 檢查區域文化(可能有多個)是否支援,如果不支援則返回 null
cultureInfo = GetCultureInfo(
cultures,
_options.SupportedCultures,
_options.FallBackToParentCultures);
}
if (_options.SupportedUICultures != null)
{
// 檢查UI區域文化(可能有多個)是否支援,如果不支援則返回 null
uiCultureInfo = GetCultureInfo(
uiCultures,
_options.SupportedUICultures,
_options.FallBackToParentUICultures);
}
// 如果區域文化和UI區域文化均不受支援,則視為解析失敗,繼續讓下一個 Provider 解析
if (cultureInfo == null && uiCultureInfo == null)
{
continue;
}
// 兩種區域文化若有為 null 的,則賦 options 中設定的預設值
// 注意:我們上面講 Provider 時提到過,如果只傳了 culture 和 ui-culture 其中的一個值,會將該值賦值到兩者,這個行為是 Provider 中執行的,不要搞混咯
cultureInfo ??= _options.DefaultRequestCulture.Culture;
uiCultureInfo ??= _options.DefaultRequestCulture.UICulture;
var result = new RequestCulture(cultureInfo, uiCultureInfo);
requestCulture = result;
winningProvider = provider;
// 解析成功,直接跳出
break;
}
}
context.Features.Set<IRequestCultureFeature>(new RequestCultureFeature(requestCulture, winningProvider));
// 將當前區域文化資訊設定到當前請求的執行緒,便於後續在地化器讀取
SetCurrentThreadCulture(requestCulture);
if (_options.ApplyCurrentCultureToResponseHeaders)
{
var headers = context.Response.Headers;
headers.ContentLanguage = requestCulture.UICulture.Name;
}
await _next(context);
}
private static void SetCurrentThreadCulture(RequestCulture requestCulture)
{
CultureInfo.CurrentCulture = requestCulture.Culture;
CultureInfo.CurrentUICulture = requestCulture.UICulture;
}
private static CultureInfo? GetCultureInfo(
IList<StringSegment> cultureNames,
IList<CultureInfo> supportedCultures,
bool fallbackToParentCultures)
{
foreach (var cultureName in cultureNames)
{
if (cultureName != null)
{
// 裡面通過遞迴查詢支援的區域文化(包括回退的)
var cultureInfo = GetCultureInfo(cultureName, supportedCultures, fallbackToParentCultures, currentDepth: 0);
if (cultureInfo != null)
{
return cultureInfo;
}
}
}
return null;
}
}
通過以上內容,我們可以總結出以下核心知識點:
IStringLocalizer
或IStringLocalizer<>
:文字在地化器,是最常用的,可以通過依賴注入獲取,也可以通過IStringLocalizerFactory
來獲取。IStringLocalizer<>
是對IStringLocalizer
的一層包裝。IHtmlLocalizer
或IHtmlLocalizer<>
:HTML在地化器,顧名思義,可以在地化HTML文字而不會對其編碼。可以通過依賴注入獲取,也可以通過IHtmlLocalizerFactory
來獲取。IViewLocalizer
:檢視在地化器,用於前端檢視的在地化。AddLocalization
設定資源根目錄,並註冊在地化服務IStringLocalizer<>
和IStringLocalizerFactory
AddDataAnnotationsLocalization
註冊資料註解在地化服務,主要是設定DataAnnotationLocalizerProvider
委託AddViewLocalization
註冊檢視在地化服務IViewLocalizer
、IHtmlLocalizer<>
和IHtmlLocalizerFactory
UseRequestLocalization
啟用請求在地化中介軟體RequestLocalizationMiddleware
,它可以從請求中解析出當前請求的區域文化資訊並設定到當前的處理執行緒中。
AddSupportedCultures
和AddSupportedUICultures
設定受支援的 Cultures 和 UICulturesSetDefaultCulture
設定預設 CultureRequestCultureProvider
:
QueryStringRequestCultureProvider
:通過在 Query 中設定"culture"、"ui-culture"的值,例如 ?culture=zh-CN&ui-culture=zh-CNCookieRequestCultureProvider
:通過Cookie中設定名為 ".AspNetCore.Culture" Key 的值,值形如 c=zh-CN|uic=zh-CNAcceptLanguageHeaderRequestCultureProvider
:從請求頭中設定 "Accept-Language" 的值AddInitialRequestCultureProvider
新增自定義RequestCultureProvider
,可以通過委託傳入解析邏輯,也可以繼承RequestCultureProvider
抽象類來編寫更復雜的邏輯。My.Extensions.Localization.Json
將資原始檔(.resx)更換為 Json 檔案。