ストアに出したアプリは、私の手元の都合では引っ込められません。新しい版を出した日にも、古い版は誰かの端末で動き続けます。そして古い版は、出荷したときのサーバーの応答の形を、そのまま覚えております。
個人開発で運用している壁紙アプリは、サーバーが返す一覧を「何番目の項目が何か」で読む作りです。1番目が ID、2番目がタイトル、3番目がカテゴリ、というように、項目の名前ではなく位置で受け取っております。この形は軽くて速い反面、サーバー側で項目を一つ消したり、並びを入れ替えたりした瞬間に、出荷済みの旧版が別の項目を読み始めます。私の運用では、その結果として旧版が起動時にクラッシュする、というところまで進みます。
だから決まりは一つに絞りました。位置で読まれる応答には、項目を末尾にしか足しません。 削除も並び替えもしません。ただ、決まりは書いただけでは守れません。エージェントに配信ツリーの整理を任せた日や、疲れた夜の手作業では、うっかり破ります。そこで、決まりを機械に守らせることにしました。
ここでは、その門番を二つ、動かした出力つきでお見せします。一つは応答の形を比べる検査、もう一つは出荷済みの版ディレクトリを凍結する台帳です。
先に、何を守るのかを一枚の表にしました
最初にやったのは、変えてよいものと変えてはいけないものの仕分けです。「位置で読まれる」と一口に言っても、壊れ方は変更の種類ごとに違います。
| サーバー側の変更 | 旧版への影響 | 扱い |
|---|---|---|
| 行(壁紙)を末尾に追加 | 旧版は新しい行を普通に読むだけ | 自由に行う |
| 各行の末尾に項目を追加 | 旧版は知らない位置を読まない | 許可(追加は末尾のみ) |
| 途中の項目を削除 | 後ろの項目が一つ前にずれる | 禁止 |
| 項目の並び替え | 別の項目を読む。型が同じなら気づきにくい | 禁止 |
| 出荷済みの行を削除・並べ替え | お気に入りや通知の参照先が消える | 禁止 |
| 閲覧数のように、もともと値が動く項目の更新 | 位置は変わらない | 許可(変えてよい位置を明示) |
この表の三行目と四行目が、事故の中心です。行を足すのは安全で、項目を足すのも末尾なら安全です。危ないのは、すでに出荷した位置の意味を動かす変更だけです。検査はこの一点を見張ればよい、と整理できました。
一つ目の門番: 出荷済みの応答と、上げる応答を比べます
発想は単純で、出荷した時点の応答を「基準」として一つ取っておき、これから上げる応答と突き合わせます。見るのは四つです。
- 一番短い行の列数が減っていないか
- 既存の位置の型が変わっていないか
- 先頭の列を主キーとみなし、出荷済みの行が消えたり並びが入れ替わったりしていないか
- 出荷済みの行の値が、変えてよいと明示した位置を除いて一致しているか
#!/usr/bin/env python3
"""出荷済み版の応答(baseline)と、これから上げる応答(candidate)を比べ、
「位置で読む」クライアントを壊す変更だけを落とす。
使い方: append_only_check.py BASELINE.json CANDIDATE.json [--mutable 3,5]
--mutable: 出荷済みの行でも値が変わってよい列の位置(例: 閲覧数)。それ以外は値まで一致を求める
終了コード: 0=安全 / 1=壊す変更あり / 2=入力不正
"""
import json, sys
from collections import Counter
def kind(v):
# 値でなく「型」を見る。壁紙が増えて値が変わるのは正常だから
if isinstance(v, bool): return "bool"
if isinstance(v, int): return "int"
if isinstance(v, float): return "float"
if isinstance(v, str): return "str"
if v is None: return "null"
return type(v).__name__
def column_kinds(rows):
"""列ごとの型の集合(null は「未設定」なので型の判定から外す)"""
cols = {}
for r in rows:
for i, v in enumerate(r):
if v is not None:
cols.setdefault(i, set()).add(kind(v))
return cols
def main(base_path, cand_path, mutable=frozenset()):
try:
base = json.load(open(base_path, encoding="utf-8"))
cand = json.load(open(cand_path, encoding="utf-8"))
assert all(isinstance(r, list) for r in base + cand)
except Exception as e:
print(f"入力不正: {e}"); return 2
problems = []
# 1) 行ごとの列数: 出荷済みの行より短くしない
base_w = Counter(len(r) for r in base)
cand_w = Counter(len(r) for r in cand)
min_base, min_cand = min(base_w), min(cand_w)
if min_cand < min_base:
problems.append(f"最短の行が {min_base} 列 → {min_cand} 列に短くなっています")
# 2) 既存の列の型が変わっていないか(位置 i の意味が入れ替わっていないか)
bk, ck = column_kinds(base), column_kinds(cand)
for i in sorted(bk):
if i not in ck:
problems.append(f"列 {i} が候補から消えています(型 {sorted(bk[i])})")
elif not ck[i] <= bk[i]:
problems.append(f"列 {i} の型が {sorted(bk[i])} → {sorted(ck[i])} に変わっています")
# 3) 先頭列を主キーとみなし、出荷済みの行が消えていないか・並びが入れ替わっていないか
base_ids = [r[0] for r in base]
cand_ids = [r[0] for r in cand]
if cand_ids[:len(base_ids)] != base_ids:
gone = [x for x in base_ids if x not in set(cand_ids)]
problems.append(f"出荷済みの行の先頭一致が崩れています(消えた行 {len(gone)} 件)"
if gone else "出荷済みの行の並びが入れ替わっています")
# 4) 同じ型の列どうしの入れ替えは型だけでは見えない。出荷済みの行は値まで一致を見る
by_id = {r[0]: r for r in cand}
diffs = Counter()
for r in base:
c = by_id.get(r[0])
if c is None: continue
for i, v in enumerate(r):
if i in mutable: continue
if i >= len(c) or c[i] != v: diffs[i] += 1
for i, n in sorted(diffs.items()):
problems.append(f"列 {i}: 出荷済みの行 {n} 件で値が変わっています(入れ替えの疑い)")
print(f"baseline: {len(base)} 行 / 列数 {dict(sorted(base_w.items()))}")
print(f"candidate: {len(cand)} 行 / 列数 {dict(sorted(cand_w.items()))}")
if problems:
for p in problems: print("NG " + p)
return 1
added_cols = max(cand_w) - max(base_w)
print(f"OK 末尾追加のみ(追加行 {len(cand)-len(base)} 件・追加列 {max(added_cols,0)} 本)")
return 0
if __name__ == "__main__":
args = [a for a in sys.argv[1:]]
mutable = frozenset()
if "--mutable" in args:
k = args.index("--mutable")
mutable = frozenset(int(x) for x in args[k + 1].split(",") if x)
del args[k:k + 2]
if len(args) != 2:
print(__doc__); sys.exit(2)
sys.exit(main(args[0], args[1], mutable))実際に動かして確かめた出力を、ここに残します。基準は 3 行 × 4 列の小さな応答です。実運用の応答の形そのものではなく、検査の挙動を確かめるための見本として用意しました。
--- ok(行を1件足し、各行の末尾に1列足した)
baseline: 3 行 / 列数 {4: 3}
candidate: 4 行 / 列数 {5: 4}
OK 末尾追加のみ(追加行 1 件・追加列 1 本)
exit=0
--- drop(3列目を丸ごと消した)
baseline: 3 行 / 列数 {4: 3}
candidate: 3 行 / 列数 {3: 3}
NG 最短の行が 4 列 → 3 列に短くなっています
NG 列 2 の型が ['str'] → ['int'] に変わっています
NG 列 3 が候補から消えています(型 ['int'])
NG 列 2: 出荷済みの行 3 件で値が変わっています(入れ替えの疑い)
exit=1
--- rowgone(出荷済みの2行目を消した)
NG 出荷済みの行の先頭一致が崩れています(消えた行 1 件)
exit=1