連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index
設定ファイルに項目を1つ足した。起動確認だけのつもりだった。
「これだけで何か壊れる?」と思いながら再起動する。
旧環境で立ち上げると、読み込みで落ちるか、黙って既定値に戻る。
差分を見ると、増えたのは1項目だけで、読み込み側の前提が崩れていた。
このページでは、項目追加が「読めない」「意味が変わる」「重くなる」に化ける理由と、壊れにくい作り方を整理する。
このページで手に入るもの
- シリアライズ/デシリアライズの前提が短距離で分かる(設定・状態保存・通信・キャッシュ)
- 互換性のルール(追加/削除/型変更/名前変更/形変更)を、判断できる粒度で持てる
- ファイルに版数を入れ、旧データを読み続ける移行の流れを組める
- 読み込み直後に検証し、壊れた入力で落ち方が出ないようにできる
- .NET 8(System.Text.Json)と .NET Framework 4.8(Newtonsoft.Json)の最小テンプレをコピペで使える
- 過去サンプルで互換性をテストし、破壊的変更を早期に止められる
先に逆引き(症状→原因→対策)
| 症状 | ありがちな原因 | 切り分け(見る場所) | 最短の対処 | 再発防止 |
|---|---|---|---|---|
| 旧データが読めない(例外) | 必須扱いの項目が欠損 / 型が変わった | 例外位置、モデルの必須条件 | 欠損を既定値で埋める / 版数分岐を入れる | 互換性ルールと版数運用を決める |
| 読めたが意味が変わる | 名前変更 / enum追加 / 単位変更 | 変換処理、既定値の扱い | 旧→最新へ変換を入れる | 版数と移行を一箇所に集める |
| 人が編集して落ちる | 型の揺れ(文字列/数値)、末尾カンマ、コメント | パーサ設定、入力検証 | 設定の許容範囲を決める | 入力防御(サイズ/深さ/必須/範囲) |
| 起動が重い | 巨大ファイル / 深いネスト / 循環参照 | ファイルサイズ、深さ、モデル形 | サイズ上限・深さ上限を入れる | 保存用モデルを浅くし、制限を入れる |
| ロールバックできない | 旧アプリが新データを理解できない | 旧アプリ側の読み込み | 新データ側で未知を無視できる形へ | 追加は安全側、破壊的変更は版数を上げる |
| キャッシュが壊れる/肥大化 | 版数なし、検証なし、意味変更 | 版数、検証、削除条件 | 版数を入れ、期限やサイズで破棄 | キャッシュも「入力」として扱う |
最短テンプレ(コピペ)
設定ファイルの読み込み手順が散ると、欠損や版数違いの扱いが場所ごとに変わりやすい。
この章では、読み込み直後に通す流れ(保存用モデルの分離・版数・検証)を最小構成で示す。
テンプレA:版数つき外枠 + 保存用モデル
public sealed class VersionedData<T>
{
public int Version { get; init; }
public T Data { get; init; } = default!;
}
/// <summary>
/// 保存用モデル v1(設定ファイルに出す形)
/// </summary>
/// <remarks>
/// 数値/文字列/真偽/配列/辞書など、テキストと相性がよい形を中心にする。
/// 日時は表現を決める(例: ISO 8601 / epoch 秒)。
/// </remarks>
public sealed class AppSettingsV1
{
public string ApiBaseUrl { get; init; } = "";
public int TimeoutSec { get; init; } = 30;
}
テンプレB:.NET 8(System.Text.Json)最小
using System.Text;
using System.Text.Json;
public static class SettingsJsonNet8
{
// サイズ上限(例)。設定の用途に合わせて決める。
private const int MaxBytes = 256 * 1024;
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
// 人が編集する設定なら許容範囲を先に決める(例: コメント不可・末尾カンマ不可)
ReadCommentHandling = JsonCommentHandling.Disallow,
AllowTrailingCommas = false,
// 深さ上限(深いネストで重くなるのを避ける)
MaxDepth = 32
};
/// <summary>
/// 版数つき設定を読み、直後に最低限の検証を通す。
/// </summary>
/// <remarks>
/// 貼る場所:ファイル読み込み直後(アプリへ渡す手前)。
/// 例外方針:JSON構文・版数不正・必須欠損は JsonException で止める。
/// </remarks>
public static VersionedData<T> DeserializeVersioned<T>(string json)
{
if (Encoding.UTF8.GetByteCount(json) > MaxBytes)
throw new JsonException("Input too large.");
var obj = JsonSerializer.Deserialize<VersionedData<T>>(json, Options);
if (obj is null) throw new JsonException("JSON parse failed.");
if (obj.Version <= 0) throw new JsonException("Invalid version.");
if (obj.Data is null) throw new JsonException("Data is null.");
return obj;
}
public static string SerializeVersioned<T>(VersionedData<T> obj, bool indented = true)
{
var opt = new JsonSerializerOptions(Options) { WriteIndented = indented };
return JsonSerializer.Serialize(obj, opt);
}
}
テンプレC:.NET Framework 4.8(Newtonsoft.Json)最小
using Newtonsoft.Json;
public static class SettingsJsonNet48
{
private static readonly JsonSerializerSettings Settings = new()
{
// 追加された未知フィールドは無視(旧アプリが新データを読む側の救いになる)
MissingMemberHandling = MissingMemberHandling.Ignore,
// 型名ベースの自動復元は使わない(設定ファイルの内容で生成型が変わり得るため)
TypeNameHandling = TypeNameHandling.None
};
/// <summary>
/// 版数つき設定を読み、直後に最低限の検証を通す。
/// </summary>
public static VersionedData<T> DeserializeVersioned<T>(string json)
{
var obj = JsonConvert.DeserializeObject<VersionedData<T>>(json, Settings);
if (obj is null) throw new JsonSerializationException("JSON parse failed.");
if (obj.Version <= 0) throw new JsonSerializationException("Invalid version.");
if (obj.Data is null) throw new JsonSerializationException("Data is null.");
return obj;
}
public static string SerializeVersioned<T>(VersionedData<T> obj)
=> JsonConvert.SerializeObject(obj, Formatting.Indented, Settings);
}
テンプレD:読み込み直後の検証(必須/範囲/長さ)
using System;
public static class SettingsValidation
{
/// <summary>
/// デシリアライズ直後に最低限の検証を行う。
/// </summary>
/// <remarks>
/// 貼る場所:読み込み直後(アプリへ渡す前)。
/// 例外方針:入力が壊れている場合は ArgumentException 系で止める。
/// </remarks>
public static void Validate(AppSettingsV1 s)
{
if (string.IsNullOrWhiteSpace(s.ApiBaseUrl))
throw new ArgumentException("ApiBaseUrl is required.", nameof(s));
if (s.ApiBaseUrl.Length > 2048)
throw new ArgumentException("ApiBaseUrl is too long.", nameof(s));
if (s.TimeoutSec is < 1 or > 600)
throw new ArgumentException("TimeoutSec out of range (1..600).", nameof(s));
}
/// <summary>
/// 欠損を既定値で埋める(追加項目の受け止め)。
/// </summary>
public static AppSettingsV1 Normalize(AppSettingsV1 s)
{
return new AppSettingsV1
{
ApiBaseUrl = s.ApiBaseUrl ?? "",
TimeoutSec = s.TimeoutSec <= 0 ? 30 : s.TimeoutSec
};
}
}
整理:なぜ「項目を1つ足す」だけで壊れやすいか(概念→評価規則→変化→落とし穴)
シリアライズ/デシリアライズ
シリアライズは、アプリ内の値を 保存できる文字列データ(JSONなど) に変換すること。
デシリアライズは、その文字列データを アプリ内の値へ戻す こと。
- 保存:
モデル -> JSON(設定ファイルなど) - 復元:
JSON(設定ファイルなど) -> モデル
ここで重要なのは、保存が始まった瞬間から 「過去のファイル」が残り続ける 点。
新しい実装は増えるが、古いファイルは勝手に更新されない。だから追加や変更が互換性の問題に化ける。
この話が出てくる瞬間
設定ファイルの話として始まっても、同じ壊れ方は「保存して後で読む」場面全般で起きる。
状態保存やキャッシュ、外部連携も同じ往復を持つため、同じルールがそのまま当てはまる。
- 設定ファイルを読み書きしたい(端末別/環境別に値を変えたい)
- アプリの状態を残したい(最近使った一覧、前回の画面位置、検索条件など)
- キャッシュを残したい(一度取ったマスタ、API応答、計算結果を次回使い回したい)
- ファイル連携やAPIで相手に渡したい(外部システム/別アプリ/別チームが読む)
- テスト用に入出力サンプルを残したい(過去データを読めるかを自動で確認したい)
評価規則:何が「安全寄り」で、何が「危険寄り」か
互換性は「新が旧を読む」「旧が新を読む」の両側で考える。
この2軸を先に置くと、変更の良し悪しが短距離で見える。
| 変更 | 新アプリが旧データを読む | 旧アプリが新データを読む | 判断 |
|---|---|---|---|
| フィールド追加 | 欠損を既定値で補えば通りやすい | 未知フィールドを無視できれば通りやすい | 比較的安全 |
| フィールド削除 | 旧データの値が消えて困りやすい | 旧アプリが必須扱いだと止まりやすい | 段階的に減らす |
| 型変更 | 変換が必要になりやすい | 変換が必要になりやすい | 新フィールド追加 + 移行が安全 |
| 名前変更 | 旧名が読めなくなりやすい | 旧が新名を知らない | 新旧併存 + 移行 |
| enum追加 | Unknown を受け止めれば通りやすい | 未知値で止まりやすい | Unknown を用意 |
| 形変更(配列→単体など) | 読めても意味が崩れやすい | 読めても意味が崩れやすい | 版数を上げて移行 |
変化が増えると起きること
- 旧データが読めない
- ちょっと項目を変えただけで互換性が崩れる
- 「触ってよい値」と「触ると壊れ方が出る値」の境界が曖昧になる
- 想定外入力で落ちる/重くなる/壊れ方が出る
落とし穴:読めたのに意味が変わる
特に長引くのは「例外は出ないが、既定値に戻っていた」「単位が変わった」「名称が変わった」など。
この種の壊れ方は、版数と移行、読み込み直後の検証が効く。
どこで使うか(用途別)
設定/初期値(人が触る)
人が触る前提なら、手編集の揺れを最初から織り込む。
- 欠損、誤入力、型の揺れ(文字列/数値)、コメント運用で止まりやすい
- 欠損に強い読み込み、入力検証、保存用モデルを浅くする方針が効く
状態保存/永続化(アプリが書いてアプリが読む)
過去データが残り続ける前提になる。
- クラス変更で復元不能、循環参照、型変更で止まりやすい
- 版数、移行、保存用モデル分離、互換性テスト用サンプルが効く
通信/外部連携(外部システム/別アプリ/別チーム)
仕様として約束を守る領域。
- 相手の期待とズレる、破壊的変更が入り込みやすい、想定外入力が来る
- 互換性ルール、版数、受信側の入力防御が効く
キャッシュ/スナップショット(性能のために残す)
起動や再計算を短縮する代わりに、古いデータが残る。
- 古いキャッシュが読めない、読めたが意味が変わる、巨大化する
- 版数、検証、サイズ制限、破棄条件(期限/上限)が効く
最初に決める3点(保存を始める前)
-
保存用のモデルを分ける
アプリ内部のクラスをそのまま保存に出さず、保存に出す形を別に持つ。 -
ファイルに版数を入れる
「何版の保存形式か」を一緒に残し、読む側が判断できるようにする。 -
読み込み直後に検証する
デシリアライズ直後に「サイズ/必須/範囲/深さ」を検証してからアプリへ渡す。
書き出し前も最低限の整合性チェックを通してから保存する。
判例:混入点が多い順に潰す(悪い例→直す→ポイント)
1) 内部モデルをそのまま保存に出し、変更で壊れ方が出る
内部都合で変わりやすいものを保存に出すと、互換性の揺れが増える。
悪い例
public sealed class AppRuntimeState
{
public Uri ApiBaseUri { get; set; } = new("https://example.invalid");
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
}
直す
public sealed class AppRuntimeState
{
public Uri ApiBaseUri { get; set; } = new("https://example.invalid");
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
}
public sealed class AppSettingsV1
{
public string ApiBaseUrl { get; init; } = "";
public int TimeoutSec { get; init; } = 30;
}
public static class SettingsMapper
{
public static AppSettingsV1 ToSaveModel(AppRuntimeState s)
=> new() { ApiBaseUrl = s.ApiBaseUri.ToString(), TimeoutSec = (int)s.Timeout.TotalSeconds };
public static AppRuntimeState ToRuntime(AppSettingsV1 m)
=> new() { ApiBaseUri = new Uri(m.ApiBaseUrl), Timeout = TimeSpan.FromSeconds(m.TimeoutSec) };
}
ポイント
- 保存用モデルは「形が変わりにくい」を優先し、プリミティブ中心にする
-
object/dynamicで逃げると、入力検証が入りにくくなる - 日時は表現を決める(ISO 8601 / epoch 秒など)
2) フィールド追加で、旧データ欠損が止まりになる
追加は比較的安全寄りだが、欠損の扱いが揺れると止まりやすい。
悪い例
public sealed class AppSettingsV2
{
public string ApiBaseUrl { get; init; } = "";
public int TimeoutSec { get; init; } = 30;
public string Region { get; init; } = ""; // 追加
}
// 欠損時の扱いが決まっていない
直す
public sealed class AppSettingsV2
{
public string ApiBaseUrl { get; init; } = "";
public int TimeoutSec { get; init; } = 30;
public string Region { get; init; } = "jp"; // 欠損時の既定値を決める
}
ポイント
- 追加した項目は「欠損時の既定値」を最初に決める
- 人が触る設定なら「必須として止める」より「既定値で補う」方が運用が安定しやすい場面がある
3) 名前変更で、旧データが読めなくなる
悪い例
// ApiBaseUrl を ApiEndpoint に変えた
public sealed class AppSettingsV2
{
public string ApiEndpoint { get; init; } = "";
}
直す(新旧併存 → 移行)
public sealed class AppSettingsV2
{
public string ApiBaseUrl { get; init; } = ""; // 旧
public string ApiEndpoint { get; init; } = ""; // 新
}
public static class MigrationV2
{
public static AppSettingsV2 Normalize(AppSettingsV2 s)
{
var endpoint = string.IsNullOrWhiteSpace(s.ApiEndpoint) ? s.ApiBaseUrl : s.ApiEndpoint;
return new AppSettingsV2 { ApiBaseUrl = s.ApiBaseUrl, ApiEndpoint = endpoint };
}
}
ポイント
- 名前変更は「読めない」に直結しやすい
- 新旧併存で受け止め、最新へ変換してから本体へ渡す
4) 型変更(int→string など)で、読めない/意味が変わる
悪い例
// TimeoutSec: int → Timeout: "00:00:30"
public sealed class AppSettingsV2
{
public string Timeout { get; init; } = "";
}
直す(新フィールド追加 + 移行)
public sealed class AppSettingsV2
{
public int TimeoutSec { get; init; } = 30; // 旧
public string TimeoutText { get; init; } = ""; // 新
}
ポイント
- 型変更は一気にやらず、新フィールド追加で逃がし、変換を入れる
- 旧→最新への変換を一箇所に集めると追いやすい
5) 版数なしで、移行の分岐が組めなくなる
悪い例(版数がない)
{
"apiBaseUrl": "https://api.example.com",
"timeoutSec": 30
}
直す(版数 + data)
{
"version": 2,
"data": {
"apiBaseUrl": "https://api.example.com",
"timeoutSec": 30
}
}
ポイント
- 後から版数を足すと「過去データが何版か分からない」が発生しやすい
- 読み込み時に version で分岐し、最新の保存用モデルへ変換してから渡す
6) 入力防御なしで、壊れた入力が重さや落ち方へ直結する
ここでの入力防御は「壊れた入力・悪意ある入力を読んでも、落ちない/重くならない/変な挙動をしない」。
代表例は次の通り。
- サイズ爆弾:巨大入力でメモリ/CPUを食う
- 深さ爆弾:深いネストで解析が重くなる
- 型名ベースの自動復元:型名を信じて生成し、境界が崩れる
- XMLの外部参照:外部参照や展開で想定外へ到達する
直す(最小)
- 入力サイズ上限
- JSONの深さ上限
- 型名ベースの自動復元を使わない
- XMLは外部参照を使わない設定にする
- 失敗時は「どこで壊れたか」を残せる形にする(例外位置、版数、ファイル種別など)
版数と移行(旧データを読み続ける流れ)
読み込みは次の順に寄せると追いやすい。
- 外枠(version)を読む
- version で分岐し、該当モデルとして読む
- 最新の保存用モデルへ変換する
- 本体ロジックは最新だけを前提にする
using System.Text.Json;
public static class SettingsLoaderNet8
{
public static AppSettingsV1 LoadLatest(string json)
{
// 外枠だけ読む(Data は JsonElement にしておく)
var root = SettingsJsonNet8.DeserializeVersioned<JsonElement>(json);
AppSettingsV1 latest = root.Version switch
{
1 => root.Data.Deserialize<AppSettingsV1>() ?? throw new JsonException("v1 data invalid."),
_ => throw new JsonException($"Unsupported version: {root.Version}")
};
latest = SettingsValidation.Normalize(latest);
SettingsValidation.Validate(latest);
return latest;
}
}
地雷マップ(見返す用)
チェックリスト:レビューで見る所(表)
| 観点 | 確認ポイント |
|---|---|
| 保存用モデル | 内部モデルから分離されている(保存に出す形が安定している) |
| 欠損と既定値 | 欠損時の扱い(補う/必須で止める)が決まっている |
| 互換性ルール | 追加/削除/型変更/名前変更/形変更の扱いが整理されている |
| 版数 | ファイルに version が入り、読み込みが分岐できる |
| 移行 | 旧→最新の変換が一箇所に集まっている |
| 読み込み直後の検証 | サイズ/深さ/必須/範囲が入っている |
| 入力防御 | 型名ベースの自動復元を使っていない、XML外部参照を使わない設定になっている |
| 過去サンプル | v1/v2などのサンプルファイルが残り、読み込みテストがある |
セルフチェック(5問)
- 設定ファイルに項目を追加した時、欠損は既定値で補うのか、必須で止めるのかが言えるか
- 追加/削除/型変更/名前変更/形変更のうち、版数を上げて移行を用意する変更を言えるか
- version で分岐して「旧→最新へ変換→本体は最新だけ」の流れになっているか
- 読み込み直後に、サイズ/深さ/必須/範囲の検証が入っているか
- v1/v2など過去サンプルで、旧データが読めることをテストで確認できているか
回答の目安
- 1:人が触る設定は、欠損を補う運用が合う場面が多い。必須で止める場合は理由が必要になる
- 2:破壊的変更(削除/型変更/名前変更/形変更)は、移行なしだと「読めない」「意味が変わる」へ行きやすい
- 3:本体が旧版を直接扱い始めると、分岐が散って追いにくくなる
- 4:検証が無いと「巨大/深い/壊れた入力」が重さや落ち方へ直結しやすい
- 5:過去サンプルが無いと、変更のたびに互換性が静かに崩れやすい
関連トピック
- 連載Index(読む順・公開済リンク): S00_門前の誓い_総合Index
- K29_テキストフォーマット選定
連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index