ASP.NET Core – Globalization & Localization

前言

之前就写过 2 篇, 只是写的很乱, 这篇作为整理版.

Asp.net core (学习笔记 路由和语言 route & language)

Asp.net core 学习笔记之 globalization & localization 复习篇

我的项目只是做语言而已, 没有做区域, 也没有 Data Annotation 的需求, 所以下面不会提到.

 

参考:

docs – Globalization and localization in ASP.NET Core

Razor Pages Localisation - SEO-friendly URLs

Using Resource Files In Razor Pages Localisation

YouTube – Introduction to Internationalization in Angular

 

基本用法:

Setup Program.cs

这篇只讲 Razor Pages 的使用, 不会讲到 MVC 和 Data Annotation.

builder.Services.AddRazorPages()
    .AddViewLocalization();

Setup Options

builder.Services.Configure<RequestLocalizationOptions>(options =>
{
    var supportedCultures = new[] { "en", "zh-Hans" };
    options.SetDefaultCulture(supportedCultures[0])
        .AddSupportedCultures(supportedCultures)
        .AddSupportedUICultures(supportedCultures);
});

定义支持的语言, 默认语言. 我项目没有区域性, 所以是 en 而不是 en-US.

最后启动就可以了

app.UseRequestLocalization();
app.MapRazorPages();

.resx

这个文件的位置是挺讲究的. .cshtml 在哪里它就在旁边. 取一样的 file name, 配上指定的 language code

位置虽然是可以改的, 但我觉得默认就很好了, follow 它吧.

用 Visual Studio 打开 .resx

Name 其实是 Key, 但是为了方便, 一般上会直接放默认语言的值. 你要放 Key (代号) 也是可以的.

pure text, HTML 都支持. 也支持 string format 代号 {0}, 调用时传入 parameters.

.cshtml 调用

@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer

<div class="text-center">
  <h1 class="display-4">About Page</h1>
  @Localizer["Hello World"]
  @Localizer["<h1>Hello World {0}</h1>", "parameter1"]
</div>

注入 IViewLocalizer, 使用方式是 Localizer["Key"]

它会返回一个对象, 而不是一个值哦.

这个对象有一个方法叫 WriteTo. Razor Pages 在 render 的时候会调用它, 最后 encode 成 HTML.

另外, Localizer["Key"] 如果没有找到 .resx file 它会返回 Key. 这个是为了方便项目提前设计. 以后才支持语言. 非常方便.

访问

https://localhost:7078/About?culture=zh-Hans&ui-culture=zh-Hans

它是通过 query params 来选择语言的哦.

 

Use Path Segment as Language Selection

上面提到, 默认用 query params 作为语言的选择, 但是 SEO 不鼓励这样做.

通常是用第一个 path segment 作为语言: /zh-Hans/about-us

builder.Services.Configure<RequestLocalizationOptions>(options =>
{
    var supportedCultures = new[] { "en", "zh-Hans" };
    options.SetDefaultCulture(supportedCultures[0])
        .AddSupportedCultures(supportedCultures)
        .AddSupportedUICultures(supportedCultures);

    options.AddInitialRequestCultureProvider(new CustomRequestCultureProvider(httpContent =>
    {
        // 这里写判断逻辑 (base on httpContent info), 最后返回指定的语言就可以了
        return Task.FromResult(new ProviderCultureResult("zh-Hans"))!;
    }));
});

通过 AddInitialRequestCultureProvider 就可以实现了.

题外话, 用 path first segment 作为语言, 需要调整 Razor Pages 的 routing 匹配哦. 请参考: ASP.NET Core – Razor Pages Routing

 

Shared Resource

上面提到的都是 1 个 .cshtml 对应 1 个 .resx. 但有时候内容一样想做抽象怎么办呢?

创建一个空的 class 和 .resx. 

namespace TestLocalization.Pages;
public class SharedResource { }

然后, 在 .cshtml 把注入换成 IHtmlLocalizer<ClassName> 就可以了

@using Microsoft.AspNetCore.Mvc.Localization
@* @inject IViewLocalizer Localizer *@
@inject IHtmlLocalizer<SharedResource> Localizer

注意: resx 的 file name 和位置也是有讲究的哦, 依据 class 的 namespace + class name

