連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index
シリアライズ/デシリアライズ
シリアライズは、モデルクラスの状態を「ファイルに書ける形」へ変換して保存すること。
逆にデシリアライズは、保存ファイルのデータをモデルクラスへ展開して復元すること。
要するにこういう往復だ。
- 保存:
Model -> ファイル(JSONなど) - 復元:
ファイル(JSONなど) -> Model
この話が出てくるのは、だいたい次の瞬間
- 設定ファイルを読み書きしたい(端末別/環境別に値を変えたい)
- アプリの状態を残したい(最近使った一覧、前回の画面位置、検索条件など)
- キャッシュを残したい(一度取ったマスタ、API応答、計算結果を次回使い回したい)
- ファイル連携やAPIで相手に渡したい(外部システム/別アプリ/別チームが読む)
- テストで入出力サンプルを残したい(過去データを読めるかを自動で確認したい)
ここで勢いで始めると、後からこうなる
- 旧データが読めない
- ちょっと項目を変えただけで互換性が崩れる
- 「触っていい値」と「触ったら壊れる値」の境界が曖昧になる
- 想定外の入力で落ちる/重くなる/壊れる
K29とK30の関係
K29が「入れ物(テキスト形式)の選び方」なら、
K30は「入れ物の上に、壊れない約束(互換性/版数/防御)を作る」話。
1. ゴール
- 保存用のモデルを用意し、内部都合の変更で保存データを壊しにくくする
- 互換性のルール(追加/削除/型変更/名前変更)を言語化する
- 版数の持ち方と移行の流れを作る(旧データを読み続ける)
- 読み込み直後に検証し、壊れた入力で落ちないようにする
- .NET 8 と .NET Framework 4.8 の最小パターンを持つ
2. 結論(まずはこれ)
2-1. 3点セット
-
保存用のモデルを分ける
アプリ内部のクラスをそのまま保存に出さない。保存に出す形を別に持つ。 -
ファイルに版数を入れる
「何版の保存形式か」を一緒に残して、読む側が判断できるようにする。 -
読み込み直後に検証する
デシリアライズ直後に「サイズ/必須/範囲/深さ」を検証してからアプリに渡す。
書き出し前も、最低限の整合性チェックを通してから保存する。
3. どこで使うか(用途別)
3-1. 設定/初期値(人が触る)
- 目的: 手編集しやすい、差分でレビューできる
- ここで詰みやすい: 欠損や誤入力、コメント問題、型の揺れ
- このページで押さえること: 欠損に強い読み込み、入力検証、保存用モデルを浅くする
3-2. 状態保存/永続化(アプリが書いてアプリが読む)
- 目的: 旧データも読める、移行できる
- ここで詰みやすい: クラス変更で復元不能、循環参照、型変更
- このページで押さえること: 版数、移行、保存用モデル分離、互換性テスト用サンプル
3-3. 通信/外部連携(外部システム/別アプリ/別チーム)
- 目的: 仕様として約束を守る、変更時に事故を起こさない
- ここで詰みやすい: 相手の期待とズレる、破壊的変更が混ざる、想定外入力
- このページで押さえること: 互換性ルール、版数、受信側の防御
3-4. キャッシュ/スナップショット(性能のために残す)
- 目的: 起動時の重い読み込みや計算を毎回やらない
- ここで詰みやすい: 古いキャッシュが読めない/読めたが意味が変わる/巨大化する
- このページで押さえること: 版数、検証、サイズ制限
4. 最初の掟: 保存用のモデルで分離する
保存・通信に出すのは、アプリ内部のモデルそのままではなく 保存用のモデル に限定する。
理由:
- 内部都合の変更が、そのまま保存データ破壊になりやすい
- 依存や参照が増えて、復元が重い/循環参照/想定外の要素が混ざる
- 入力検証を入れにくくなる(境界が曖昧になる)
4-1. 保存用モデルの形(壊しにくい寄せ方)
- プリミティブ寄り(数値/文字列/真偽/配列/辞書)を基本にする
- 日時の表現を決めておく
例:2026-01-18T12:34:56Zのような形式(ISO 8601。日時表現の一般的な書式)
あるいは epoch 秒などに寄せてもよい -
object/dynamicで逃げない - イベント/デリゲート/ハンドル/ポインタを入れない
- 欠損と既定値の扱いを決める(欠損は埋める/必須なら落とす)
4-2. 例: 内部モデルと保存用モデルを分ける
// 内部モデル: 変更されやすい(保存に出さない)
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) };
}
5. 互換性ルール(決めないと壊れる)
保存を始めた瞬間から、過去のファイルが残る。
この状態でコード側のモデルを変えると、次のどれかが起きる。
- 新しいアプリが旧ファイルを読めない
- 旧アプリが新ファイルを読めない(ロールバックできない)
- 読めた“つもり”で、意味が変わって事故る
5-1. 互換性の基本表
| 変更 | 新アプリが旧データを読む | 旧アプリが新データを読む | 方針 |
|---|---|---|---|
| フィールド追加 | 欠損を既定値で埋めれば通る | 未知フィールドを無視できれば通る | 追加は比較的安全 |
| フィールド削除 | 旧データ側の値が消えて困る | 旧アプリが必須扱いだと落ちる | 段階的に捨てる |
| 型変更 | 事故りやすい | 事故りやすい | 新フィールド追加で逃がす |
| 名前変更 | 事故りやすい | 事故りやすい | 新旧併存して移行 |
| enum追加 | Unknownを受け止めれば通る | 未知値で落ちると詰む | Unknownを用意 |
| 形変更(配列→単体など) | 事故りやすい | 事故りやすい | 版数を上げて移行 |
ポイントはこれ。
- 「追加」は安全寄りにできる(欠損を埋める/未知を無視する)
- 「削除」「型変更」「名前変更」「形変更」は危険寄り(読めない/意味が変わる)
5-2. 欠損と既定値を扱う
- 欠損は既定値で補うのか、必須として落とすのか
-
nullを許すのか、空文字/空配列で寄せるのか
この判断が揺れると「環境差」や「端末差」に見えて調査が伸びる。
6. 版数と移行(旧データを読み続ける設計)
互換性が必要になる可能性があるなら、最初から 版数を一緒に保存しておく。
後から足すと「過去データが何版か分からない」状態に落ちやすい。
6-1. 版数つきフォーマット(版数 + データ本体)
例: JSONならこう持つ。
{
"version": 2,
"data": {
"apiBaseUrl": "https://api.example.com",
"timeoutSec": 30
}
}
-
version: 何版の保存形式か -
data: 保存用モデルの中身
6-2. 移行の流れ
- 読み込み時に
versionを見て分岐する - 読んだら 最新の保存用モデルへ変換してからアプリへ渡す
- アプリ本体のロジックは 最新だけ を前提にする
版数分岐をあちこちに散らさない
6-3. 版数の付け方と、詰みにくい運用
運用で詰むのは、だいたい次のパターンだ。
- 何を変えたら版数を上げるべきか曖昧
- 旧データが残っているのに、読み方を消す
- v1→v3が必要なのに、変換が散って追えない
版数運用はこう寄せると詰みにくい。
-
追加だけで済む変更は、旧データ欠損を既定値で埋めて読めるようにする
(追加で壊れないなら、移行を増やさずに済む) - 意味が変わる変更(型変更、名前変更、形変更)は、版数を上げて移行を用意する
- 変換は「旧→最新」に寄せる
v1を読んだら最新へ変換して、それ以降は最新だけで扱う
例:
| 版 | 変更内容 | 読み込み側の扱い |
|---|---|---|
| v1 |
timeoutSec だけ |
v1を読む |
| v2 |
apiBaseUrl を追加 |
v1欠損は既定値で埋める、v2も読む |
| v3 |
timeoutSec を timeoutMs に変更 |
v1/v2を読み、v3へ変換してから使う |
そして、互換性テスト用に「過去データ」を残す。
settings.v1.jsonsettings.v2.jsonsettings.v3.json
これらを読み込むテストを回すと、「旧データが読める」を崩しにくい。
7. セキュリティ(シリアライズにおける意味)
ここでいうセキュリティは「認証」ではない。
壊れた入力・悪意ある入力を読んでも、落ちない/重くならない/変な挙動をしないという意味。
保存ファイルや通信データは、次の理由で改ざんされ得る。
- ユーザーが手で編集する
- 別ツールが吐く
- 外部システムから来る
- 事故で途中までしか書けていない
つまり「入力の出どころがアプリ外」になった時点で、信用しすぎると事故る。
7-1. 代表的な事故
- サイズ爆弾: 巨大入力でメモリ/CPUを食い潰す
- 深さ爆弾: 深いネストで解析が重くなる、例外位置が追いにくい
- 型の自動復元: 型名を信じて生成してしまい、境界が壊れる
- XMLの外部参照: 外部参照や展開で想定外を踏む
7-2. 最低限の防御(読み込み直後)
- 入力サイズの上限を決める
- JSONは深さ上限を決める
- 型名ベースの自動復元は使わない
- XMLは外部参照を使わない設定にする
- 失敗時に「どこで壊れたか」をログに残す(R05へ)
8. 実装パターン(最小で安全側)
8-1. 版数つき保存形式
public sealed class VersionedData<T>
{
public int Version { get; init; }
public T Data { get; init; } = default!;
}
public sealed class AppSettingsV1
{
public string ApiBaseUrl { get; init; } = "";
public int TimeoutSec { get; init; } = 30;
}
8-2. System.Text.Json(.NET 8) 最小例
using System.Text;
using System.Text.Json;
public static class SafeJson
{
private const int MaxBytes = 256 * 1024; // 例: 256KB
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Disallow,
AllowTrailingCommas = false,
MaxDepth = 32
};
// 読み込み直後に検証してから返す(サイズ/版数/nullなど)
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);
}
}
8-3. Newtonsoft.Json(.NET Framework 4.8) 最小例
using Newtonsoft.Json;
public static class SafeNewtonsoft
{
private static readonly JsonSerializerSettings Settings = new()
{
MissingMemberHandling = MissingMemberHandling.Ignore,
NullValueHandling = NullValueHandling.Include,
// 型名ベースの自動復元は使わない
TypeNameHandling = TypeNameHandling.None
};
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);
}
8-4. 版数ごとの読み込み分岐(最小)
using System.Text.Json;
public static class SettingsLoader
{
public static AppSettingsV1 LoadLatest(string json)
{
// まず版数つきの外枠だけ読む
var root = SafeJson.DeserializeVersioned<JsonElement>(json);
// versionごとに保存用モデルへ落とし、最後は最新へ寄せる
return root.Version switch
{
1 => root.Data.Deserialize<AppSettingsV1>() ?? throw new JsonException("v1 data invalid."),
_ => throw new JsonException($"Unsupported version: {root.Version}")
};
}
}
9. 地雷マップ(見返す用)
9-1. 互換性
9-2. 版数と移行
9-3. 読み込み直後の検証
9-4. 互換性テスト用サンプル
10. レビュー用チェックリスト
- 保存用モデルが内部モデルから分離されている
- 欠損と既定値の扱いが決まっている
- 互換性ルール(追加/削除/型変更/名前変更)が整理されている
- ファイルに版数が入っている
- 読み込み直後に検証してから使っている(サイズ/深さ/必須/範囲)
- 型名ベースの自動復元を使っていない
- 失敗時に「どこで壊れたか」がログに残る(R05へ)
- v1/v2などのサンプルファイルで「旧データが読める」をテストしている