理解ASP.NET Core - 全球化&本地化&多語言(Globalization and Localization)

注:本文隸屬於《理解ASP.NET Core》系列文章,請查看置頂博客或點擊此處查看全文目錄

概述

在衆多知名品牌的網站中,比如微軟官網、YouTube等,我們經常可以見到“切換頁面語言”的功能,我們可以選擇最適合的語言瀏覽頁面內容。毫無疑問,爲網站提供多種語言,頁面內容本地化,大大擴展了受衆範圍,提升了用戶體驗。

名詞術語

爲了更好地理解下面的內容,我們先來了解一下行業內通用的名詞術語:

  • Globalization (G11N):全球化,即使應用支持不同語言和區域的過程。G11N 是首字母、尾字母和它們之間字母的個數組成的,下同,不再贅述。
  • Localization (L10N):本地化,即針對特定語言和區域自定義全球化應用的過程。
  • Internationalization (I18N):國際化,又稱爲多語言,包含了全球化和本地化。
  • Culture:區域性,即一種語言文化或區域。
  • Neutral Culture:非特定區域性,即具有指定語言但不具有區域的區域性。例如“zh”、“en”,僅僅表示中文或英文,並沒有包含指定地區,如大陸、香港、臺灣等。
  • Specific Culture: 特定區域性,即具有指定語言和區域的區域性。例如“zh-CN”、“zh-HK”。
  • Parent Culture: 父區域性,例如“zh”就是“zh-CN”和“zh-HK”的父區域性。

區域代碼查詢:https://www.venea.net/web/culture_code

實現本地化

一般情況下,統一使用英文作爲多語言的字典Key,在 Web 剛進入開發階段時,最好就支持多語言,否則後續改造的工作量會比較大。當然,你可以選擇使用中文作爲 Key,不過並不太推薦,畢竟你總不能要求懂阿拉伯語的人要懂中文。

本地化器

ASP.NET Core 提供了多種本地化工具:

  • IStringLocalizer
  • IStringLocalizerFactory
  • IHtmlLocalizer
  • IViewLocalizer

IStringLocalizer

IStringLocalizerIStringLocalizer<>可以在運行時提供區域性資源,使用非常簡單,就像操作字典一樣,提供一個 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
{
}

在服務類中使用本地化

  1. 首先,注入本地化服務,並啓用中間件
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註冊了IStringLocalizerFactoryIStringLocalizer<>,並指定了資源的根目錄爲“Resources”。

然後,我們又通過UseRequestLocalization啓用了中間件RequestLocalizationMiddleware。默認情況下,該中間件支持的區域文化僅爲當前區域文化,即CultureInfo.CurrentCultureCultureInfo.CurrentUICulture,我們可以通過AddSupportedCulturesAddSupportedUICultures自定義設置多個支持的區域文化:

  • Culture:影響日期、時間、數字或貨幣的展示格式
  • UICulture:影響查找哪些區域文化資源(如.resx、json文件等),也就是說,如果這裏未添加某區域文化A,即使添加了對應區域文化A的資源文件,也無發生效。一般 Culture 和 UICulture 保持一致。

另外,當我們的服務接收到一個請求時,如果該請求未指明當前的區域文化,就會使用默認的,這裏我們通過SetDefaultCulture指定了默認區域文化爲 zh-CN

最後,通過設置ApplyCurrentCultureToResponseHeaderstrue,將當前區域信息設置到Http響應頭的Content-Language中。

  1. 接下來,我們新建“Resources/Controllers”目錄,在 Resources 目錄下新建2個共享資源文件,在 Controllers 目錄中新建2個HomeController類的資源文件,目錄結構如下:
- Resources
 - Controllers
   - HomeController.en-US.resx
   - HomeController.zh-CN.resx
 - SharedResource.en-US.resx
 - SharedResource.zh-CN.resx

並填充內容如下:

  • SharedResource.en-US.resx
名稱
CurrentTime Current Time:
  • SharedResource.zh-CN.resx
名稱
CurrentTime 當前時間:
  • HomeController.en-US.resx
名稱
HelloWorld Hello, World!
  • HomeController.zh-CN.resx
名稱
HelloWorld 你好,世界!

這些文件默認爲“嵌入的資源”

  1. 爲了優雅地使用共享資源,我們在項目根目錄下創建SharedResource僞類,用來代理共享資源。
public class SharedResource
{
    // 裏面是空的
}
  1. 最後,我們在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),檢查資源放置位置是否有誤。

在模型驗證中使用本地化

好了,我們已經掌握了本地化在服務類中的使用方法,接下來,一起來看下在模型驗證中如何使用本地化。

  1. 首先通過調用AddDataAnnotationsLocalization註冊數據註解本地化服務:
