しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

ASP.NET Core と Azure Functions でのローカライズについて一通り試した

Twitter で ASP.NET Core / Azure Functions のローカライズに関する話になった時に、そういえば試してなかったなと思ったので一通り触って理解を深めておきました。

考えられる部分、全て触ってみたので非常に長くなりました。概要を先にまとめます。

  • 規約ベースで使用するリソースが解決される
    • ビューはファイルパスを、コントローラなどの型は名前空間を利用する
    • ResourcesPath で設定したパスをベースとして使う
  • リソースにはデフォルトで resx が使われる
    • 拡張可能なので JSON や DB などをソースとしても使える
  • リソースキーとしてデフォルトのカルチャ向けテキストを使うと導入が楽
    • 日本語で書くのは少し抵抗がある…
  • ReSharper を使うとリソースキーの存在チェックをしてくれるので便利

規約ベースでリソースが解決されるので、コントローラやビュー単位でリソースを用意することで保守性が上がります。共通して使うリソースはダミークラスを用意して解決します。

後から度々出てくる Culture と UICulture については、先に正しく理解しておかないとはまります。

  • Culture (例 : CultureInfo.CurrentCulture)
    • 日付、数値のフォーマットなどに使われるカルチャ
  • UICulture (例 : CultureInfo.CurrentUICulture)
    • インターフェースの表示言語に使われるカルチャ

今回は UI 部分だけの対応ですが、日付や数値などのフォーマットも考慮する必要があります。

Web の世界だとあまり見ないかも知れないですが、Azure Portal は別々に設定できます。

f:id:shiba-yan:20190824195624p:plain

Azure Portal の設定で言語とフォーマットが別々で指定出来るように、.NET の世界でも Culture と UICulture は区別されています。

ReSharper を使っていると、日付や数値を文字列に変換する時に InvariantCulture を使えと言われるのは、この辺りが理由になっています。単純に ToString を呼ぶとカルチャによって結果が変わる可能性があります。

実際に今回は UI 周りのローカライズを試していくわけですが、その時にメインとなるのが .NET Core から用意された Microsoft.Extensions.Localization です。

ASP.NET Core に依存しないライブラリなので、DI が使えればコンソールアプリケーションでも使えます。

ASP.NET Core でのローカライズ

基本はドキュメントが充実しているので、まず読んでおくと良いです。色々と書いてありますが、文字列リソースを DI で切り替えて使う仕組みが用意されているという話です。

少し古い情報になりますが、こちらの記事が実際のローカライズ実装にとても参考になります。

ASP.NET Core のローカライズを構成する要素は以下の 4 つです。

  • 使用するカルチャを決定する RequestLocalizationMiddleware / RequestCultureProvider
  • カルチャと名前空間に従って言語リソースを読み込む IStringLocalizer<T>
  • ビューのローカライズを行う IViewLocalizer / IHtmlLocalizer<T>
  • DataAnnotations のローカライズを行う DataAnnotationsLocalizationServices

一般的にはリクエスト単位でカルチャを選択する必要があるので、Core MVC では Middleware を使って使用するカルチャを決定する仕組みが用意されています。

デフォルトの設定では以下の順番でカルチャが決定されますが、カスタマイズは可能です。

  1. Query String (culture / ui-culture)
  2. Cookie (.AspNetCore.Culturec / uic)
  3. Accept-Language

テンプレート的な使い方が出来る Startup クラスを載せておきます。対応するカルチャは en / ja です。

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // AddMvc より先に追加する
        services.AddLocalization(options => options.ResourcesPath = "Resources");

        services.AddMvc()
                .AddViewLocalization()
                .AddDataAnnotationsLocalization()
                .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

        services.Configure<RequestLocalizationOptions>(options =>
        {
            var supportedCultures = new[]
            {
                "en",
                "ja"
            };

            options.DefaultRequestCulture = new RequestCulture("en");

            options.AddSupportedCultures(supportedCultures);
            options.AddSupportedUICultures(supportedCultures);
        });
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        // UseMvc より先に呼び出す
        app.UseRequestLocalization();

        app.UseHttpsRedirection();
        app.UseStaticFiles();
        app.UseCookiePolicy();

        app.UseMvc(routes =>
        {
            routes.MapRoute(
                name: "default",
                template: "{controller=Home}/{action=Index}/{id?}");
        });
    }
}

対応するカルチャには ja-jpen-us を設定したくなりますが、Chrome や Edge などのブラウザは Accept-Language にニュートラル言語な ja を含めてくるので、ja-jp を設定すると動きません。

ハイフンが増える度に詳細なカルチャを表していますが、Provider はデフォルトでカルチャのフォールバックが有効となっているので、ja を設定しておけば ja-jp が来ても日本語で表示されます。

