0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

.NET 10 / C# 14で作る型安全でシンプルなJSONベース多言語化ライブラリ

0
Last updated at Posted at 2026-09-14

はじめに

.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(...)

による翻訳元データの自動生成が特徴です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?