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つ追加しただけで旧データが読めなくなる: 互換性・版数・入力防御

0
Last updated at Posted at 2026-01-18

連載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. 保存用のモデルを分ける
    アプリ内部のクラスをそのまま保存に出さず、保存に出す形を別に持つ。

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

  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は外部参照を使わない設定にする
  • 失敗時は「どこで壊れたか」を残せる形にする(例外位置、版数、ファイル種別など)

版数と移行(旧データを読み続ける流れ)

読み込みは次の順に寄せると追いやすい。

  1. 外枠(version)を読む
  2. version で分岐し、該当モデルとして読む
  3. 最新の保存用モデルへ変換する
  4. 本体ロジックは最新だけを前提にする
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問)

  1. 設定ファイルに項目を追加した時、欠損は既定値で補うのか、必須で止めるのかが言えるか
  2. 追加/削除/型変更/名前変更/形変更のうち、版数を上げて移行を用意する変更を言えるか
  3. version で分岐して「旧→最新へ変換→本体は最新だけ」の流れになっているか
  4. 読み込み直後に、サイズ/深さ/必須/範囲の検証が入っているか
  5. v1/v2など過去サンプルで、旧データが読めることをテストで確認できているか
回答の目安
  • 1:人が触る設定は、欠損を補う運用が合う場面が多い。必須で止める場合は理由が必要になる
  • 2:破壊的変更(削除/型変更/名前変更/形変更)は、移行なしだと「読めない」「意味が変わる」へ行きやすい
  • 3:本体が旧版を直接扱い始めると、分岐が散って追いにくくなる
  • 4:検証が無いと「巨大/深い/壊れた入力」が重さや落ち方へ直結しやすい
  • 5:過去サンプルが無いと、変更のたびに互換性が静かに崩れやすい

関連トピック


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

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?