比如 namespace = ProjectName.Pages, class name = SharedResource.

那么 .resx 必须放在 /Pages/SharedResource.zh-Hans.resx

详解资料可以看这篇: Resources Search Strategy

 

在 Model.cs 使用 Localization

上面都是讲 View 如何使用 Localization。想在 Model.cs 里面使用的话,不可以注入 IViewLocalization 哦。

public class IndexModel : PageModel
{
    private readonly IStringLocalizer<IndexModel> _stringLocalizer;
    private readonly IHtmlLocalizer<IndexModel> _htmlLocalizer;

    public IndexModel(
        IStringLocalizer<IndexModel> stringLocalizer,
        IHtmlLocalizer<IndexModel> htmlLocalizer
    )
    {
        _stringLocalizer = stringLocalizer;
        _htmlLocalizer = htmlLocalizer;
    }

    public void OnGet()
    {
        var value1 = _stringLocalizer["Hello World"].Value;
        // var value2 = _htmlLocalizer["Hello World"].WriteTo(TextWriter writer, HtmlEncoder encoder);
    }
}

要注入 IStringLocalizer 或者 IHtmlLocalizer。

另外两者是有很大区别的哦,IString 内容只能是 pure text 不包含 HTML。使用的时候 _stringLocalizer["Key"] 返回一个对象,通过 .Value 获取翻译后的值。

IHtmlLocalizer 则内容可以包含 HTML。使用时 _htmlLocalizer["Key"] 返回一个对象,通过 WriteTo(writer, encoder) 获取翻译后的值,这里和 string 的 .Value 不同哦

看看例子

代码

var value1 = _stringLocalizer["Hello World {0}", "123"].Value; // "哈喽世界 123 <span>test</span>"
var value2 = _htmlLocalizer["Hello World {0}", "123"].Value;   // "哈喽世界 {0} <span>test</span>"

结果是不同的,_htmlLocalizer.Value 拿到的是还没有处理的值

正确的获取方式是

using var ms = new MemoryStream();
using var sw = new StreamWriter(ms, Encoding.UTF8);
_htmlLocalizer["Hello World {0}", "test"].WriteTo(sw, htmlEncoder);
await sw.FlushAsync();
ms.Seek(0, SeekOrigin.Begin);
using var streamReader = new StreamReader(ms, Encoding.UTF8);
var text = await streamReader.ReadToEndAsync(); // "哈喽世界 test <span>test</span>"

Tips:在 Razor Pages(.cshtml)要拿 htmlEncoder 可以直接用 this.HtmlEncoder

.rexs 的位置

仔细看, IStringLocalizer<IndexModel> 的泛型是 IndexModel class, 也就是当前的 class.

直觉会认为它应该和 View 用同一个 resx.

但其实不是, 上面有提到 SharedResource, 只要是 class 就是 namespace + class = forlder + file name, 所以是 IndexModel.zh-Hans.resx

也是醉了...因此我建议当需要这样搞时, 做一个 shared class 让 view 也统一使用 IHtmlLocalizer 会更好.

 

Localizer with specify culture

上面我们提到,Localization 是通过 middleware 拦截 request,然后通过逻辑判断 HttpContext 最终决定整个 request 使用什么语言。

那如果我们想在某个地方特别指定某种语言去翻译一段文字,而不是 follow request culture 可以吗?

可以。我们先看看源码,了解一下 Localization 是怎样工作的。

翻看源码 RequestLocalizationMiddleware.cs

在 middleware 它做了 2 件事

1. set IRequestCultureFeature(这类 Feature 属于 request 的全局变量, 可以通过 HttpContext 访问到)

2. set CultureInfo(这个是静态类来的, 也算是全局变量吧)

而在 ResourceManagerStringLocalizer.cs 里

翻译就是依据 CultureInfo 这个静态类去做的。

所以,如果我们想指定语言的话,我们就要 re-set 掉 CultureInfo

public void OnGet()
{
    CultureInfo.CurrentCulture = new CultureInfo("zh-Hans");
    CultureInfo.CurrentUICulture = new CultureInfo("zh-Hans");
    var value1 = _stringLocalizer["Hello World"].Value;
}

.cshtml

@using System.Globalization
@{
  CultureInfo.CurrentCulture = new CultureInfo("zh-Hans");
  CultureInfo.CurrentUICulture = new CultureInfo("zh-Hans");
}

