はじめに
.NET の多言語化といえば .resx が定番です。
しかし、
- 翻訳データを JSON で管理したい
- Git 上で差分を見やすくしたい
- 実行中に言語を切り替えたい
- 文字列キーの手書きを無くしたい
という場合には少し扱いづらいことがあります。
そこで、JSON ファイルだけで運用できる軽量なローカライズライブラリを作ってみました。
特徴は次の通りです。
- 型安全な
TextKey - JSONベースの翻訳管理
- 実行中の言語切り替え
-
PropertyInfoの表示名ローカライズ - 既定文言の自動エクスポート
完成したソース
public static class Language
{
public readonly record struct TextKey(string Key, string Default)
{
public string Text => Get(this);
public string Format(params object?[] args) => string.Format(Text, args);
}
public static TextKey CreateKey<T>(Expression<Func<T>> expr, string @default)
{
if (expr.Body is not MemberExpression member) throw new ArgumentException(null, nameof(expr));
return new($"{member.Member.DeclaringType!.Name}.{member.Member.Name}", @default);
}
public static TextKey CreateKey<T>(Expression<Func<T, object?>> expr)
{
if (expr.Body is not MemberExpression member) throw new ArgumentException(null, nameof(expr));
return ((PropertyInfo)member.Member).CreateKey();
}
extension(PropertyInfo property)
{
public string Text => property.CreateKey().Text;
TextKey CreateKey() => new($"Property.{property.DeclaringType!.Name}.{property.Name}",
property.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? property.Name);
}
static readonly JsonSerializerOptions JSON_OPTIONS = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
public static event EventHandler? LanguageChanged;
static readonly Dictionary<string, IReadOnlyDictionary<string, string>> languages = [];
static IReadOnlyDictionary<string, string> current = new Dictionary<string, string>();
public static string CurrentLanguage { get; private set; } = "ja";
public static void Initialize(string languageDir)
{
if (!Directory.Exists(languageDir)) return;
foreach (var file in Directory.EnumerateFiles(languageDir, "*.json"))
{
var culture = Path.GetFileNameWithoutExtension(file);
languages[culture] = JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(file, JSON_OPTIONS)) ?? [];
}
SetLanguage(CurrentLanguage);
}
public static bool SetLanguage(string culture)
{
if (!languages.TryGetValue(culture, out var lang)) return false;
if (ReferenceEquals(current, lang)) return true;
current = lang;
CurrentLanguage = culture;
LanguageChanged?.Invoke(null, EventArgs.Empty);
return true;
}
public static void ExportDefaults(string path, params IEnumerable<Type> types)
{
var dict = new SortedDictionary<string, string>();
foreach (var type in types)
Collect(type, dict);
File.WriteAllText(path, JsonSerializer.Serialize(dict, JSON_OPTIONS));
}
static string Get(TextKey key) => current.TryGetValue(key.Key, out var value) ? value : key.Default;
static void Collect(Type type, IDictionary<string, string> dict)
{
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
{
if (field.FieldType != typeof(TextKey)) continue;
if (field.GetValue(null) is not TextKey key) continue;
dict[key.Key] = key.Default;
}
foreach (var nested in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic))
Collect(nested, dict);
}
}
使い方
テキストキーを定義する
static class Texts
{
public static readonly Language.TextKey Save =
Language.CreateKey(() => Save, "保存");
public static readonly Language.TextKey Cancel =
Language.CreateKey(() => Cancel, "キャンセル");
}
生成されるキーは次のようになります。
Texts.Save
Texts.Cancel
取得
buttonSave.Text = Texts.Save.Text;
フォーマット付き文字列
public static readonly TextKey ItemCount =
Language.CreateKey(() => ItemCount, "{0}件");
ItemCount.Format(123);
結果:
123件
なぜ Expression を使うのか
通常のローカライズでは文字列キーを直接書きます。
Get("Texts.Save");
しかしリネーム時に壊れる可能性があります。
このライブラリでは
Language.CreateKey(() => Save, "保存");
と記述することで、Expression から安全にキー名を生成します。
PropertyInfo のローカライズ
public class Product
{
[DisplayName("商品名")]
public string Name { get; set; } = "";
}
typeof(Product)
.GetProperty(nameof(Product.Name))!
.Text;
キー:
Property.Product.Name
JSONファイル
ja.json
{
"Texts.Save": "保存",
"Texts.Cancel": "キャンセル"
}
en.json
{
"Texts.Save": "Save",
"Texts.Cancel": "Cancel"
}
初期化
Language.Initialize("Languages");
言語切り替え
Language.SetLanguage("en");
Language.LanguageChanged += (_, _) => RefreshText();
既定文言の自動出力
Language.ExportDefaults(
"ja.json",
typeof(Texts));
出力例:
{
"Texts.Cancel": "キャンセル",
"Texts.Save": "保存"
}
この方式のメリット
- 型安全
- リネームに強い
- JSON管理
- Git差分が綺麗
- コードファースト
- 実行時言語切替対応
まとめ
このライブラリは .resx を使わず、
- JSON
- Expression
- Reflection
だけでシンプルな多言語化を実現しています。
特に
Texts.Save.Text
のような型安全なアクセスと、
Language.ExportDefaults(...)
による翻訳元データの自動生成が特徴です。