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?

K30 【鍛錬】項目を1つ足しただけなのに、もう読めない 互換性 バージョン セキュリティの落とし穴

Posted at

連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index

シリアライズ/デシリアライズ

シリアライズは、モデルクラスの状態を「ファイルに書ける形」へ変換して保存すること
逆にデシリアライズは、保存ファイルのデータをモデルクラスへ展開して復元すること

要するにこういう往復だ。

  • 保存: Model -> ファイル(JSONなど)
  • 復元: ファイル(JSONなど) -> Model

この話が出てくるのは、だいたい次の瞬間

  • 設定ファイルを読み書きしたい(端末別/環境別に値を変えたい)
  • アプリの状態を残したい(最近使った一覧、前回の画面位置、検索条件など)
  • キャッシュを残したい(一度取ったマスタ、API応答、計算結果を次回使い回したい)
  • ファイル連携やAPIで相手に渡したい(外部システム/別アプリ/別チームが読む)
  • テストで入出力サンプルを残したい(過去データを読めるかを自動で確認したい)

ここで勢いで始めると、後からこうなる

  • 旧データが読めない
  • ちょっと項目を変えただけで互換性が崩れる
  • 「触っていい値」と「触ったら壊れる値」の境界が曖昧になる
  • 想定外の入力で落ちる/重くなる/壊れる

K29とK30の関係

K29が「入れ物(テキスト形式)の選び方」なら、
K30は「入れ物の上に、壊れない約束(互換性/版数/防御)を作る」話。


1. ゴール

  • 保存用のモデルを用意し、内部都合の変更で保存データを壊しにくくする
  • 互換性のルール(追加/削除/型変更/名前変更)を言語化する
  • 版数の持ち方と移行の流れを作る(旧データを読み続ける)
  • 読み込み直後に検証し、壊れた入力で落ちないようにする
  • .NET 8 と .NET Framework 4.8 の最小パターンを持つ

2. 結論(まずはこれ)

2-1. 3点セット

  1. 保存用のモデルを分ける
    アプリ内部のクラスをそのまま保存に出さない。保存に出す形を別に持つ。

  2. ファイルに版数を入れる
    「何版の保存形式か」を一緒に残して、読む側が判断できるようにする。

  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 timeoutSectimeoutMs に変更 v1/v2を読み、v3へ変換してから使う

そして、互換性テスト用に「過去データ」を残す。

  • settings.v1.json
  • settings.v2.json
  • settings.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などのサンプルファイルで「旧データが読める」をテストしている

関連トピック


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?