set CultureInfo.CurrentUICulture 是 ASP.NET Core 5.0 之后的唯一方法,以前有一个叫 ResourceManagerWithCultureStringLocalizer 的可以更简单的做到,但是因为一些原因被拿掉了,参考:Github Issue

CurrentCulture 的作用域

参考:

Docs – 区域性和基于任务的异步操作

Stack Overflow – Keep CurrentCulture in async/await

Stack Overflow – ASP.NET MVC (Async) CurrentCulture is not shared between Controller and View

Index.cshtml.cs、Index.cshtml、_Layout.cshtml、Templated delegates

这几个地方都是独立的作用域,需要分别 set CultureInfo.CurrentCulture,超级麻烦。

比如 templated delegates

@{
  CultureInfo.CurrentCulture = new CultureInfo("zh-Hans");
  CultureInfo.CurrentUICulture = new CultureInfo("zh-Hans");

  Func<dynamic?, object> template = @<h1>@Localizer["Hello World"]</h1>;
}
   
<h1>@Localizer["Hello World"]</h1> @* 结果是: 哈喽世界 *@
@template(null) @*结果是: Hello World *@

template 的内容依然是英文。

要这样才行

@{
  CultureInfo.CurrentCulture = new CultureInfo("zh-Hans");
  CultureInfo.CurrentUICulture = new CultureInfo("zh-Hans");

  Func<dynamic?, object> template = @<h1>@{
    CultureInfo.CurrentCulture = new CultureInfo("zh-Hans");
    CultureInfo.CurrentUICulture = new CultureInfo("zh-Hans");
    @Localizer["Hello World"]
  }</h1>;
}
   
<h1>@Localizer["Hello World"]</h1> @* 结果是: 哈喽世界 *@
@template(null) @* 结果是: 哈喽世界 *@

再比如:我们在 Index.cshtml.cs

public class IndexModel : PageModel
{
    public void OnGet()
    {
        CultureInfo.CurrentCulture = new CultureInfo("zh-Hans");
    }
}

在它的 View Index.cshtml 去拿会发现,没有 set 到

@using System.Globalization
@{
    var culture = CultureInfo.CurrentCulture.DisplayName; // 还是 English
}

CurrentCulture 的作用域原理

为什么我们 set CultureInfo.CurrentCulture 不代表 request 级别的 culture?

但 Localization Middleware set 的却代表 request 级别的 culture 呢?

我们来模拟一下它的过程

public static async Task Main()
{
    Console.WriteLine("Start: " + CultureInfo.CurrentCulture.DisplayName);
    DoMiddleware();
    await DoControllerAsync();
    await DoViewAsync();
}

public static void DoMiddleware()
{
    CultureInfo.CurrentCulture = new CultureInfo("zh-Hans");
    Console.WriteLine("Set culture in middleware");
}

public static async Task DoControllerAsync()
{
    Console.WriteLine("Controller: " + CultureInfo.CurrentCulture.DisplayName);
}

public static async Task DoViewAsync()
{
    Console.WriteLine("View" + CultureInfo.CurrentCulture.DisplayName);
}

输出

正确。那我们尝试在 controller set culture 看看。

public static async Task DoControllerAsync()
{
    CultureInfo.CurrentCulture = new CultureInfo("ja");
    Console.WriteLine("Controller: " + CultureInfo.CurrentCulture.DisplayName);
}

输出

View 没有拿到 Controller set 的日语,它只拿到了 middleware set 的中文。

Why?!问题出在 async Task。

在 async Task 里面设置 CultureInfo.CurrentCulture 离开 async Task 就没了,这是 CultureInfo.CurrentCulture 的规则,想知道细节可以看上面的参考链接。

middleware 不是 async Task 所以它 set 就变成了 request 级别,而 Controller 或者 View 都是 async Task,所以 set 了只能在自己小小的区域玩。

所以,如果想 page 级别的 culture,不能在 PageModel 里,也不能在 View 里。最少需要在 PageFilter。

这里给一个例子

利用 IPageFilter 和 FilterAttribute 拦截并且设置 culture。

