.NET Coreの国際化リソースにデータベースを使う

# .NET Coreの国際化リソースにデータベースを使う
[前回](https://qiita.com/epsilon_87_myo/items/043e57841a8038f5e276)に関連した内容です。

.NET Coreの国際化対応はデフォルトでは`.resx`を使いますが、それ以外の方法も使える仕組みになっています。

今回はデータベースのテーブルに保存した内容を用いて国際化対応をします。

## その前にデフォルト実装の確認
デフォルトでは[LocalizationServiceCollectionExtensions](https://github.com/dotnet/extensions/blob/v3.1.5/src/Localization/Localization/src/LocalizationServiceCollectionExtensions.cs)の`AddLocalization`メソッドを実行することで`.resx`ファイルを用いた国際化が可能です。

国際化関係のクラスは以下のような階層になっています。

- `IStringLocalizer` インターフェース
  - `IStringLocalizer<T>` インターフェース
     - `StringLocalizer<T>` クラス
  - `ResourceManagerStringLocalizer` クラス
- `IStringLocalizerFactory` インターフェース
  - `ResourceManagerStringLocalizerFactory` クラス

[StringLocalizer\<T\>](https://github.com/dotnet/extensions/blob/v3.1.5/src/Localization/Abstractions/src/StringLocalizerOfT.cs) はコンストラクタで `IStringLocalizerFactory` を受け取っており、ファクトリが生成した`IStringLocalizer` のインスタンスをフィールドに保持しています。
そして`IStringLocalizer` インスタンスに処理を委譲する仕組みになっています。

なので`ResourceManagerStringLocalizer`, `ResourceManagerStringLocalizerFactory` に相当するクラスを用意することで、データベースから取得するローカライザを作ることができそうです。

## 本題
上記の内容を踏まえた上で データベースを用いて国際化対応をしてみます。ソースは以下の場所に配置しています。

- [ epsilonGtMyon/DbStringLocalizerSample](https://github.com/epsilonGtMyon/DbStringLocalizerSample)

データベースは`PostgreSQL`, データベースアクセスを簡略化するために`Dapper` を使っています。

また動作確認はWebアプリで行いました。
ただしビューにあたる部分は`.cshtml` ではなく生HTML(`Vue.js`)を使っています。
つまりサーバー側はWebAPIとして動かしています。

### テーブルの用意
テーブルの形状(タテ持ち、ヨコ持ち)は任意ですが今回は以下のようにしました。

|列名     |型            | 主キー|説明         |
|---------|-------------|------|-------------|
|category |varchar(20)  |1    |メッセージの種類|
|key_name |varchar(20)  |2    |メッセージのキー|
|ja       |varchar(200) |     |日本語文字列   |
|en       |varchar(200) |     |英語文字列     |

以下のデータを入れています。

```sql
-- カテゴリ:Item
insert into localization_resource values('Item', 'Item01', '田中', 'Tanaka');

-- カテゴリ:Message
insert into localization_resource values('Message', 'M0001', 'ようこそ{0}さん！', 'Hello. {0}!');
insert into localization_resource values('Message', 'M0002', 'おはようございます。', 'Good Morning.');
```

### 実装

SQLの検索結果の1レコードを表すクラスを用意

```cs:LocalizationRecord.cs
namespace DbStringLocalizerSample.Localizer
{
    /// <summary>
    /// データベースで管理されている国際化リソースのレコード
    /// </summary>
    public class LocalizationRecord
    {
        public string Key { get; set; }

        public string Ja { get; set; }

        public string En { get; set; }
    }
}
```

データベースから取得したレコードを保持するクラス

```cs:DbLocalizedStringSource.cs
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

namespace DbStringLocalizerSample.Localizer
{
    /// <summary>
    /// データベースから取得した国際化リソースのソースを保持するクラス
    /// </summary>
    public class DbLocalizedStringSource
    {
        private readonly IDictionary<string, LocalizationRecord> _records;

        public DbLocalizedStringSource(IDictionary<string, LocalizationRecord> records)
        {
            _records = records;
        }

        public static DbLocalizedStringSource FromEnumerable(IEnumerable<LocalizationRecord> src)
        {
            IDictionary<string, LocalizationRecord> records = src.ToDictionary(x => x.Key);
            return new DbLocalizedStringSource(records);
        }

        public IEnumerable<string> GetAllKey()
        {
            return _records.Keys;
        }

        public string GetString(string name, CultureInfo currentUICulture)
        {
            if (_records.TryGetValue(name, out LocalizationRecord record))
            {
                switch (currentUICulture.Name)
                {
                    case "ja": return record.Ja;
                    case "en": return record.En;
                }
            }
            return null;
        }
    }
}

```

データベースからレコードを取得し`DbLocalizedStringSource` を返すクラス

```cs:DbLocalizedStringSourceProvider.cs
using Dapper;
using Npgsql;
using System;
using System.Collections.Generic;
using System.Data;

namespace DbStringLocalizerSample.Localizer
{
    /// <summary>
    /// データベースから国際化リソースのソースを取得するクラス
    /// </summary>
    public class DbLocalizedStringSourceProvider
    {
        private const string connectionString = "Host=localhost;Database=test_db;Username=test_user;Password=test_user";

        public DbLocalizedStringSource GetLocalizedStrings(Type resourceSource)
        {
            using IDbConnection con = new NpgsqlConnection(connectionString);
            con.Open();
            using IDbTransaction tran = con.BeginTransaction();


            string sql = @"
SELECT
   key_name as Key
  ,ja       as Ja
  ,en       as En
FROM
   localization_resource
WHERE
   category = @category
ORDER BY
  key
";

            var param = new
            {
                category = resourceSource.Name
            };

            IEnumerable<LocalizationRecord> records = con.Query<LocalizationRecord>(sql, param, tran);
            return DbLocalizedStringSource.FromEnumerable(records);
        }
    }
}
```

`IStringLocalizer`の実装クラス
`DbLocalizedStringSource` に委譲しています。
(`ResourceManagerStringLocalizer` を参考)

```cs:DbStringLocalizer.cs
using Microsoft.Extensions.Localization;
using System;
using System.Collections.Generic;
using System.Globalization;

namespace DbStringLocalizerSample.Localizer
{
    /// <summary>
    /// データベースを使用したIStringLocalizerの実装
    /// </summary>
    public class DbStringLocalizer : IStringLocalizer
    {
        private readonly DbLocalizedStringSource _dbLocalizedStringSource;

        public DbStringLocalizer(DbLocalizedStringSource dbLocalizedStringSource)
        {
            _dbLocalizedStringSource = dbLocalizedStringSource;
        }

        /// <inheritdoc/>
        public LocalizedString this[string name]
        {
            get
            {
                if (name == null)
                {
                    throw new ArgumentNullException(nameof(name));
                }

                var value = GetString(name);

                return new LocalizedString(name, value ?? name, resourceNotFound: value == null, searchedLocation: null);
            }
        }

        /// <inheritdoc/>
        public LocalizedString this[string name, params object[] arguments]
        {
            get
            {
                if (name == null)
                {
                    throw new ArgumentNullException(nameof(name));
                }

                var format = GetString(name);
                var value = string.Format(format ?? name, arguments);

                return new LocalizedString(name, value, resourceNotFound: format == null, searchedLocation: null);
            }
        }

        private string GetString(string name, CultureInfo culture = null)
        {
            if (name == null)
            {
                throw new ArgumentNullException(nameof(name));
            }

            var keyCulture = culture ?? CultureInfo.CurrentUICulture;

            return _dbLocalizedStringSource.GetString(name, keyCulture);
        }

        public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
        {
            //includeParentCulturesを使ってない...

            IEnumerable<string> allKey = _dbLocalizedStringSource.GetAllKey();

            var culture = CultureInfo.CurrentUICulture;
            foreach (var key in allKey)
            {
                var value = GetString(key, culture);
                yield return new LocalizedString(key, value ?? key, resourceNotFound: value == null, searchedLocation: null);
            }
        }

        /// <summary>
        /// インターフェースのこのメソッドがObsoleteなので実装していません。
        /// </summary>
        /// <param name="culture"></param>
        /// <returns></returns>
        [Obsolete("This method is obsolete. Use `CurrentCulture` and `CurrentUICulture` instead.")]
        public IStringLocalizer WithCulture(CultureInfo culture)
        {
            throw new NotImplementedException("Not Implemented");
        }
    }
}
```

`IStringLocalizerFactory` の実装クラス
`DbStringLocalizer` の生成とキャッシュをしています。
(`ResourceManagerStringLocalizerFactory`を参考)

```cs:DbStringLocalizerFactory.cs
using Microsoft.Extensions.Localization;
using System;
using System.Collections.Concurrent;

namespace DbStringLocalizerSample.Localizer
{
    /// <summary>
    /// DbStringLocalizerのファクトリ
    /// </summary>
    public class DbStringLocalizerFactory : IStringLocalizerFactory
    {
        private readonly ConcurrentDictionary<RuntimeTypeHandle, DbStringLocalizer> _localizerCache =
            new ConcurrentDictionary<RuntimeTypeHandle, DbStringLocalizer>();

        private readonly DbLocalizedStringSourceProvider _dbLocalizedStringSourceProvider;

        public DbStringLocalizerFactory(DbLocalizedStringSourceProvider dbLocalizedStringSourceProvider)
        {
            _dbLocalizedStringSourceProvider = dbLocalizedStringSourceProvider;
        }

        /// <inheritdoc/>
        public IStringLocalizer Create(string baseName, string location)
        {
            throw new NotImplementedException("Not Implemented");
        }

        /// <inheritdoc/>
        public IStringLocalizer Create(Type resourceSource)
        {
            return _localizerCache.GetOrAdd(resourceSource.TypeHandle, _ => CreateDbStringLocalizer(resourceSource));
        }

        private DbStringLocalizer CreateDbStringLocalizer(Type resourceSource)
        {
            DbLocalizedStringSource source = _dbLocalizedStringSourceProvider.GetLocalizedStrings(resourceSource);
            return new DbStringLocalizer(source);
        }
    }
}
```


`Startup` で使用するクラスを登録する。
`AddLocalization` より前に`DbStringLocalizerFactory`をしておく

```cs:Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    //...省略...
    
    //AddLocalizationより前にDbStringLocalizerFactoryを登録する
    services.AddTransient<DbLocalizedStringSourceProvider>();
    services.AddSingleton<IStringLocalizerFactory, DbStringLocalizerFactory>();
    services.AddLocalization();
}
```

### 使用方法
使用方法は`.resx` を使うときと同じです。

まずカテゴリ用に2つのクラスを用意します。

```cs:Item.cs
namespace DbStringLocalizerSample.Dummy
{
    public class Item
    {}
}
```

```cs:Message.cs
namespace DbStringLocalizerSample.Dummy
{
    public class Message
    {}
}
```

`IStringLocalizer<T>` をインジェクションするだけです。

```cs
[ApiController]
[Route("api/sandbox01")]
public class Sandbox01Controller : ControllerBase
{
    private readonly IStringLocalizer<Item> _itemLocalizer;
    private readonly IStringLocalizer<Message> _messageLocalizer;

    public Sandbox01Controller(IStringLocalizer<Item> itemLocalizer, IStringLocalizer<Message> messageLocalizer)
    {
        _itemLocalizer = itemLocalizer;
        _messageLocalizer = messageLocalizer;
    }

    [HttpGet("message01")]
    public IActionResult Message01()
    {
        string item = _itemLocalizer["Item01"];
        string mes = _messageLocalizer["M0001", item];
        return Content(mes);
    }

    [HttpGet("message02")]
    public IActionResult Message02()
    {
        string mes = _messageLocalizer["M0002"];
        return Content(mes);
    }
}
```

## 最後に
今回はデータベースを用いましたが、上記のポイントを押さえていれば、任意の方法を使って国際化対応ができそうです。

ただこの実装方法ではまだ少しだけ課題が残っています。

- `IStringLocalizer.WithCulture`メソッドが実装されていない
- `IStringLocalizer.GetAllStrings(bool includeParentCultures)`メソッドで`includeParentCultures` が未使用
- `IStringLocalizer Create(string baseName, string location)` を実装していないので`IViewLocalizer`を使うことができない

これらを改善すればもう少し実用的なものになりそうです。