コンテンツへスキップ

設定ファイルのスキーマバージョニングとマイグレーション — アップデートで設定が読めなくなる問題を防ぐ

アプリを更新したら、前のバージョンで作った設定ファイルが読めなくなった。キーの名前が変わっていた、値の形が変わっていた、というのは、設定ファイルを持つソフトウェアでは珍しくない事故です。

この記事では、設定ファイルに「スキーマのバージョン」を持たせ、古い形式を段階的に新しい形式へ変換(マイグレーション)するという定番の設計を、Python 3.12.8 で実際に動かした結果を交えて整理します。特定のライブラリに依存しない考え方なので、JSON でも YAML でも同じです。

補足: ここでの「スキーマ」は、設定ファイルの構造のルール(どんなキーがあり、値がどんな型か)のことです。アプリのバージョン番号とは別に、設定の構造だけを表す番号を持たせます。

なぜ設定ファイルは壊れやすいのか

プログラムのコードは、更新のたびに新しいものに置き換わります。一方、設定ファイルは利用者の手元に古い形のまま残ります。新しいコードは新しい形を前提に書かれているため、両者がずれると次のような問題が起きます。

  • キー名が変わって、値が読めない(KeyError)
  • 値の型が変わって、読めても使えない(TypeError など)
  • エラーにはならないが、黙って初期値に戻ってしまい、利用者の設定が失われる

特に3つ目は気づきにくい失敗です。例外が出ないので、後で「設定が消えた」と分かったときには原因をたどるのが難しくなります。

実験: バージョンなしで形式を変えると何が起きるか

設定の形を、次のように変えたとします。

  • バージョン1: {"email": "...", "timeout": 20}
  • バージョン2: 通知先を notify の下にまとめる
  • バージョン3: timeout を接続と読み取りに分ける({"connect": 5, "read": 20})

バージョン1のファイルを、バージョン3向けのコードでそのまま読むと、次のようになりました。

old = {"email": "a@example.com", "timeout": 20}
old["notify"]            # KeyError: 'notify'
old["timeout"]["read"]   # TypeError: 'int' object is not subscriptable

どちらも例外で止まるぶんまだ親切で、.get() で初期値を返す書き方をしていた場合は、エラーなしに設定が無視されることになります。

解決の基本形: バージョン番号 + 変換関数の連鎖

設計の骨格はシンプルです。

  1. 設定ファイルに schema_version を持たせる(無ければ「1」とみなす)
  2. 「1→2」「2→3」のように、隣り合うバージョン同士の変換関数を1つずつ用意する
  3. 読み込み時に、ファイルのバージョンから現在のバージョンまで、変換関数を順番に適用する
CURRENT = 3
def m1_to_2(c): c["notify"] = {"email": c.pop("email", "")}; return c
def m2_to_3(c): c["timeout"] = {"connect": 5, "read": c.pop("timeout", 30)}; return c
STEPS = {1: m1_to_2, 2: m2_to_3}

def load(raw):
    c = json.loads(raw); v = c.get("schema_version", 1)
    while v < CURRENT:
        c = STEPS[v](c); v += 1
    c["schema_version"] = CURRENT
    return c

バージョン1のファイルを読み込むと、結果は次のとおりでした。

{'notify': {'email': 'a@example.com'}, 'timeout': {'connect': 5, 'read': 20}, 'schema_version': 3}

利用者が設定していた email と timeout=20 の値は引き継がれ、新しく増えた connect には既定値が入っています。バージョン2のファイルを読んだ場合は「2→3」だけが適用されます。

「隣同士の変換」を連鎖させる利点

「1→3」「2→3」のように、古い版ごとに最新版への変換を書く方法もありますが、版が増えるほど組み合わせが増えます。隣同士だけを書いておけば、新しい版を追加するときに足す関数は1つだけです。過去の変換は書き換えず、増やすだけにできます。

守りたい性質

1. 何度読んでも同じ結果になる(冪等性)

すでに最新版のファイルを読み込んだときに、何も変わらないようにします。実験では、変換後の結果をもう一度 load に通しても、結果は完全に一致しました(True)。起動のたびにマイグレーションが走っても安全、という状態です。

2. 新しすぎる設定は黙って読まない

古いアプリが、新しいバージョンで作られた設定を読む場面もあります(アプリを戻した、別のPCで古い版を使った、など)。このとき、知らない形式を推測で読んで保存し直すと、新しい版が必要としていた情報を壊しかねません。実験では、対応版より新しい schema_version: 4 を読ませると、次のように明示的に止めました。

RuntimeError: newer config v4 > supported v3

「止める」か「読み取り専用で扱う」かは設計次第ですが、黙って上書きしないことが大切です。

3. 変換前の内容を残す

マイグレーションは、一度走るとファイルの形が変わります。保存し直す前に元のファイルをコピーして残しておけば、変換の誤りに後から気づいても戻せます。変換の結果をテストで確認しておくこと自体も、もちろん有効です。

運用上の注意

  • バージョン番号は構造を変えたときだけ上げる。 値を変えただけ、キーを足しただけで既定値を補えば足りるなら、番号を上げなくてもよい場合があります。
  • キーの削除・名前の変更・型の変更は、必ず変換関数を書く。 これらは、古いファイルをそのままでは読めなくなる変更です。
  • 各変換関数には、古い形式のサンプルを使ったテストを付ける。 「1のファイルが3として読めること」を残しておくと、後の変更で壊したときにすぐ気づけます。
  • 変換関数は、後から書き換えない。 過去の版の変換を直してしまうと、すでに変換済みの利用者との間で結果がずれます。誤りがあれば、次の版で補正する形にします。

まとめ

  • 設定ファイルは、コードの更新後も古い形のまま利用者の手元に残る。
  • schema_version を持たせ、隣り合う版同士の変換関数を連鎖させると、版が増えても管理しやすい。
  • 変換は何度実行しても同じ結果になるようにし、新しすぎる設定は黙って上書きしない。
  • 変換前の内容を残し、古い形式のサンプルでテストを書いておく。

自分のツールの設定ファイルに、バージョンを表す項目があるかどうかを、一度見直してみてください。まだ無ければ、次に形式を変えるときが、導入する一番良いタイミングです。関連して、設定が読めない原因が文字エンコーディングにある場合は「UTF-8とcp932の違い」、書き込み中の中断で設定が壊れる問題は「アトミックな書き込み」も参照してください。