4Hえんぴつ|React+TypeScriptでWeb RPGを作る
第7章「テストと品質管理」
第36回:壊れたcurrentをbackupから復旧する――Recoveryの実装フロー
1. 今回のテーマ
前回は、
current
↓
通常利用する現在のセーブ
backup
↓
復旧候補
quarantine
↓
壊れたデータの隔離先
という3つの役割を整理しました。
今回は、
その役割を使って、
currentが壊れている
↓
backupは正常
という状態から、
どの順番で復旧するか
を考えます。
Recoveryで大切なのは、
単に、
backup
↓
currentへコピー
することではありません。
壊れたデータを失わず、正常なbackupだけを使い、復旧後のcurrentも確認する
ところまでを一連の処理として考えます。
2. Recoveryはロード処理の途中で始まる
通常のロードは、
current取得
↓
validation
↓
valid
↓
GameStateをロード
です。
しかし、
validationの結果が、
invalid
だった場合、
通常ロードには進みません。
ここから、
Recovery処理へ分岐します。
current取得
↓
validation
↓
invalid
↓
Recovery
です。
3. currentが壊れているからといって、すぐ消さない
最初に避けたいのは、
current
↓
invalid
↓
削除
です。
壊れたcurrentには、
まだ調査に使える情報が残っている可能性があります。
例えば、
appVersion
saveSchemaVersion
slotId
savedAt
壊れる直前までのGameState
などです。
そのため、
Recoveryの最初の考え方は、
消すのではなく、守る
です。
4. 最初に破損currentをquarantineへ退避する
Recoveryでは、
まず、
invalid current
↓
quarantine
へ退避します。
これにより、
通常ロード対象から外しながら、
元データを残せます。
slot_1
current
↓
破損
quarantine
↓
破損currentを保持
という状態です。
5. なぜbackup復旧より先にquarantineするのか
例えば、
先に、
backup
↓
currentへ上書き
したとします。
すると、
元の壊れたcurrentが失われる可能性があります。
そのため、
考え方としては、
壊れたcurrentを保存
↓
その後でbackupから復旧
の順にします。
Recoveryの途中でも、
元データを失わない
ことを優先します。
6. quarantineへ入れれば復旧成功ではない
ここで注意します。
current
↓
quarantine
できたからといって、
ゲームを再開できるわけではありません。
quarantineは、
破損データを通常利用から外した
だけです。
次に、
正常な復旧元があるかを確認します。
7. backupを探す
次は、
同じスロットのbackupを確認します。
slot_1
├ current → invalid
├ backup → ?
└ quarantine
backupがなければ、
自動復旧はできません。
backupが存在する場合も、
まだ復旧できるとは限りません。
8. backupも無条件には信用しない
例えば、
backupあり
だけで、
そのデータをcurrentへ戻すのは危険です。
backupも、
壊れている可能性があります。
そこで、
backup取得
↓
validation
↓
valid?
とします。
9. backupにも第34回と同じvalidationを使う
backupだからといって、
別の特別な検証方法を用意する必要はありません。
基本的には、
currentへ使った、
parse
schema判定
validation
を利用できます。
つまり、
current
↓
同じvalidation
backup
↓
同じvalidation
です。
保存場所ではなく、
SaveDataとして成立しているか
を確認します。
10. backupがfuture schemaなら復旧に使わない
例えば、
backupが、
saveSchemaVersion = 99
だったとします。
これは、
壊れているとは限りません。
しかし、
現在のアプリでは、
unsupported
です。
そのため、
current
↓
invalid
backup
↓
unsupported
なら、
通常Recoveryには使いません。
11. backupがold schemaならMigration可能性を考える
一方、
backupが、
old / migratable
だった場合があります。
この場合、
backup
↓
旧schemaとしてvalidation
↓
Migration
↓
現在schema
↓
validation
を経て、
正常な現在形式にできるなら、
復旧候補として扱えます。
12. RecoveryとMigrationを混ぜすぎない
ただし、
責務としては分けて考えます。
Recovery
↓
正常な復旧元を探す
と、
Migration
↓
旧schemaを現在schemaへ変換する
です。
Recovery処理が、
Migrationの細かい変換内容まで持つ必要はありません。
13. Recovery側は「使えるSaveData」を受け取る
考え方としては、
backup
↓
Compatibility Check
↓
必要ならMigration
↓
Current Schema Validation
↓
復旧可能なSaveData
までを別処理に任せます。
Recovery側は、
このbackupは安全に使える
という結果を受けて、
currentへ復元します。
14. Recovery結果を型で表す
Recoveryも、
成功・失敗だけではなく、
いくつかの状態があります。
以下は考え方を説明するため、実装を簡略化しています。
type RecoveryResult =
| {
status: "recovered";
saveData: SaveData;
}
| {
status: "no_backup";
}
| {
status: "backup_unusable";
}
| {
status: "error";
};
これなら、
呼び出し側で、
recovered
no_backup
backup_unusable
error
を区別できます。
15. booleanだけでは理由が分からない
例えば、
以下は考え方を説明するため、実装を簡略化しています。
const success = await recoverSave();
だけだと、
falseだったときに、
backupがなかった
backupも壊れていた
書き込みに失敗した
のどれなのか分かりません。
Recoveryは複数の処理をまたぐため、
結果の意味を分けておくと扱いやすくなります。
16. 復旧可能なbackupが見つかったらcurrentへ戻す
backupが正常なら、
次に、
backup
↓
current
へ復元します。
ただし、
単純にオブジェクトをメモリ上へ返すだけではありません。
以後の通常ロードでも使えるよう、
保存基盤上のcurrentとして戻します。
17. currentを正式な状態へ戻す
例えば、
Recovery前が、
slot_1
current
↓
invalid
backup
↓
valid
なら、
Recovery後は、
slot_1
current
↓
backupから復旧した正常データ
quarantine
↓
元のinvalid current
という状態になります。
18. backupをそのまま残すかは設計次第
復旧後、
backupをどう扱うかは、
保存世代の設計によって変わります。
例えば、
backupをそのまま保持する
方法もありますし、
新しい正常currentを基準に、
次回保存時にbackupを更新する方法もあります。
重要なのは、
Recoveryの途中で、
復旧元まで不用意に失わないことです。
19. currentへ書けたら終わりではない
ここが重要です。
backup
↓
currentへ書き込み成功
しただけでは、
Recovery成功と断定しません。
書き込んだcurrentが、
本当に正常に読み戻せるかを確認します。
20. 復旧後のcurrentを再validationする
流れとしては、
backup
↓
currentへ保存
↓
currentを再取得
↓
validation
↓
valid
です。
これで初めて、
正常なcurrentを再構築できた
と判断できます。
21. 書き込み成功とデータ正常は別
例えば、
Storage APIとしては、
write success
でも、
アプリが期待する構造になっていない可能性はあります。
そのため、
書けた
=
復旧成功
ではなく、
書けた
+
正常に読めた
=
復旧成功
と考えます。
22. 復旧後にGameStateをロードする
currentが正常だと確認できたら、
通常ロードへ戻ります。
Recovered current
↓
SaveData
↓
GameState
↓
Application
です。
Recovery専用のゲーム状態を作る必要はありません。
最終的には、
通常のGameStateへ戻ります。
23. UI側から見ると通常ロードに近づける
理想的には、
プレイヤーから見ると、
つづきから
↓
ロード
という操作の裏側で、
必要ならRecoveryが動きます。
つまり、
通常ならcurrentをロード
壊れていたらbackupから復旧してロード
です。
復旧可能であれば、
できるだけ通常の再開へ戻します。
24. ただしRecoveryした事実は内部で分かるようにする
プレイヤー操作を複雑にしなくても、
内部では、
通常ロードだった
backupからRecoveryした
を区別できる方が便利です。
例えば、
ログやテストで、
recovered_from_backup
と確認できます。
25. Recoveryで元の破損データを上書きしない
Recovery時に避けたいのは、
invalid current
↓
その場でbackupに置換
↓
元データ消失
です。
そのため、
invalid current
↓
quarantine
↓
backupからcurrent復旧
という順番に意味があります。
26. 処理順序をまとめる
基本フローを整理すると、
current取得
↓
validation
↓
invalid
↓
currentをquarantine
↓
backup取得
↓
backup validation
↓
valid
↓
backupをcurrentへ復元
↓
current再取得
↓
current validation
↓
valid
↓
GameStateをロード
となります。
27. Recovery途中で失敗したらどうするか
例えば、
quarantine成功
↓
backupも正常
↓
currentへの書き込み失敗
ということも考えられます。
このとき、
たぶん書けただろう
としてゲームを続けてはいけません。
Recovery自体が完了していないからです。
28. 中途半端なRecoveryを成功扱いしない
考え方としては、
全工程成功
↓
recovered
です。
途中で失敗したら、
error
として、
通常ロードには進みません。
ここでもfail-closed寄りに扱います。
29. quarantineに残っていることが役立つ
Recovery途中で失敗しても、
最初に元currentをquarantineへ保存できていれば、
元データが残ります。
つまり、
Recovery失敗
↓
元データも消失
という最悪の状態を避けやすくなります。
30. backupがない場合は自動Recoveryしない
次に、
current
↓
invalid
backup
↓
なし
の場合です。
このケースでは、
復旧元がありません。
そのため、
壊れたcurrentを推測で修復するのではなく、
自動Recoveryを止めます。
31. currentはquarantineへ残す
backupがなくても、
invalid current
↓
quarantine
は行えます。
その結果、
通常currentがなくなれば、
スロットを空きとして再利用できます。
32. 復旧できないことと、スロットを失うことは別
例えば、
slot_2
↓
current破損
↓
backupなし
でも、
破損currentをquarantine
↓
slot_2 currentなし
↓
新しいセーブを作成可能
とできます。
一つの事故で、
そのスロットを永久に失わせないようにします。
33. backupも壊れている場合
例えば、
current
↓
invalid
backup
↓
invalid
の場合です。
このとき、
backupを復旧元には使いません。
正常ではないデータから、
正常なcurrentを作れる保証がないためです。
34. backupの破損も別途保持を考えられる
設計によっては、
壊れたbackupについても、
調査用として隔離・記録することが考えられます。
ただし、
重要なのは、
backupという名前だから安全
と思わないことです。
backupもSaveDataの一つとしてvalidationします。
35. Recoveryをslot単位に閉じ込める
例えば、
slot_1
↓
valid
slot_2
↓
Recovery中
slot_3
↓
valid
の場合、
slot_1とslot_3は、
通常利用できるようにします。
一つのRecovery失敗を、
セーブシステム全体へ広げません。
36. 他スロットを書き換えない
Recovery処理には、
対象スロットを明確に渡します。
以下は考え方を説明するため、実装を簡略化しています。
async function recoverSlot(
slotId: SaveSlotId
): Promise<RecoveryResult> {
// 指定されたslotだけを対象にする
}
これにより、
slot_2の復旧
が、
slot_1
slot_3
へ影響しないようにします。
37. Recoveryは冪等性も意識したい
Recovery処理の途中で、
ブラウザを閉じたり、
再実行されたりする可能性があります。
そのため、
同じRecoveryをもう一度実行したら
さらに壊れる
ような設計は避けたいところです。
38. すでにRecovery済みなら通常ロードへ進む
例えば、
1回目で正常に復旧できていれば、
次回起動時には、
current
↓
valid
です。
そのため、
Recoveryには入りません。
valid current
↓
通常ロード
となります。
39. quarantineを再びcurrentへ戻さない
また、
一度quarantineした破損データを、
次回起動時に、
以前のセーブがある
として自動的にcurrentへ戻してはいけません。
quarantineは、
通常利用対象から外した状態です。
40. 保存場所がIndexedDBでも考え方は同じ
現在の正式公開版では、
永続化基盤は、
IndexedDB
+
Dexie.js
です。
しかし、
Recoveryの中心にある考え方は、
Storage APIそのものではありません。
current
backup
quarantine
validation
Recovery
という役割です。
41. Repositoryの責務としてまとめやすい
例えば、
ゲームUIから、
IndexedDBのテーブル操作を直接行うのではなく、
Save Service
↓
Save Repository
↓
IndexedDB / Dexie.js
という境界を使います。
Recovery処理も、
保存層へ寄せることで、
UIからストレージ固有の処理を分離できます。
42. Recoveryを単体テストする
まずは、
関数・Repositoryレベルで、
例えば、
current invalid
backup valid
を用意します。
そして、
currentが正常データになる
元currentがquarantineへ残る
他slotは変化しない
ことを確認します。
43. backupなしケースもテストする
次に、
current invalid
backupなし
を用意します。
期待する結果は、
currentを通常利用しない
quarantineへ残る
slotを再利用できる
他slotは影響を受けない
です。
44. future schemaとの取り違えもテストする
例えば、
current
↓
Schema 99
なら、
Recoveryを始めてはいけません。
これは、
invalid
ではなく、
unsupported
だからです。
つまり、
future schema
↓
quarantine
↓
backup復旧
には進みません。
45. Recoveryのテストは結果だけでは足りない
例えば、
画面上で、
ロードできた
だけ確認すると、
内部で、
壊れたcurrentを削除しているかもしれません。
そのため、
テストでは、
ロード成功
+
current正常
+
quarantine存在
+
他slot不変
まで見る価値があります。
46. E2Eではプレイヤー操作として確認する
このRecoveryは、
PlaywrightによるE2Eでも確認している重要なシナリオです。
note #21でも、主要E2Eの一つとして、
current破損
+
backup正常
↓
復旧
+
quarantine
を確認しています。([note][1])
47. なぜE2Eまで必要なのか
Recovery処理単体が正しくても、
セーブ一覧画面
ロードボタン
起動時復元
IndexedDB
React状態管理
をつないだときに、
別の問題が出る可能性があります。
E2Eでは、
それらを含めて、
実際に続きを遊べるか
を確認できます。
48. 正式公開版ではRecoveryまで受入済み
現在の正式公開版では、
破損セーブ検出・backup・quarantine・復旧がPhase Gの正式完了項目になっています。
公開品質として、
単にセーブできるだけでなく、
壊れたときにどう戻るか
までを受入対象にしています。
49. 今回のポイント
今回のポイントは3つです。
- 破損currentをすぐ上書きせず、先にquarantineへ退避して元データを残す
- backupもvalidationし、安全だと確認できた場合だけ復旧元として使う
- backupからcurrentへ戻したあとも再validationし、正常なcurrentになったことを確認してからロードする
整理すると、
current
↓
invalid
↓
quarantine
backup
↓
validation
↓
valid
↓
currentへ復旧
↓
再validation
↓
valid
↓
GameStateをロード
です。
Recoveryで大切なのは、
backupをコピーすることではなく、安全な状態へ戻れたことを確認すること
です。
50. 次回
次回は、
セーブ処理をVitestでどこまでテストするか【第37回】
です。
ここまで、
validation
current
backup
quarantine
Migration
future schema
Recovery
と、
セーブ処理がかなり増えてきました。
これらを、
更新のたびにすべて手作業で確認するのは現実的ではありません。
次回はまず、
どこまでをVitestで確認するか
を整理します。
そのうえで、
第38回では、
PlaywrightによるゲームE2E
へ進みます。
この記事を最後まで読んでいただき、ありがとうございます。
「いいね」を押していただけるとうれしいです。これからの記事づくりの励みになります。
もし気に入っていただけましたら、フォローもよろしくお願いします。
前の記事
第35回 current/backup/quarantineでセーブを守る
次の記事
連載トップ
第0回 React+TypeScriptで異世界RPGを作る――連載の目的と開発ロードマップ
note関連記事
壊れたセーブデータから、どう復旧するか #20
noteでは、破損currentを検出し、正常なbackupがあれば復旧し、元の破損データはquarantineへ隔離する考え方を、制作側の視点から書いています。([note][2])