4Hえんぴつ|React+TypeScriptでWeb RPGを作る
第7章「テストと品質管理」
第34回:壊れたセーブデータをどう検出するか
1. 今回のテーマ
前回までの第6章では、
アプリが変わる
↓
セーブ形式が変わる
↓
保存場所が変わる
という変化に対して、
セーブデータをどう引き継ぐかを考えました。
第7章では、
そこから一歩進めて、
保存したデータが本当に正しいのか
を扱います。
セーブデータは、
保存されているからといって、
必ず正常とは限りません。
例えば、
JSONとして読めない
必要な項目がない
型が違う
想定していない値が入っている
といった状態があります。
今回は、
「ロードできなかった」ではなく、「どこまで確認すれば壊れていると判断できるのか」
を整理します。
2. セーブデータは壊れることがある
通常は、
Save
↓
Storage
↓
Load
という流れで、
保存したものをそのまま読み戻せます。
しかし実際には、
途中にさまざまな問題が入り得ます。
例えば、
保存処理の途中で中断された
古いデータの一部だけが残った
手作業でデータを書き換えた
想定外の形式が保存された
アプリ側の不具合で不完全な値を書いた
などです。
そのため、
ロード処理では、
保存されているものを無条件に信用しない
ようにします。
3. 最初の判定は「存在するか」
まず最も単純なのは、
データが存在するかどうかです。
slot_1
↓
データあり
slot_2
↓
データなし
slot_2にデータがないことは、
破損ではありません。
ただの、
empty
です。
これは、
invalid
とは区別します。
4. emptyとinvalidは違う
整理すると、
empty
↓
そもそもセーブが存在しない
です。
一方、
invalid
↓
何かは存在するが
正常なセーブとして扱えない
です。
この違いを最初に分けておくと、
空きスロットを誤って、
壊れたセーブ
として扱わずに済みます。
5. 次に「読み取れるか」を確認する
localStorageのような文字列保存では、
まずJSONとして読み取れるかを確認します。
以下は考え方を説明するため、実装を簡略化しています。
function parseJson(
raw: string
): unknown {
return JSON.parse(raw);
}
例えば、
{"slotId":"slot_1"
のように、
途中で切れていれば、
JSON.parse()は失敗します。
これは比較的分かりやすい破損です。
6. JSONとして読めても正常とは限らない
ここが重要です。
例えば、
次のデータはJSONとして正常です。
{
"foo": "bar"
}
しかし、
ゲームのセーブデータとしては、
必要な情報がありません。
つまり、
JSONとして読めた
=
正常なSaveData
ではありません。
7. parseとvalidationを分ける
そこで、
処理を、
文字列
↓
parse
↓
unknown
↓
validation
↓
SaveData
と考えます。
JSON.parse()が担当するのは、
JSONとして成立しているか
までです。
その後、
アプリ側で、
SaveDataとして成立しているか
を確認します。
8. unknownとして受け取る
外部から読み込んだデータを、
最初から、
SaveData
として扱うのは危険です。
例えば、
以下は考え方を説明するため、実装を簡略化しています。
const parsed: unknown =
JSON.parse(raw);
まずは、
何が入っているか分からない
という状態から始めます。
そのあとで、
SaveDataとして検証します。
9. TypeScriptの型だけでは守れない
例えば、
const save =
JSON.parse(raw) as SaveData;
と書くこともできます。
しかし、
as SaveDataは、
実際のJSONを検証しているわけではありません。
例えば、
{
"slotId": 123
}
でも、
TypeScriptの型アサーションだけなら、
コンパイル後には止めてくれません。
10. compile-timeとruntimeは別
TypeScriptが得意なのは、
コードを書くときの型安全性
です。
一方、
保存データは、
ブラウザの外側から戻ってくる値です。
つまり、
TypeScript型
↓
compile-time
保存済みJSON
↓
runtime
です。
runtimeで受け取った値には、
別途validationが必要になります。
11. SaveDataの外側から確認する
例えば、
現在のSaveDataには、
概ね、
interface SaveData {
saveSchemaVersion: 3;
appVersion: string;
slotId: "slot_1" | "slot_2" | "slot_3";
savedAt: string;
state: GameState;
migratedFromSchemaVersion?: 2;
}
という情報があります。
そこで、
まず、
objectなのか
saveSchemaVersionがあるか
slotIdがあるか
stateがあるか
といった外側から確認できます。
12. 必須項目を確認する
例えば、
次のデータがあったとします。
{
"saveSchemaVersion": 3,
"slotId": "slot_1"
}
JSONとしては正常です。
しかし、
appVersion
savedAt
state
がありません。
現在Schema 3として、
これらが必須なら、
正常なセーブとは扱えません。
13. 項目があるだけでも足りない
例えば、
{
"saveSchemaVersion": 3,
"appVersion": 123,
"slotId": "slot_1",
"savedAt": true,
"state": {}
}
というデータがあったとします。
項目名はあります。
しかし、
値の型が違います。
appVersion
↓
stringであるべき
savedAt
↓
stringであるべき
です。
そのため、
項目の存在と値の型の両方
を見る必要があります。
14. 値の範囲も確認する
型が正しくても、
値として不正な場合があります。
例えば、
{
"slotId": "slot_99"
}
です。
slotIdが文字列なので、
型だけ見ると、
string
です。
しかし現在のゲームで許可しているのは、
slot_1
slot_2
slot_3
です。
そのため、
値の範囲もvalidation対象になります。
15. enum的な値にも注意する
例えば、
ゲーム進行状態に、
success
partial_success
failure
だけを許しているとします。
そこに、
almost_success
が入っていたら、
文字列としては正常でも、
現在のモデルでは扱えません。
つまり、
型
+
許可される値
まで確認します。
16. GameStateの中も確認する
SaveDataの外側だけ正しくても、
state: GameState
の中身が壊れている場合があります。
例えば、
playerStatus
currentTownId
currentRoute
questProgress
eventProgress
などです。
SaveDataが正常でも、
GameStateとして成立しなければ、
ゲームを再開できません。
17. ただしすべてを一度に判定しない
validation処理を、
一つの巨大な関数へ詰め込むと、
分かりにくくなります。
考え方としては、
Raw Data
↓
Save Envelope Validation
↓
Schema Compatibility
↓
GameState Validation
↓
Semantic Validation
のように、
段階を分けられます。
18. まずセーブ形式を確認する
例えば、
saveSchemaVersion = 3
なら、
現在Schema 3のvalidationへ進めます。
しかし、
saveSchemaVersion = 99
なら、
第32回で扱った、
unsupported
です。
ここで、
Schema 3の必須項目チェックを当てて、
invalid
にしてしまうと、
意味が変わります。
19. future schemaを破損扱いしない
例えば、
Schema 99では、
Schema 3には存在しない構造が使われているかもしれません。
現在のアプリから見ると、
知らない項目
知らない構造
があります。
しかし、
それは、
壊れている
ことを意味しません。
単に、
現在のアプリでは扱えない
だけです。
20. 判定順序が重要になる
そこで、
例えば、
データ存在?
↓
JSONとして読める?
↓
schemaVersionを識別できる?
↓
future schema?
├─ Yes → unsupported
└─ No
↓
対応schemaとしてvalidation
という順序にします。
これなら、
future schemaを誤って破損扱いしません。
21. old schemaにも現在validationをそのまま当てない
同じことは、
古いSchemaにも言えます。
例えば、
Schema 2には、
Schema 3で追加された項目がありません。
そこへ、
いきなりSchema 3 validationを当てれば、
必須項目がない
↓
invalid
となります。
しかし、
本当は、
old
↓
migratable
かもしれません。
22. schemaごとに意味を理解する
つまり、
Schema 2
↓
Schema 2として検証
Schema 3
↓
Schema 3として検証
Schema 99
↓
現在はunsupported
という考え方です。
現在Schemaだけを正解としてすべてのデータを判定しない
ことが重要です。
23. validation結果を分類する
例えば、
以下は考え方を説明するため、実装を簡略化しています。
type SaveValidationResult =
| {
status: "valid";
saveData: SaveData;
}
| {
status: "migratable";
schemaVersion: number;
}
| {
status: "unsupported";
schemaVersion: number;
}
| {
status: "invalid";
reason: string;
};
これなら、
結果を、
valid
migratable
unsupported
invalid
として明示できます。
24. invalidにも理由を残す
単に、
invalid
だけでも処理はできます。
しかし、
調査を考えると、
理由を残した方が便利です。
例えば、
invalid_json
missing_required_field
invalid_slot_id
invalid_game_state
unexpected_value
などです。
25. reasonはユーザー表示とは分ける
内部では、
missing_required_field
と記録していても、
ユーザーへ、
missing_required_field
とそのまま表示する必要はありません。
UIでは、
このセーブデータを正常に読み込めませんでした
とし、
内部ログでは、
より具体的なreasonを残せます。
26. 「どこで壊れたか」が調査材料になる
例えば、
JSON parse
↓
失敗
なのか、
JSON parse
↓
成功
↓
SaveData validation
↓
失敗
なのかで、
問題の種類が違います。
さらに、
SaveData
↓
正常
↓
GameState validation
↓
失敗
なら、
ゲーム状態の生成側に問題があった可能性もあります。
27. 検出処理と復旧処理を分ける
ここで、
今回の記事では、
検出
までを中心にします。
例えば、
currentがinvalid
↓
backupを探す
↓
復旧する
の後半は、
別の責務です。
今回扱うのは、
current
↓
正常?
壊れている?
unsupported?
を判定するところまでです。
28. 判定と行動を分ける
例えば、
Validation
↓
invalid
という結果を受けて、
次に、
backupを見る
quarantineへ送る
スロットを再利用可能にする
などを決めます。
これを分けることで、
validation関数自体は、
壊れているかどうかを判定する
ことに集中できます。
29. currentだけでなくbackupも検証する
後でbackupから復旧する場合でも、
backupを無条件に信用してはいけません。
current
↓
invalid
backup
↓
?
です。
backupも、
同じようにvalidationします。
backup
↓
valid
なら、
復旧候補になります。
30. 壊れたcurrentから壊れたbackupへ戻さない
例えば、
current
↓
invalid
backup
↓
invalid
なら、
backupから復旧しても意味がありません。
そのため、
currentが壊れた
↓
backupがある
↓
backupも検証
↓
正常なら復旧候補
とします。
31. backupがあることと使えることは違う
これは、
これまで何度か出てきた、
存在する
≠
利用できる
という考え方です。
backupあり
だけではなく、
backupがvalid
であることを確認します。
32. 複数スロットは独立して検証する
3つのセーブスロットがある場合、
例えば、
slot_1
↓
valid
slot_2
↓
invalid
slot_3
↓
valid
という状態があります。
このとき、
slot_2が壊れているからといって、
セーブシステム全体を使えなくする必要はありません。
33. 問題をslot単位に閉じ込める
例えば、
slot_1
↓
通常利用
slot_2
↓
復旧処理
slot_3
↓
通常利用
とします。
これは、
第31回・第32回でも扱った、
影響範囲をスロット単位に閉じ込める
考え方です。
34. fail-closedで判断する
validationしていて、
これは正常か?
と判断できない場合に、
たぶん大丈夫
としてロードするのではなく、
安全だと確認できない
↓
通常ロードしない
方向にします。
セーブ周りでは、
これまでと同様、
fail-closed寄りに考えます。
35. ただし「すぐ削除」はしない
fail-closedだからといって、
invalid
↓
即削除
ではありません。
むしろ、
invalid
↓
通常ロードしない
↓
元データを保持
↓
復旧可能性を確認
とします。
壊れている可能性があるからこそ、
まず守ります。
36. 検出できなければ復旧もできない
backup復旧やquarantineを作る前に、
まず必要なのは、
何が正常で
何が異常なのか
を判定する仕組みです。
ここが曖昧だと、
正常なセーブを誤って隔離したり、
壊れたセーブを正常としてロードしたりする可能性があります。
37. テストすべきvalidationケース
例えば、
次のようなケースを用意できます。
正常なcurrent
JSON破損
必須項目欠落
型不一致
許可されないslotId
不正なGameState
旧schema
future schema
重要なのは、
これらが、
すべて、
ロード失敗
という同じ結果にならないことです。
38. 期待する分類を決める
例えば、
正常なcurrent
↓
valid
JSON破損
↓
invalid
必須項目欠落
↓
invalid
旧schema
↓
migratable
future schema
↓
unsupported
という期待値を決めます。
その分類自体を、
テストできます。
39. 正式公開版でも破損検出を品質基盤にした
正式公開版では、
セーブ品質について、
破損currentの検出
正常backupからの復旧
backupなし破損データの隔離
future schema保護
まで実装しています。
Phase Gでは、破損セーブ検出・backup・quarantine・復旧が正式完了項目になっています。
40. 「保存できる」だけでは公開品質にならない
開発初期では、
保存できる
ロードできる
ことが大きな目標でした。
しかし公開を考えると、
さらに、
壊れたデータを見分けられる
古いデータと区別できる
未来データと区別できる
正常な別スロットを巻き込まない
ことも必要になりました。
41. 今回のポイント
今回のポイントは3つです。
- JSONとして読めることと、正常なSaveDataとして成立することは別
-
old、future、invalidを同じ「読めないデータ」として扱わない - 検出と復旧を分離し、まずセーブデータの状態を正しく分類する
整理すると、
Raw Data
↓
存在確認
↓
Parse
↓
Schema判定
↓
Validation
↓
valid
migratable
unsupported
invalid
です。
壊れたセーブを守るために、
最初に必要なのは、
どう直すかではなく、何が起きているのかを正しく見分けること
です。
42. 次回
次回は、
current/backup/quarantineでセーブを守る【第35回】
です。
今回は、
current
↓
invalid
と判断できるところまで進みました。
次は、
その壊れたデータを、
削除
するのではなく、
current
backup
quarantine
という3つの役割に分けて扱います。
なぜ、
壊れたセーブ
↓
削除
ではなく、
壊れたセーブ
↓
quarantine
なのか。
そして、
backupをどのタイミングで作り、
currentとどう使い分けるのかを整理します。
この記事を最後まで読んでいただき、ありがとうございます。
「いいね」を押していただけるとうれしいです。
これからの記事づくりの励みになります。
もし気に入っていただけましたら、フォローもよろしくお願いします。
前の記事
第33回 localStorageからIndexedDBへ、既存セーブを失わずに移行する
次の記事
第35回 current/backup/quarantineでセーブを守る
連載トップ
第0回 React+TypeScriptで異世界RPGを作る――連載の目的と開発ロードマップ
note関連記事
壊れたセーブデータから、どう復旧するか #20
noteでは、壊れたセーブをすぐ削除するのではなく、まず破損を検出し、backupやquarantineを使って守るようにした背景を制作側の視点から書いています。([note][1])