builder.Services
    .AddControllersWithViews()
    .AddDataAnnotationsLocalization();
  1. 接着在 Dtos 目錄下新建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”目錄下添加資源文件:

  • RegisterDto.en-US.resx
名稱
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
  • RegisterDto.zh-CN.resx
名稱
PasswordDoNotMatch 兩次密碼輸入不一致
PasswordIsRequired 請輸入密碼
PasswordLeastCharactersLong 密碼長度不能小於 {2}
UserNameIsRequired 請輸入用戶名
  1. 最後在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));
    });

IStringLocalizerFactory

有時,我們可能想要使用一些沒有代理類或代理類無法使用的區域資源,無法直接通過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.XXX.resx 來說,資源根目錄就是前面註冊服務時設置的 Resources,相對路徑爲 Controllers,文件基礎名爲 HomeController,所以資源基礎名稱爲 Controllers.HomeController

資源文件命名規則

是時候明確一下資源文件的命名規則了,很簡單:類的資源名稱 = 類的完整類型名 - 程序集名稱。

還是拿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>

IHtmlLocalizer

相對於IStringLocalizerIHtmlLocalizerIHtmlLocalizer<>中的資源可以包含 HTML 代碼,並使其能夠在前端頁面中正常渲染出來。

通常情況下,我們僅僅需要本地化文本內容,而不會包含 HTML。不過這裏還是簡單介紹一下。

  1. 首先調用AddViewLocalization註冊服務
builder.Services
    .AddControllersWithViews()
    .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix);

此處我們註冊了IHtmlLocalizerFactoryIHtmlLocalizer<>,以及接下來要講的IViewLocalizer共3個服務,並且通過LanguageViewLocationExpanderFormat.Suffix指定了視圖(View)語言資源命名格式爲後綴,即 <view-name>.<language>.resx

  1. 接着在 SharedResource 的資源文件中添加以下內容:
  • SharedResource.en-US.resx
名稱
Welcome <b>Welcome {0}!</b>
  • SharedResource.zh-CN.resx
名稱
Welcome <b>歡迎 {0}!</b>
  1. 最後自己可以在視圖中看一下效果,文本確實被加粗了:
@inject IHtmlLocalizer<SharedResource> HtmlSharedResource

<div class="text-center">
    @HtmlSharedResource["Welcome", "jjj"]
</div>

IViewLocalizer

IViewLocalizer是專門服務於視圖的,他沒有泛型版本,也沒有工廠類,所以它只能用來獲取當前視圖資源文件中的資源,如果想要使用其他資源,可以使用IStringLocalizerIHtmlLocalizer

它繼承自IHtmlLocalizer,所以它也支持資源中包含 HTML 代碼:

public interface IViewLocalizer : IHtmlLocalizer { }

下面我們在Views/Home/Index.cshtml中演示一下效果。

上面我們已經通過AddViewLocalizationIViewLocalizer服務註冊到容器中了。

  1. 首先在Resources/Views/Home目錄下增加以下兩個資源文件,並設置內容:
  • Index.en-US.resx
名稱
Welcome Welcome {0} !!!
  • Index.zh-CN
名稱
Welcome 歡迎 {0} !!!
  1. 在視圖中使用並查看效果
@inject IViewLocalizer L 

<div class="text-center">
    <h1>@L["Welcome", "jjj"]</h1>
</div>

區域性回退

當請求的區域資源未找到時,會回退到該區域的父區域資源,例如檔區域文化爲 zh-CN 時,HomeController資源文件查找順序如下:

  • HomeController.zh-CN.resx
  • HomeController.zh.resx
  • HomeController.resx

如果都沒找到,則會返回資源 Key 本身。

配置 CultureProvider

上面,我們通過在 url 中添加參數 culture 來設置當前請求的區域信息,實際上,ASP.NET Core 是通過IRequestCultureProvider接口來爲我們提供區域的設置方式。

內置的 RequestCultureProvider

可以通過以下代碼查看已添加的 Provider:

app.UseRequestLocalization(options =>
{
    var cultureProviders = options.RequestCultureProviders;
}

可以看到,ASP.NET Core 框架默認添加了3種 Provider,分別爲:

  1. QueryStringRequestCultureProvider:通過在 Query 中設置"culture"、"ui-culture"的值,例如 ?culture=zh-CN&ui-culture=zh-CN
  2. CookieRequestCultureProvider:通過Cookie中設置名爲 ".AspNetCore.Culture" Key 的值,值形如 c=zh-CN|uic=zh-CN
  3. AcceptLanguageHeaderRequestCultureProvider:從請求頭中設置 "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的方式有兩種,分別是通過委託和繼承抽象類RequestCultureProvider

下面,我們實現一個從自定義 Header 中獲取區域文化信息的自定義RequestCultureProvider

  1. 通過委託實現自定義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

  1. 通過繼承抽象類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" });
}

