ASP.NET MVC ベリーシンプル多言語対応2020

## 概要

ASP.NET MVC で多言語対応をこころみてみます。多言語対応だけシンプルにこころみます。デモはこちら。

- [i18nApp Demo](https://i18napp.azurewebsites.net/)

![0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270842/3152c6ab-2397-5308-aeaa-1263e912786e.png)

オライリー本を読んだので、前回より若干洗練されています。(だといいな)

- 前回: [ASP.NET MVC ベリーシンプル多言語対応【2019 アドカレ】](https://qiita.com/yuu-eguci/items/27211b706e4fb9969ec6)

## ベリーシンプル多言語対応

前回は `Accept-Language` に応じた言語切り替えを扱いましたが、今回は URL によって切り替わるパターンも扱います。

![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270842/828e54b9-6ba5-4ca3-c806-f45d7eba1ee2.png)

1. /Home/Index を、 http リクエストの `Accept-Language` に応じて言語が切り替わるページとする
1. /Home/About を、 URL によって言語が切り替わるページとする
    - /ja/Home/About のとき日本語、 /en/Home/About のとき英語、ということ
    - /Home/About に直接アクセスすると、勝手に /.../Home/About へリダイレクト
1. ページを日本語にする(/ja/ に遷移する)リンクと英語にする(/en/ に遷移する)リンクをつくる

興味のあるパターンだけ見てもらえればいいと思います。完成品のプロジェクトはここ↓においてあります。

- [yuu-eguci/i18nAppCSharp - GitHub](https://github.com/yuu-eguci/i18nAppCSharp)

デモはこちら。(デモは予告なく閉じることがあります。)

- [i18nApp Demo](https://i18napp.azurewebsites.net/)

## 今回やることを図にして整理

/Home/Index についてはこう。

![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270842/68e1428e-abf5-f5d9-3ae2-b0ab8478d57a.png)

/Home/About と /Home/Contact についてはこう。

![3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270842/47087c50-da6e-5ee2-ee94-4e2771549e97.png)

## 今回使うプロジェクト

今回使うプロジェクトは次のように New しました。

- ASP.NET Web Application (.NET Framework)
- .NET Framework 4.7.2
- MVC
- Authentication: No Authentication
- Place solution and project in the same directory のチェックは外す

## 各言語リソースファイル作成

リソースファイルは拡張子 resx のファイルですね。ここに言語ごとにテキストを書いておきます。リソースファイルは

- `[好きな名前].[culture の名前].resx`

という名称でつくります。デフォルトの言語リソースと、多言語のリソースは同じ階層に並べましょう。リソースファイルのアクセス修飾子は忘れず `Public` にします。(だいじ)

- I18n.resx: デフォルトで使うもの
- I18n.ja.resx: ja カルチャで使うもの
- I18n.en.resx: en カルチャで使うもの

**場所は?**

場所はどこでもよさそうです。私はたとえば /Home/Index 用のリソースは Resources/Home/Index/I18n.resx みたいなフォルダ階層をつくって置きます。実際に Razor ファイル(cshtml ファイル)で使うときの表記が `@I18n.PageTitle` みたいになって見やすいからです。 I18n は internationalization の略です。

**カルチャ?**

カルチャというのは、 http リクエストヘッダ `Accept-Language` の値のことみたいです。サイトの閲覧者さんが、何語を求めているかということですね。 C# 的には、 `CurrentCulture` と `CurrentUICulture` にそれをセットすれば、上で作った各リソースが自動で呼び出されます。

```csharp
// CurrentCulture はフォーマット形式とかに使われる。
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("en");
// CurrentUICulture はリソースを切り替えるのに使われる。
Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo("en");
```

ふたつ挙げましたが、今回はリソースの切り替えしか扱わないので `CurrentUICulture` しか関係ないです。

**resx なくない?**

ないですね。

![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270842/71d83aed-9b8f-e453-142f-85d4b4f623f6.png)

ないときもあるみたいです。そういうときはリストにとらわれず resx の拡張子を手打ちしてファイルを作成すれば OK です。(えぇー?!
あるいはすでにある resx ファイルをどこかから見つけてきて、 Add Existing Item から追加することもできます。

![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270842/4f9023e6-b053-70db-474b-5779b9b53a38.png)

**リソースに書いたテキストを使うには?**

String1 というテキストを用意したなら……

![4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270842/8d15d408-5587-1527-3a34-91a17699eba0.png)

こういうふうに呼び出します。

```
@i18nApp.Resources.Home.Index.I18n.String1

あるいは

@using i18nApp.Resources.Home.Index
@I18n.String1
```

## Accept-Language に応じた言語切り替え

/Home/Index を `Accept-Language` によってリソースが切り替わるページにします。まず /Home/Index の View(cshtml) がリソースを使うようになっていないと話になりません。

```html:Views/Home/Index.cshtml
@using i18nApp.Resources.Home.Index

<div class="jumbotron">
    <h1>
        @I18n.String1
    </h1>
</div>
```

こう書けばとりあえずデフォルトのリソースを使ってくれます。 `Accept-Language` に応じたリソースを使って欲しいときはトップディレクトリの Web.config の system.web タグ内に `<globalization culture="auto" uiCulture="auto" />` を記述します。

```xml:web.config
<system.web>
  <compilation debug="true" targetFramework="4.7.2" />
  <httpRuntime targetFramework="4.7.2" />
  <globalization culture="auto" uiCulture="auto" />  <!-- 追加 -->
</system.web>
```

リクエスト・ヘッダの `Accept-Language` に応じて、自動で `CurrentUICulture` が変わるようになります。 `CurrentUICulture` に応じたリソースが呼び出されるので、これで /Home/Index は完成です。

**Accept-Language はどう変更する?**

- Chrome: メニュー > 設定 > 詳細設定 > 言語
- Firefox: メニュー > オプション > 言語

## URL による言語切り替え

/Home/About を URL によってリソースが切り替わるページにします。 /Home/About の View(cshtml) を次のようにしておきます。リソースはあらかじめ用意してください。

```html:Views/Home/About.cshtml
@using i18nApp.Resources.Home.About

<div class="jumbotron">
    <h1>
        @I18n.String1
    </h1>
</div>
```

### {culture}/{controller}/{action} という URL を受け付けるようにする

そもそも ASP.NET MVC はデフォルトではそんな URL を受け付けません。 RouteConfig.cs を見ればわかるように、受け付けるのは `{controller}/{action}` という形式だけです。 `{culture}/{controller}/{action}` も受け付けるように、設定します。

```csharp:App_Start/RouteConfig.cs
// これを追加。
routes.MapRoute(
    // 名前はてきとー。
    name: "I18n",
    url: "{culture}/{controller}/{action}/{id}",
    defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional, culture = "ja" },
    // {culture} には ja か en しか認めない、という設定。
    constraints: new { culture = "ja|en" }
);

// 最初からあるやつ。
routes.MapRoute(
    name: "Default",
    url: "{controller}/{action}/{id}",
    defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
);
```

/en/Home/About みたいな URL でアクセスされたら、コントローラで `culture = "en"` を受け取れるようになりました。受け取った culture を `CurrentUICulture` にセットすれば、それに応じたリソースが呼ばれます。ヨシ!

### URL の culture を CurrentUICulture にセット

で、どこでセットするかなんですが、コントローラの各アクションメソッド内ではやりたくないので、

```csharp
// やりたくないやつ。
public ActionResult About(string culture)
{
    Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(culture);
    return View();
}
```

アクション実行前に呼び出されるメソッド `OnActionExecuting` にその処理を追加しましょう。

```csharp:Helpers/I18nAttribute.cs
using System.Web.Mvc;
using System.Threading;
using System.Globalization;

namespace i18nApp.Helpers
{
    public class I18nAttribute : ActionFilterAttribute
    {
        public override void OnActionExecuting(ActionExecutingContext filterContext)
        {
            // culture が null だったら(/Home/About でアクセスされたということ)
            // /ja/Home/About にリダイレクトしたいところだけどそれは後述。
            string culture = filterContext.ActionParameters["culture"] as string;
            Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(culture);
        }
    }
}
```

そしてこの `OnActionExecuting` を /Home/About に適用するには、アクションメソッドにこんなふうにくっつければ OK です。

```csharp:Controllers/HomeController.cs
using i18nApp.Helpers;

// これで↑の OnActionExecuting を事前に実行してくれる。
[I18nAttribute]
public ActionResult About(string culture)
{
    return View();
}
```

1. /ja あるいは /en/Home/About でアクセスされる
1. `OnActionExecuting` メソッドで `CurrentUICulture` を ja あるいは en にセットする
1. それに応じたリソースが View で表示される

これで /Home/About も完成です。デモページでは /Home/Contact も同様の実装にしてあります。

## OnActionExecuting もりもりセット

実際のところ、アクションメソッド実行前にやってほしいことはもっとあります。

- /Home/About でアクセスされたら /ja/Home/About にリダイレクトしてほしい
- /en でアクセスされたら次からデフォルトの言語は en にしてほしい

そのへんを盛り込んでおきます。

```csharp:Helpers/I18nAttribute.cs
public class I18nAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        // NOTE:
        // アクションパラメータを取得: filterContext.ActionParameters["culture"]
        // GET クエリを取得: filterContext.HttpContext.Request.QueryString["key"]

        // /{controller}/{action} のアクセスを /{culture}/{controller}/{action} へリダイレクト。
        if (!(filterContext.ActionParameters["culture"] is string culture))  // culture が null だったら、という書き方
        {
            // リクエストされた URI。
            Uri requestUri = filterContext.HttpContext.Request.Url;
            var uriBuilder = new UriBuilder(requestUri);

            // URI を変更。
            // デフォルトは ja だけれど、クッキーに値が入っていればそれをデフォルトとする。
            string initialDefaultCulture = "ja";
            HttpCookie cookieCulture = filterContext.RequestContext.HttpContext.Request.Cookies["culture"];
            if (cookieCulture != null && !string.IsNullOrEmpty(cookieCulture.Value))
            {
                initialDefaultCulture = cookieCulture.Value;
            }
            uriBuilder.Path = initialDefaultCulture + uriBuilder.Path;

            // リダイレクト。
            filterContext.Result = new RedirectResult(uriBuilder.ToString());
            return;
        }

        // RouteConfig で culture を ja か en に絞っているので culure の値チェックは不要。
        // NOTE: CurrentCulture is used to switch formats.
        Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo(culture);
        // NOTE: CurrentUICulture is used to switch resources.
        Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(culture);

        // /ja や /en でアクセスされたらその値をクッキーに保存。
        var cookie = new HttpCookie("culture")
        {
            Value = culture
        };
        filterContext.HttpContext.Response.Cookies.Add(cookie);
    }
}
```

## URL を日本語版、あるいは英語版にするリンク

次のように `Html.ActionLink` を書くことで、現在の URL に /ja や /en を付与したページへ遷移するリンクを作れます。

```html:Views/Shared/_Layout.cshtml
<li>
    @Html.ActionLink(
       "日本語",
       this.ViewContext.RouteData.Values["action"].ToString(),
       this.ViewContext.RouteData.Values["controller"].ToString(),
       new { culture = "ja" },
       null
    )
</li>
<li>
    @Html.ActionLink(
       "English",
       this.ViewContext.RouteData.Values["action"].ToString(),
       this.ViewContext.RouteData.Values["controller"].ToString(),
       new { culture = "en" },
       null
    )
</li>
```

もともとある Index, About, Contact へのリンクはそのままで OK です。

```html:Views/Shared/_Layout.cshtml
<li>@Html.ActionLink("Home", "Index", "Home")</li>
<li>@Html.ActionLink("About", "About", "Home")</li>
<li>@Html.ActionLink("Contact", "Contact", "Home")</li>
```

`new { culture = "ja" }` とかをつけなくても自動で /ja/Home/About みたいなリンクになります。 ASP.NET MVC は現在の `MapRoute` 情報とアクションパラメータの値を保持するためです。

## おしまい

ASP.NET Core が気になります。 Mac や Visual Studio Code で C# が書けるなんて素敵すぎる。