未来の自分へ知識貯金

ゆーきのエンジニアブログ

約5分 C#Visual Studioプログラミング

【C#】パラメータ管理【関数】

【C#】パラメータ管理【関数】

ソフトを作るとき、パラメータ(設定値)のないソフトを作ることはほとんどありません。ウィンドウの位置、前回開いたフォルダ、ユーザーが調整したしきい値——こうした値はファイルに保存して、次回起動時に読み出すことになります。

面倒なのは、ソフトを改造してパラメータが増えるたびに、保存/読出のコードも書き換えないといけないことです。「設定項目を1つ足しただけなのに、書き込み処理と読み込み処理と初期値処理の3か所を直す」を繰り返した経験のある方は多いと思います。

この記事では、System.Text.Json を使ってデータクラスを丸ごと JSON にシリアライズする汎用関数を用意し、「パラメータが増えてもクラスにプロパティを1行足すだけ」という状態を作ります。

方針:クラスを定義したら、保存も読出も勝手についてくる

作るのは次の4つの汎用関数です。一度作ればどのアプリにも使い回せます。

関数役割
SerializeJson<T>オブジェクト → JSON 文字列
DeserializeJson<T>JSON 文字列 → オブジェクト
SaveData<T>オブジェクトをファイルに保存
LoadData<T>ファイルからオブジェクトを復元

使う側はこう書けます。パラメータクラスの中身が何であっても、この2行は変わりません。

// 保存
Funcs.SaveData("param.json", param);

// 読み出し(ファイルがなければ初期値の新品が返る)
Funcs.LoadData("param.json", out AppParam param);

準備:System.Text.Json

NuGet で System.Text.Json をインストールします(.NET 6 以降なら標準搭載なので不要です)。

using System.Text.Json;

シリアライズ関数(object ⇔ string)

public static string SerializeJson<T>(T obj)
{
    var jsonOptions = new JsonSerializerOptions
    {
        WriteIndented = true,
    };
    return JsonSerializer.Serialize(obj, jsonOptions);
}

public static T DeserializeJson<T>(string jsonString)
{
    return JsonSerializer.Deserialize<T>(jsonString);
}

WriteIndented = true は JSON を改行・インデント付きで整形するオプションです。設定ファイルは人間がテキストエディタで開いて直せるのが利点なので、多少ファイルが大きくなっても整形して出すのがおすすめです。

保存と読み出し

object⇔string の変換ができたので、あとはファイルへの入出力をかぶせるだけです。

using System.IO;
using System.Reflection;

public static string GetAppDataDirectory()
{
    return Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
}

public static bool LoadData<T>(string fileName, out T param) where T : new()
{
    string root = Funcs.GetAppDataDirectory();
    string path = Path.Combine(root, fileName);

    if (!File.Exists(path))
    {
        param = new T();  // ファイルがなければ初期値で新品を作る
        return false;
    }

    string jsonString = File.ReadAllText(path);

    try
    {
        param = Funcs.DeserializeJson<T>(jsonString);
    }
    catch (Exception ex)
    {
        param = new T();  // 壊れたファイルでも初期値で起動できるようにする
        Console.WriteLine(ex.ToString());
        return false;
    }

    return true;
}

public static bool SaveData<T>(string fileName, T param)
{
    string root = Funcs.GetAppDataDirectory();
    string path = Path.Combine(root, fileName);
    string tempPath = path + ".temp";

    string jsonString = Funcs.SerializeJson(param);

    File.WriteAllText(tempPath, jsonString);
    File.Copy(tempPath, path, true);
    File.Delete(tempPath);

    return true;
}

設計上のポイントを3つ挙げます。

  • where T : new() 制約: ファイルがない・壊れているときに new T() で初期値のオブジェクトを返します。「初回起動でも設定ファイルが壊れていても、とにかくアプリは起動する」という動きになります
  • 一時ファイル経由で保存: いったん .temp に書き切ってから本体へコピーしています。書き込みの途中でアプリが落ちたり電源が切れたりしても、本体のファイルが中途半端な状態にならないための保険です
  • 読み込みは try-catch: ユーザーが手で編集してカンマを1つ消してしまった、ということは実際に起きます。例外で落とさず初期値に戻します

ハマりポイント:File.OpenWrite で上書きすると JSON が壊れる

私が最初に書いたバージョンでは、保存処理に File.OpenWrite を使っていました。これには罠があります。

// NG: 前より短いデータを書くと、末尾に前回のゴミが残る
using (var sw = new StreamWriter(File.OpenWrite(path)))
{
    sw.Write(jsonString);
}

File.OpenWrite は既存ファイルを切り詰めません(truncate しない)。先頭から上書きするだけなので、新しい内容が前回より短いと、前回の内容の末尾が残ってしまいます。

{ "Count": 5 }
}, "Name": "古いデータの残骸" }   ← 前回の書き込みの残り

こうなると次回の読み込みで JsonException になります。「設定項目を減らしたら、なぜか次の起動から設定が初期化されるようになった」という不可解な症状の正体がこれでした。上のコードのように File.WriteAllText(毎回作り直し)を使うか、new FileStream(path, FileMode.Create) のように FileMode.Create を明示してください。

バージョンアップに強い理由

この方式の一番の利点は、アプリのバージョンアップでパラメータが増減しても壊れないことです。

  • プロパティを足した場合: 古い JSON ファイルにはそのキーがありませんが、デシリアライズ時にプロパティ初期化子(public int Timeout { get; set; } = 30;= 30)が使われるだけで、例外にはなりません
  • プロパティを消した場合: JSON に余分なキーが残っていても、既定では単に無視されます

つまり「クラス定義が正、JSON は追従する」という関係になり、マイグレーション処理をほとんど書かずに済みます。新しいプロパティには必ず初期化子でデフォルト値を付けておくのがコツです。

補足:保存先は exe と同じフォルダでよいか

GetAppDataDirectory は exe と同じフォルダを返しています。自分用ツールや持ち運ぶツールならこれが手軽ですが、インストーラーで C:\Program Files に入れるアプリの場合、そこには一般ユーザー権限で書き込めません。配布するアプリでは、ユーザーごとの AppData を使うのが安全です。

public static string GetAppDataDirectory()
{
    string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
    string dir = Path.Combine(appData, "MyAppName");
    Directory.CreateDirectory(dir);  // なければ作る
    return dir;
}

まとめ

  • パラメータはデータクラスに集約し、System.Text.Jsonクラスごと JSON に保存/復元する
  • 項目が増えても「クラスにプロパティを1行足す」だけ。初期化子でデフォルト値を付けておけば古いファイルもそのまま読める
  • File.OpenWrite での上書きは切り詰められず JSON が壊れる(最重要)。File.WriteAllTextFileMode.Create を使う
  • 一時ファイル経由の保存で、書き込み中のクラッシュに備える
  • 配布アプリの保存先は exe フォルダではなく AppData に

ちなみに、このシリアライズ関数はパラメータ保存以外にも使い回せます。オブジェクトのディープコピーと比較に応用した記事もあるので、あわせてどうぞ。

この記事をシェアする

ゆーき
ゆーき

プログラマー経験10年以上。好きなプログラム言語はC#。使える言語はC++, Python他。好きな開発環境はVisual Studio。

プロフィールを見る →