public class PageCultureFilter : IPageFilter
{
    public void OnPageHandlerSelected(PageHandlerSelectedContext context)
    {
        var pageCultureAttr = context.ActionDescriptor.DeclaredModelTypeInfo?.GetCustomAttribute<PageCultureAttribute>();
        if (pageCultureAttr != null) 
        {
            CultureInfo.CurrentCulture = new CultureInfo(pageCultureAttr.Culture);
            CultureInfo.CurrentUICulture = new CultureInfo(pageCultureAttr.Culture);
        }
    }

    public void OnPageHandlerExecuting(PageHandlerExecutingContext context)
    {
    }

    public void OnPageHandlerExecuted(PageHandlerExecutedContext context)
    {
    }
}

还有

public class PageCultureAttribute : ResultFilterAttribute
{
    public string Culture { get; set; }

    public PageCultureAttribute(string culture) 
    {
        Culture = culture;
    }

    public override void OnResultExecuting(ResultExecutingContext context)
    {
        CultureInfo.CurrentCulture = new CultureInfo(Culture);
        CultureInfo.CurrentUICulture = new CultureInfo(Culture);
        base.OnResultExecuting(context);
    }
    public override void OnResultExecuted(ResultExecutedContext context)
    {
        base.OnResultExecuted(context);
    }
}

这样 Index.cshtml.cs、Index.cshtml、_Layout.cshtml、Templated delegates 就都拿到相同的 culture 了。

 

获取语言相关信息

public class IndexModel : PageModel
{
    private readonly RequestLocalizationOptions _requestLocalizationOptions;
    public IndexModel(
        IOptionsSnapshot<RequestLocalizationOptions> _requestLocalizationOptionsAccessor
    )
    {
        _requestLocalizationOptions = _requestLocalizationOptionsAccessor.Value;
    }
    public void OnGet()
    {
        var languageDisplayName = HttpContext.Features.Get<IRequestCultureFeature>()!.RequestCulture.Culture.DisplayName; // "中文(简体)"
        var supportLanguageDisplayNames = _requestLocalizationOptions.SupportedCultures!.Select(s => s.DisplayName).ToList(); // base on current language ["英语", "中文(简体)"]
        var supportLanguageNativeNames = _requestLocalizationOptions.SupportedCultures!.Select(s => s.NativeName).ToList(); // ["English", "中文(简体)"]
        var supportLanguageEnglishNames = _requestLocalizationOptions.SupportedCultures!.Select(s => s.EnglishName).ToList(); // ["English", "Chinese (Simplified)"]
    }
}

 

关于 New Line

有时候 placeholder 需要 new line

<textarea placeholder="@Localizer["Hi\r\nI love you"]" rows="5"></textarea>

注意,它是 \r\n 而不是 \n 哦。

.resx 长这样

  <data name="Hi
I love you" xml:space="preserve">
    <value>嗨
我爱你</value>
  </data>

就可以 enter 去下一行。

 

Without DI & ResourceManager

IViewLocalizer、IStringLocalizer、IHtmlLocalizer 都需要 DI 注入。

如果在静态 class 方法内也想 localizer 怎么办呢?

可以用比较底层的 ResourceManger。它的规则和 IStringLocalizer 非常相似,只是调用有点不同而已。

public static class Program
{
  public static async Task Main()
  {
    CultureInfo.CurrentCulture = new CultureInfo("zh-Hans");
    CultureInfo.CurrentUICulture = new CultureInfo("zh-Hans");
    var resourceManager = new ResourceManager(typeof(MyResource).FullName!, Assembly.GetExecutingAssembly());
    var value1 = resourceManager.GetString("I love you"); // 用 CurrentCulture
    var value2 = resourceManager.GetString("I love you", culture: new CultureInfo("ms")); // 指定 culture
    Console.WriteLine(value1); // 我爱你
  } 
}

MyResource class 的 namespace

.resx 的 folder and file

和上面我们提过的 Shared Resource 查找规则是一样的。

Could not find the resource "CSharp11.Parent.Child.MyResource.resources" among the resources

如果找不到 .resx 或者 file 里面 match 不到 key 是会报错的哦。如果想像 IStringLocalizer 那样,拿不到 resource 就拿 key 当作 value,需要自己额外处理。

它也是 wrap 一层来出的.

 

posted @ 2022-03-22 20:18  兴杰  阅读(392)  评论(0编辑  收藏  举报