アプリを更新したら、前のバージョンで作った設定ファイルが読めなくなった。キーの名前が変わっていた、値の形が変わっていた、というのは、設定ファイルを持つソフトウェアでは珍しくない事故です。
この記事では、設定ファイルに「スキーマのバージョン」を持たせ、古い形式を段階的に新しい形式へ変換(マイグレーション)するという定番の設計を、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() で初期値を返す書き方をしていた場合は、エラーなしに設定が無視されることになります。
解決の基本形: バージョン番号 + 変換関数の連鎖
設計の骨格はシンプルです。
- 設定ファイルに
schema_versionを持たせる(無ければ「1」とみなす) - 「1→2」「2→3」のように、隣り合うバージョン同士の変換関数を1つずつ用意する
- 読み込み時に、ファイルのバージョンから現在のバージョンまで、変換関数を順番に適用する
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の違い」、書き込み中の中断で設定が壊れる問題は「アトミックな書き込み」も参照してください。