DI で各種 Localizer を取得するパターン

Core MVC では以下の 3 つのパターンを使ってローカライズが行えます。規約ベースで解決したリソースファイルから、該当するキーの文字列を引っ張ってくる機能。という感じです。

  • IStringLocalizer / IStringLocalizer<T>
    • ローカライズ結果を LocalizedString として返す
    • タグが含まれている場合は HTML エンコードされる
  • IHtmlLocalizer / IHtmlLocalizer<T>
    • ローカライズ結果を LocalizedHtmlString として返す
    • キー名は HTML エンコードせず、パラメータのみエンコードするのでタグが使える
  • IViewLocalizer
    • 基本は IHtmlLocalizer と同じで、リソース解決時の規約だけが異なる
    • コンパイル後の Razor は _Views_Home_Index のようなクラス名になるので型ベースだと詰む

ビュー以外は名前空間とクラス名ベースでリソースが解決されるので、コントローラなどの役割に依存せずにシンプルにローカライズされた文字列を引っ張ってくることが出来ます。

とりあえず Controller / Tag Helper / View Components / View それぞれで試しました。プロジェクトの構造は以下のようにしています。

f:id:shiba-yan:20190826181043p:plain

それぞれの実装は非常にシンプルに文字列を引っ張ってくるだけです。フォーマットや HTML タグを使ってはいますが、エンコードされるかどうかの違いしかありません。

public class HomeController : Controller
{
    public HomeController(IStringLocalizer<HomeController> localizer)
    {
        _localizer = localizer;
    }

    private readonly IStringLocalizer<HomeController> _localizer;

    public IActionResult Index()
    {
        ViewBag.Message = _localizer["Hello, {0}", "かずあき"];

        return View();
    }
}
public class LocalizedTagHelper : TagHelper
{
    public LocalizedTagHelper(IHtmlLocalizer<LocalizedTagHelper> localizer)
    {
        _localizer = localizer;
    }

    private readonly IHtmlLocalizer<LocalizedTagHelper> _localizer;

    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        var content = await output.GetChildContentAsync();

        output.Content.SetHtmlContent(_localizer["<b>Hello, {0}</b>", content.GetContent()]);
    }
}
public class LocalizedViewComponent : ViewComponent
{
    public LocalizedViewComponent(IStringLocalizer<LocalizedViewComponent> localizer)
    {
        _localizer = localizer;
    }

    private readonly IStringLocalizer<LocalizedViewComponent> _localizer;

    public IViewComponentResult Invoke(string name, int age)
    {
        ViewBag.Message = _localizer["{0} is {1} years old.", name, age];

        return View();
    }
}

ビューでは作成した Tag Helper や View Components を呼び出しているのでごちゃっとしています。

日付を出しているのは、カルチャが切り替わったことを分かりやすくするためです。

@{
  ViewData["Title"] = "Home Page";
}

@inject IViewLocalizer Localizer

DateTime.Now = @DateTime.Now.ToString()

<hr />

IStringLocalizer = @ViewBag.Message

<hr />

IViewLocalizer = @Localizer["<b>Mr. {0}</b>", "かずあき"]

<hr />

Tag Helper = <localized>かずあき</localized>

<hr />

View Component = @await Component.InvokeAsync("Localized", new { name = "かずあき", age = 50 })

作成したクラス、ビューに対応するリソースは以下のように準備しました。

今回はカルチャ無しのリソースは用意しませんでしたが、見つからない場合はリソースキーがそのまま使われるので、導入が楽という訳です。デフォルトは en にしているのでリソースキーは英語で書いています。

f:id:shiba-yan:20190824183105p:plain

アプリケーションを実行して、クエリ文字列でカルチャを切り替えてアクセスすると、言語が切り替わっていることが確認できます。もちろん culture=enui-culture=ja というように別々に指定すると、日付のフォーマットと表示言語がそれぞれ別になります。

f:id:shiba-yan:20190824233020p:plain

クエリ文字列を指定しない場合には、ブラウザの言語設定が使われるので日本語で表示されています。

リソースキーをデフォルトのカルチャとして実装するか、それともリソースファイルを用意するかは悩ましいところです。リソースを作成するのが結局のところ一番大変です。

DataAnnotations のエラーメッセージをローカライズ

ASP.NET MVC では DataAnnotations のメッセージは ErrorMessageResourceTypeErrorMessageResourceName を指定してローカライズしていましたが、Core MVC では ErrorMessage をリソースキーとして自動で扱ってくれるようになりました。

なので以下のようなクラスを書いておくと、自動でローカライズ対象になります。