使用 Json 資源文件

你可能和我一樣,不太喜歡 .resx 資源文件,想要將多語言配置到 json 文件中,雖然微軟並沒有提供完整地實現,但是社區已經有大佬根據接口規範爲我們寫好了,這裏推薦使用My.Extensions.Localization.Json

ASP.NET Core 也支持 PO 文件,如果有興趣,請自行了解。

只需要將AddLocalization替換爲AddJsonLocalization即可:

builder.Services.AddJsonLocalization(options => options.ResourcesPath = "JsonResources");

後面就是在 json 文件中配置多語言了,例如:

  • HomeController.en-US.json
{
  "HelloWorld": "Hello,World!"
}
  • HomeController.zh-CN.json
{
  "HelloWorld": "你好,世界!"
}

設計原理

現在,基礎用法我們已經瞭解了,接下來就一起學習一下它背後的原理吧。

鑑於涉及到的源碼較多,所以爲了控制文章長度,下面只列舉核心代碼。

IStringLocalizerFactory & IStringLocalizer

先來看下AddLocalization中註冊的默認實現:

public static class LocalizationServiceCollectionExtensions
{
    internal static void AddLocalizationServices(IServiceCollection services)
    {
        services.TryAddSingleton<IStringLocalizerFactory, ResourceManagerStringLocalizerFactory>();
        services.TryAddTransient(typeof(IStringLocalizer<>), typeof(StringLocalizer<>));
    }
}

一共註冊了兩個實現,分別是ResourceManagerStringLocalizerFactoryStringLocalizer<>,先來看一下工廠:

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,泛型只是一層包裝。

DataAnnotationsLocalization

現在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,它接收兩個參數,TypeIStringLocalizerFactory,返回一個IStringLocalizer。從這裏我們就可以看出來,它的本地化就是通過IStringLocalizer來實現的。

默認情況下,它的本地化器指向當前模型類資源,我上面提到過,可以將其自定義爲從共享資源中獲取,這下你就理解爲啥所有模類都會受影響了吧。

IViewLocalizer & IHtmlLocalizer

IViewLocalizerIHtmlLocalizerIHtmlLocalizer<>這裏就不再深入了,畢竟現在前端更多的是用三大前端框架,等用到的時候再去了解吧。

RequestLocalizationMiddleware

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

總結

通過以上內容,我們可以總結出以下核心知識點:

  • ASP.NET Core 提供了3種本地化器:
    • IStringLocalizerIStringLocalizer<>:文本本地化器,是最常用的,可以通過依賴注入獲取,也可以通過IStringLocalizerFactory來獲取。IStringLocalizer<>是對IStringLocalizer的一層包裝。
    • IHtmlLocalizerIHtmlLocalizer<>:HTML本地化器,顧名思義,可以本地化HTML文本而不會對其編碼。可以通過依賴注入獲取,也可以通過IHtmlLocalizerFactory來獲取。
    • IViewLocalizer:視圖本地化器,用於前端視圖的本地化。
  • 通過AddLocalization設置資源根目錄,並註冊本地化服務IStringLocalizer<>IStringLocalizerFactory
  • 通過AddDataAnnotationsLocalization註冊數據註解本地化服務,主要是設置DataAnnotationLocalizerProvider委託
  • 通過AddViewLocalization註冊視圖本地化服務IViewLocalizerIHtmlLocalizer<>IHtmlLocalizerFactory
  • 通過UseRequestLocalization啓用請求本地化中間件RequestLocalizationMiddleware,它可以從請求中解析出當前請求的區域文化信息並設置到當前的處理線程中。
    • 通過AddSupportedCulturesAddSupportedUICultures配置受支持的 Cultures 和 UICultures
    • 通過SetDefaultCulture配置默認 Culture
    • 默認提供了三種RequestCultureProvider
      • QueryStringRequestCultureProvider:通過在 Query 中設置"culture"、"ui-culture"的值,例如 ?culture=zh-CN&ui-culture=zh-CN
      • CookieRequestCultureProvider:通過Cookie中設置名爲 ".AspNetCore.Culture" Key 的值,值形如 c=zh-CN|uic=zh-CN
      • AcceptLanguageHeaderRequestCultureProvider:從請求頭中設置 "Accept-Language" 的值
    • 通過AddInitialRequestCultureProvider添加自定義RequestCultureProvider,可以通過委託傳入解析邏輯,也可以繼承RequestCultureProvider抽象類來編寫更復雜的邏輯。
  • 可以通過 Nuget 包My.Extensions.Localization.Json將資源文件(.resx)更換爲 Json 文件。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章