4Hえんぴつ|React+TypeScriptでWeb RPGを作る
第6章「課題に対応しながら設計を改善する」
第32回:未来のセーブデータを無理に読まない――future schemaをfail-closedで保護する
1. 今回のテーマ
前回は、
Schema 2
↓
Validation
↓
Backup
↓
Migration
↓
Schema 3
という流れで、
古いセーブデータを現在の形式へ引き継ぐ方法を考えました。
古い形式は、
こちらがその構造を知っている
ため、変換できます。
では、逆の場合はどうでしょうか。
現在のアプリが、
Save Schema 3
までしか知らないのに、
保存されているデータが、
Save Schema 99
だった場合です。
今回は、
現在のアプリが知らない未来のセーブデータを、どう安全に扱うか
を考えます。
2. 「古い」と「新しすぎる」は違う
現在のアプリがSchema 3だとします。
Schema 2なら、
Schema 2
↓
過去に自分たちが使っていた形式
↓
構造を知っている
↓
Migrationできる
と考えられます。
一方、
Schema 99なら、
Schema 99
↓
現在のアプリより新しい
↓
構造を知らない
です。
この違いは重要です。
3. 知らない構造は変換できない
例えば、未来のSchema 4で、
以下のような変更が入っているかもしれません。
新しい必須項目が追加された
既存項目の意味が変わった
ID体系が変更された
GameStateの構造が変わった
現在のSchema 3側には、
その情報がありません。
つまり、
Schema 4
↓
Schema 3へ変換
するための正しいルール自体を、
現在のアプリは知りません。
4. 「読めるところだけ読む」は危険
JavaScriptでは、
知らないプロパティがあっても、
一部の値を取り出せることがあります。
例えば、
以下は考え方を説明するため、実装を簡略化しています。
const state = rawSave.state;
const hp = state.playerStatus.hp;
const gold = state.playerStatus.gold;
このコードだけなら、
未来形式でも偶然動くかもしれません。
しかし、
一部が読めたことと、データ全体を正しく理解できたことは別です。
5. 読み込めたように見える方が危険
完全にエラーになるなら、
問題には気づきやすくなります。
しかし、
HPは読めた
ゴールドも読めた
町も表示された
ので、
たぶん大丈夫
としてしまう方が危険です。
例えば未来形式では、
あるフラグが必須になっている
依頼進行の意味が変わっている
イベント状態の表現が変わっている
かもしれません。
画面は開けても、
後でゲーム状態が壊れる可能性があります。
6. そこでfuture schemaを識別する
前回まで使ってきた、
saveSchemaVersion
がここでも役立ちます。
例えば、
現在の対応schemaを、
以下のように持っているとします。
以下は考え方を説明するため、実装を簡略化しています。
const CURRENT_SAVE_SCHEMA_VERSION = 3;
読み込んだデータが、
saveSchemaVersion = 99
なら、
現在より新しい形式だと判断できます。
7. current / old / futureを分ける
現在がSchema 3なら、
基本的には、
Schema 3
↓
current
Schema 1 / 2
↓
old
Schema 4以上
↓
future
と分けられます。
この分類によって、
処理も分けられます。
8. currentは通常ロード
Saved Schema 3
↓
現在形式としてValidation
↓
PASS
↓
通常ロード
です。
9. oldはMigration候補
Saved Schema 2
↓
旧形式としてValidation
↓
Migration
↓
Schema 3
↓
現在形式としてValidation
↓
通常ロード
です。
前回扱った流れです。
10. futureは通常ロードしない
future schemaは、
Saved Schema 99
↓
現在のアプリは構造を知らない
↓
通常ロードしない
とします。
ここでは、
Migrationもしません。
変換元の意味を知らないためです。
11. future schemaをunsupportedとして扱う
この状態を、
今回のセーブ設計では、
unsupported
として扱います。
つまり、
データが壊れている
とは判断しません。
正確には、
データは存在しているが、現在のアプリでは対応していない
という状態です。
12. unsupportedとinvalidを分ける
ここが今回の重要なポイントの一つです。
例えば、
saveSchemaVersion = 99
で、
その形式としては正常なデータだったとします。
現在のアプリが知らないだけです。
これは、
unsupported
です。
一方、
JSONとして読めない
必須項目が欠けている
想定外の値になっている
なら、
invalid
です。
13. 状態を整理する
例えば、
次のように考えられます。
compatible
↓
現在形式として利用可能
migratable
↓
古いがMigration可能
unsupported
↓
正常かもしれないが現在のアプリでは未対応
invalid
↓
データそのものに問題がある
全部を、
ロードできない
の一種類にまとめないことが重要です。
14. TypeScriptで結果を分ける
例えば、
以下は考え方を説明するため、実装を簡略化しています。
type SaveCompatibility =
| {
status: "compatible";
saveData: SaveData;
}
| {
status: "migratable";
fromSchemaVersion: number;
}
| {
status: "unsupported";
schemaVersion: number;
}
| {
status: "invalid";
};
このようにすると、
呼び出し側も、
compatibleならロード
migratableならMigration
unsupportedなら保護
invalidなら復旧処理
と分岐できます。
15. なぜunknownではなくunsupportedなのか
単に、
unknown
としてもよさそうです。
ただ、
今回表したいのは、
何なのか全く分からない
だけではありません。
saveSchemaVersionは認識できた
↓
ただし現在のアプリの対応範囲外
です。
そのため、
unsupported
という扱いの方が、
状態の意味を明確にできます。
16. fail-closedとは何か
今回採用している考え方が、
fail-closed
です。
簡単に言えば、
安全に扱えると確認できなければ、通常処理へ進めない
という方向です。
例えば、
対応している
↓
ロードする
一方、
対応しているか判断できない
↓
とりあえずロードする
とはしません。
17. fail-openとの違い
逆に、
よく分からないけれど
動きそうなので通す
方向を取ると、
結果的にfail-open寄りになります。
セーブデータでは、
これが危険になる場合があります。
なぜなら、
ロード後にデータを書き換える操作まで続く可能性があるからです。
18. 本当に怖いのは「読むこと」だけではない
future schemaで特に注意したいのは、
ロードそのものだけではありません。
例えば、
未来形式のセーブが、
slot_2
に入っていたとします。
現在の古いアプリで、
それを正しく読めなかったとします。
そのあと、
プレイヤーが同じslot_2へ通常セーブしたらどうなるでしょうか。
19. 古いアプリが未来データを上書きしてしまう
例えば、
slot_2
Schema 4
↓
未来のアプリで作成
というデータを、
Schema 3までしか知らないアプリで開いたとします。
そこで、
読めない
↓
空きスロット扱い
↓
Schema 3で上書き
してしまうと、
未来形式のセーブを消してしまいます。
これは避けたい動作です。
20. unsupportedは「空きスロット」ではない
つまり、
ロードできない
からといって、
空きスロット
ではありません。
整理すると、
empty
↓
データが存在しない
unsupported
↓
データは存在する
ただし現在のアプリでは扱えない
です。
この違いが重要です。
21. 通常上書きも拒否する
そこでfuture schemaについては、
通常ロード
↓
拒否
だけでなく、
通常上書き
↓
拒否
とします。
つまり、
理解できないデータを、理解できないまま壊さない
ことを優先します。
22. ロードと上書きの両方にガードを置く
例えば、
以下は考え方を説明するため、実装を簡略化しています。
function canLoadSave(
compatibility: SaveCompatibility
): boolean {
return compatibility.status === "compatible";
}
上書き側にも、
同様の判断が必要です。
以下は考え方を説明するため、実装を簡略化しています。
function canOverwriteSave(
compatibility: SaveCompatibility
): boolean {
return (
compatibility.status === "compatible" ||
compatibility.status === "migratable"
);
}
実際の条件は設計によって変わりますが、
重要なのは、
ロード側だけで保護を終わらせないこと
です。
23. UIにも違いを伝える
保存システム内部で、
unsupported
と判断できても、
UIで、
壊れています
と表示してしまうと、
意味が変わってしまいます。
例えば、
このセーブデータは、
現在のバージョンでは読み込めません
のように、
現在のアプリ側の対応範囲の問題だと分かる表示にできます。
24. 「削除してください」とすぐ誘導しない
future schemaは、
データそのものが壊れているとは限りません。
新しいアプリへ戻せば、
正常に使える可能性があります。
そのため、
対応していません
↓
削除してください
と即座に誘導するのも慎重に考える必要があります。
まずは、
保持する
という選択肢があります。
25. unsupportedをquarantineしない
後続の記事では、
壊れたセーブを、
quarantine
へ隔離する仕組みを扱います。
しかし、
future schemaは、
単純な破損ではありません。
unsupported
≠
corrupted
です。
そのため、
future schemaを見つけたからといって、
破損データと同じ処理へ流す必要はありません。
26. invalidとunsupportedで復旧方針も変わる
例えば、
invalid
なら、
backupがある?
↓
正常backupから復旧できる?
という処理が考えられます。
一方、
unsupported
なら、
現在のアプリでは扱わない
↓
元データを保持する
ことが中心になります。
状態を分けたことで、
処理方針も分けられます。
27. スロット単位で判定する
今回のWeb RPGには、
3つのセーブスロットがあります。
例えば、
slot_1
Schema 3
↓
compatible
slot_2
Schema 2
↓
migratable
slot_3
Schema 99
↓
unsupported
という状態も考えられます。
28. 一つのunsupportedで全部を止めない
slot_3がfuture schemaだからといって、
セーブ機能全体を使用禁止
にする必要はありません。
例えば、
slot_1
↓
通常利用
slot_2
↓
Migration
slot_3
↓
保護
とできます。
第31回と同じく、
問題の影響範囲をスロット単位に閉じ込める
考え方です。
29. 判定処理は一か所に集めたい
例えば、
セーブ一覧画面、
ロード処理、
上書き処理、
起動時復元で、
それぞれ別々に、
future schemaか?
を判断すると、
条件がずれる可能性があります。
そこで、
SaveData
↓
Compatibility Check
↓
compatible / migratable / unsupported / invalid
という判定を一か所へ集めます。
30. UIは判定結果を利用する
例えば、
compatible
↓
[ロード]
migratable
↓
Migration後[ロード]
unsupported
↓
[ロード不可][通常上書き不可]
invalid
↓
復旧処理へ
とできます。
UI自身がschema番号を細かく解釈しないようにします。
31. appVersionではなくsaveSchemaVersionを見る
ここでも、
第30回で分けた意味が効いてきます。
例えば、
appVersion = 2.0.0
だから、
future schemaとは限りません。
アプリは新しくても、
セーブ形式は同じかもしれません。
future schemaかどうかの判断では、
saveSchemaVersion
を基準にします。
32. 保存形式のversionは処理を決める情報になる
つまり、
saveSchemaVersionは、
単にログへ記録する数字ではありません。
どのparserを使うか
Migrationできるか
通常ロードできるか
上書きしてよいか
を決める材料です。
セーブ形式のversionが、
実際の制御へつながっています。
33. future schemaをテストする
こうした保護は、
コードを書くだけでなく、
実際の動作として確認したいところです。
例えば、
saveSchemaVersion = 99
のセーブを用意します。
そして、
セーブ一覧を開く
↓
future schemaを検出
↓
通常ロードできない
↓
通常上書きできない
↓
元データが残る
ことを確認します。
34. 「ロードできない」だけではテスト不足
テストで、
ロードボタンが失敗した
だけ確認しても十分ではありません。
例えば、
裏でデータを削除していたら、
future schemaの保護にはなっていません。
確認したいのは、
ロード拒否
+
上書き拒否
+
元データ保持
です。
35. エラーではなく仕様として扱う
future schemaを見つけること自体は、
必ずしもアプリの不具合ではありません。
例えば、
ユーザーが、
新しいアプリで保存
↓
何らかの理由で古いアプリを開く
ことで発生します。
つまり、
予想可能な状態です。
そのため、
例外が出たので失敗
だけではなく、
仕様として定義された状態
として扱います。
36. セーブデータはアプリより長く残ることがある
アプリ本体は、
更新されます。
しかし、
セーブデータは、
以前の版から引き継がれ続けます。
さらに、
新しい版
↓
古い版
という逆方向の組み合わせも発生します。
そのため、
セーブ設計では、
時間の前後をまたいだ互換性
を考える必要があります。
37. 「できるだけ読む」より「壊さない」
セーブ互換性を作っていると、
つい、
何とか全部読み込みたい
と考えます。
しかし、
知らない構造まで無理に読もうとすると、
かえって危険です。
そこで、
知っている
↓
読む
古いが知っている
↓
Migrationする
知らない
↓
止める
とします。
38. fail-closedは消極的な設計ではない
分からないから止める
というと、
機能不足のようにも見えます。
しかし、
セーブデータでは、
停止することで守れるものがあります。
プレイヤーの進行
未来形式のデータ
別バージョンで再利用できる可能性
です。
つまり、
扱えないデータを積極的に保護する設計
とも言えます。
39. current / old / future / invalidを整理する
ここまでをまとめると、
Current Schema
↓
compatible
↓
通常ロード
Old Supported Schema
↓
migratable
↓
backup
↓
Migration
↓
通常ロード
Future Schema
↓
unsupported
↓
通常ロード拒否
↓
通常上書き拒否
↓
元データ保持
Broken Data
↓
invalid
↓
復旧処理へ
となります。
40. 今回のポイント
今回のポイントは3つです。
- 現在のアプリより新しいfuture schemaは、意味を知らないため無理に読み込まない
- future schemaは
invalidではなくunsupportedとして区別する - 通常ロードだけでなく通常上書きも拒否し、理解できないデータを古いアプリから守る
整理すると、
理解できる
↓
処理する
古いが理解できる
↓
Migrationする
理解できない
↓
壊さず止める
です。
future schemaへの対応で大切なのは、
現在のアプリで読み込むことではなく、現在のアプリが知らない旅を壊さないこと
です。
41. 次回
次回は、
localStorageからIndexedDBへ、既存セーブを失わずに移行する【第33回】
です。
ここまでの第30~32回では、
SaveDataそのものの形式
を扱ってきました。
次は、
保存する場所そのものを、
localStorage
↓
IndexedDB
へ変更します。
正式公開版では、
Dexie.jsを使ってIndexedDBへ移行し、
旧localStorageからの初回自動移行も行っています。
さらに、
migration marker
を使って、
移行済みデータを再び古い保存先から巻き戻さない
仕組みも入れました。
次回は、
保存形式だけでなく、保存媒体そのものを変更するとき、既存セーブをどう守るか
を整理します。
この記事を最後まで読んでいただき、ありがとうございます。
「いいね」を押していただけるとうれしいです。これからの記事づくりの励みになります。
もし気に入っていただけましたら、フォローもよろしくお願いします。
前の記事
第31回 古いセーブデータを新しい形式へ引き継ぐ――Migrationの考え方
次の記事
第33回 localStorageからIndexedDBへ、既存セーブを失わずに移行する
連載トップ
第0回 React+TypeScriptで異世界RPGを作る――連載の目的と開発ロードマップ
note関連記事
セーブ形式を変えても、旅を失わないために #19
noteでは、future schemaをunsupportedとして扱う理由、通常ロードだけでなく通常上書きも拒否する考え方、unsupportedとinvalidを分けた理由を制作側の視点から書いています。