public class FormRequest
{
    [Required(ErrorMessage = "The {0} field is required.")]
    [Display(Name = nameof(Name))]
    public string Name { get; set; }

    [Required(ErrorMessage = "The {0} field is required.")]
    [Display(Name = nameof(Age))]
    public int? Age { get; set; }
}

この場合も規約ベースでリソースが解決されるので、以下のような構造にしておきます。

f:id:shiba-yan:20190824231931p:plain

適当にフォームを用意してエラーメッセージを表示されると、ちゃんとローカライズされています。

f:id:shiba-yan:20190824231737p:plain

.NET Core では言語パックが提供されなくなったので、常に英語メッセージになっていますが、元々デフォルトのメッセージは使ったことが無かったので特に困らないかなと思っています。

ビューを個別に用意するパターン

最後に少し特別なパターンとして、ビューをカルチャ単位で分ける方法も提供されています。特に _Layout.cshtml などはリソースファイルに起こすのが大変なので、ビュー毎分けるのも一つの手です。

分ける方法としてはディレクトリとサフィックスが選べます。割と分かりやすい方法です。

public void ConfigureServices(IServiceCollection services)
{
    services.AddLocalization(options => options.ResourcesPath = "Resources");

    // デフォルトだと Suffix が使われる
    services.AddMvc()
            .AddViewLocalization(LanguageViewLocationExpanderFormat.SubFolder)
            //.AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
            .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}

ビューは以下のような形で置いておくと、自動でカルチャに合わせて使われます。

f:id:shiba-yan:20190824203326p:plain

全てのカルチャに対してビューを用意するのは正直凄く面倒なので、この辺りは上手く使い分けが必要です。

ASP.NET Core に関しては大体このくらい理解しておけば、問題なくアプリケーションのローカライズが行えると思います。MVC 5 に比べるとかなり洗練されたなという感想を持ちました。

Azure Functions でのローカライズ

Azure Functions は ASP.NET Core ベースなので同じ方法が使えるはずですが、ランタイムとアプリケーションが分離されている関係上、サテライトアセンブリが読めない問題があるようです。

サテライトアセンブリを正しく読めないなら、自分で予め読み込んでおけば良いのではと思ったので試すと、すんなりと上手くいきました。

Startup を作成して、その中で指定したカルチャのサテライトアセンブリを読み込みます。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");

        LoadSatelliteAssembly("ja", "es");
    }

    private void LoadSatelliteAssembly(params string[] uiCultures)
    {
        foreach (var supportedCulture in uiCultures)
        {
            var assemblyDirectory = Path.Combine(Environment.CurrentDirectory, "bin", supportedCulture);

            foreach (var file in Directory.EnumerateFiles(assemblyDirectory, "*.resources.dll"))
            {
                Assembly.LoadFrom(file);
            }
        }
    }
}

後は ASP.NET Core の時と同じように DI を使って IStringLocalizer<T> を受け取って使えますが、カルチャを決定するための Middleware は組み込めないため、自力で決定する必要があります。

今回は適当にクエリ文字列から CultureInfo.CurrentUICulture を設定するようにしました。

public class Function1
{
    public Function1(IStringLocalizer<Function1> localizer)
    {
        _localizer = localizer;
    }

    private readonly IStringLocalizer<Function1> _localizer;

    [FunctionName("Function1")]
    public async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
        ILogger log)
    {
        log.LogInformation("C# HTTP trigger function processed a request.");

        string name = req.Query["name"];

        string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
        dynamic data = JsonConvert.DeserializeObject(requestBody);
        name = name ?? data?.name;

        // query string からカルチャを拾って UI 向けに設定
        string culture = req.Query["culture"];

        CultureInfo.CurrentUICulture = new CultureInfo(culture ?? "en");

        return name != null
            ? (ActionResult)new OkObjectResult((string)_localizer["Hello, {0}", name])
            : new BadRequestObjectResult("Please pass a name on the query string or in the request body");
    }
}

コンストラクタで IHttpContextAccessor を取ることも出来るので、良い感じに自動でカルチャを決定する仕組みは Function でも実現できるとは思います。

リソースも ASP.NET Core と同じように規約ベースで配置しておけば良いです。

f:id:shiba-yan:20190824184837p:plain

Function を実行して、適当にカルチャをクエリ文字列で渡すとリソースが切り替わることが確認できます。

f:id:shiba-yan:20190824185140p:plain

HttpTrigger の場合は簡単ですが、それ以外の Queue などでは別の情報を使ってカルチャを決定する必要があります。ユーザー毎に言語設定を持っておいて、それを Function からは読み取るといった形ですね。

結局のところはリソースを個別に用意するのが大変なので、割と悩ましいです。しかし便利になっています。