4Hえんぴつ|React+TypeScriptでWeb RPGを作る
第6章「課題に対応しながら設計を改善する」
第33回:localStorageからIndexedDBへ、既存セーブを失わずに移行する
1. 今回のテーマ
ここまで、
第30回では、
appVersion
≠
saveSchemaVersion
を整理しました。
第31回では、
古いschema
↓
backup
↓
Migration
↓
現在schema
という移行を扱いました。
第32回では、
future schema
↓
unsupported
↓
通常ロードしない
↓
通常上書きもしない
というfail-closedの考え方を扱いました。
ここまで変えてきたのは、
セーブデータの中身
です。
今回は、
その一段外側に進みます。
セーブデータを保存する場所そのものを変える
という話です。
今回のWeb RPGでは、
localStorage
↓
IndexedDB
へ保存基盤を移行しました。
しかも、
新しいゲームとしてIndexedDBを使い始めたわけではありません。
すでにlocalStorageに残っているセーブデータを、
プレイヤーの進行を失わずにIndexedDBへ引き継ぐ
必要がありました。
2. 最初はlocalStorageで十分だった
Web RPGの開発初期では、
localStorageを使っていました。
localStorageには、
小さなプロトタイプを作るうえで、
分かりやすい利点があります。
ブラウザ標準
実装が簡単
サーバー不要
文字列として保存できる
再読み込み後も残る
例えば、
以下は考え方を説明するため、実装を簡略化しています。
localStorage.setItem(
"saveData",
JSON.stringify(saveData)
);
読み込む側も、
以下は考え方を説明するため、実装を簡略化しています。
const raw =
localStorage.getItem("saveData");
という形で扱えます。
開発初期の目的は、
ブラウザを閉じても続きを残せる
ことでした。
その段階では、
localStorageは十分に役割を果たしていました。
3. localStorageを使ったこと自体は問題ではない
後からIndexedDBへ移行したからといって、
最初からIndexedDBを
使うべきだった
とは考えていません。
初期段階で必要だったのは、
複雑な永続化基盤ではなく、
セーブできる
↓
ブラウザを閉じる
↓
もう一度開く
↓
続きから遊べる
ことの確認でした。
小さく始める段階では、
localStorageの単純さが利点になります。
4. セーブ機能が育つと扱うものが増えた
ところが、
ゲームをMVPへ広げ、
さらに公開品質へ近づけていくと、
保存するものが増えていきました。
最初は、
1つのセーブ
だったものが、
3つのセーブスロット
current
backup
quarantine
旧schema
migration情報
まで増えました。
さらに、
破損判定
backupからの復旧
future schema保護
Migration
も入ってきます。
5. 「保存できる」から「管理する」へ変わった
localStorageでも、
これらを実装すること自体は可能です。
例えば、
save_slot_1_current
save_slot_1_backup
save_slot_1_quarantine
save_slot_2_current
...
のように、
キーを増やして管理することもできます。
しかし、
扱うデータが増えるにつれて、
保存処理は、
値を1つ置く
というものではなく、
複数の役割を持つデータを
構造として管理する
ものへ変わってきました。
そこで、
保存基盤自体を見直すことにしました。
6. 次の保存先としてIndexedDBを選ぶ
IndexedDBは、
ブラウザに用意されているデータベースです。
localStorageと違い、
構造を持ったデータを、
より整理して扱えます。
今回のWeb RPGでは、
IndexedDBを直接操作するのではなく、
Dexie.js
を利用しています。
正式版では、
Dexie.js 4.4.3
を採用しています。
7. IndexedDBへ変えれば終わりではない
新しくIndexedDBを使うだけなら、
それほど難しくありません。
例えば、
新規インストール
↓
最初からIndexedDB
だけなら、
古いデータを考える必要はありません。
しかし、
すでにlocalStorageには、
プレイヤーが遊んできたセーブが残っています。
そこで問題になります。
新しい版を公開
↓
保存先をIndexedDBへ変更
↓
localStorageを見なくなる
だけでは、
以前のセーブが見えなくなってしまいます。
8. 保存方式の変更で旅を失わせない
プレイヤーから見ると、
localStorage
IndexedDB
Dexie.js
という技術は、
本来あまり重要ではありません。
重要なのは、
昨日保存した冒険を
今日も続けられること
です。
そのため、
今回の移行では、
保存媒体を変えても、既存セーブはそのまま引き継ぐ
ことを要件にしました。
9. 起動時に旧データを確認する
基本的な移行フローは、
次のようになります。
アプリ起動
↓
IndexedDBを初期化
↓
localStorageの旧データを確認
↓
移行対象あり?
├─ No
│ ↓
│ 通常起動
│
└─ Yes
↓
内容を確認
↓
IndexedDBへ移行
ユーザーに、
旧セーブをエクスポートしてください
のような操作は求めません。
通常の起動処理の中で、
自動的に移行します。
10. 保存先のMigrationとschema Migrationは別物
ここで注意したいのが、
第31回で扱ったMigrationとの違いです。
第31回は、
Save Schema 2
↓
Save Schema 3
という、
データ構造のMigration
でした。
今回は、
localStorage
↓
IndexedDB
という、
保存媒体のMigration
です。
似ていますが、
別の問題です。
11. 両方が同時に起こることもある
実際には、
保存媒体とschemaの違いが、
同時に存在することもあります。
例えば、
localStorage
+
Schema 2
に保存されているデータを、
IndexedDB
+
Schema 3
へ移す場合です。
すると、
考える必要があるのは、
どこに保存されているか
だけではありません。
どのschemaなのか
も確認する必要があります。
12. 移行を一つの巨大処理にしない
例えば、
localStorageから読む
↓
v2ならv3へ変換
↓
IndexedDBへ書く
という処理を、
一つの大きな関数にまとめることもできます。
しかし、
責務を分けた方が整理しやすくなります。
Legacy Storage Reader
↓
Save Compatibility Check
↓
Schema Migration
↓
Current SaveData
↓
IndexedDB Repository
です。
13. 保存先を変えてもSaveDataの意味は変えない
重要なのは、
localStorage版SaveData
と、
IndexedDB版SaveData
という、
別々のゲーム状態を作らないことです。
保存先が変わっても、
中心にあるのは、
SaveData
└ GameState
です。
変わるのは、
永続化する方法
です。
14. Repositoryを境界にしていたことが役立つ
第27回では、
保存処理を、
Game
↓
Save Service
↓
Save Repository
↓
Storage
のように考えました。
この境界があることで、
保存先を、
localStorage
から、
IndexedDB
へ変更しても、
ゲームロジック側への影響を抑えられます。
15. Dexie.js側では役割ごとに管理する
正式版では、
IndexedDB内に、
主に、
saveSlots
metadata
という役割を持つデータを管理しています。
saveSlotsでは、
セーブスロット側のデータを扱います。
一方、
metadataでは、
保存データそのものではなく、
移行状態など、
保存基盤全体に関係する情報を扱います。
16. currentだけ移せばよいわけではない
今回の移行で重要だったのは、
最新のcurrentだけ
を移すのではないことです。
ここまでのセーブ設計では、
current
backup
quarantine
legacy
に、
それぞれ意味があります。
保存先をIndexedDBへ変えたからといって、
その意味を捨ててしまうと、
これまで作ってきた安全設計が失われます。
17. backupも引き継ぐ
例えば、
localStorage側に、
current
+
backup
が存在していたとします。
IndexedDBへ移すときに、
currentだけ移してbackupを捨ててしまうと、
移行直後から、
破損したときに戻れる場所
がなくなります。
そのため、
backupも意味を維持したまま引き継ぎます。
18. quarantineも意味を維持する
同様に、
すでに隔離したデータがあるなら、
それを、
正常セーブ
として復活させてはいけません。
quarantine
↓
quarantineのまま
扱います。
保存媒体を変えるMigrationでは、
値だけでなく、データの役割も移す
必要があります。
19. 一度移行したら、もう一度移行しない
保存媒体Migrationで、
特に注意したのが、
二重移行
です。
例えば、
次のような状態を考えます。
1回目の起動
↓
localStorageからIndexedDBへ移行
↓
IndexedDBでゲームを進める
↓
新しいセーブを作る
ここまでは問題ありません。
しかし、
localStorageの古いデータが残っていたらどうでしょうか。
20. 古いセーブで巻き戻る可能性がある
次回起動時に、
再び、
localStorageにデータがある
↓
移行する
と判断すると、
IndexedDB側で新しく進んだ状態を、
古いlocalStorageデータで上書きする可能性があります。
IndexedDB
新しい進行
↓
古いlocalStorageを再移行
↓
過去へ巻き戻る
これは避けなければなりません。
21. migration markerを持つ
そこで、
正式版では、
移行済みであることを示すmigration marker
を持たせています。
考え方は、
Migration開始
↓
旧データを移行
↓
正常完了
↓
Migration済みを記録
です。
次回起動時には、
Migration済み?
├─ Yes
│ ↓
│ localStorageを移行元として使わない
│
└─ No
↓
移行対象を確認
とします。
22. markerは単なる高速化ではない
migration markerは、
毎回localStorageを
調べなくて済む
という性能上の目的だけではありません。
より重要なのは、
一度移行した古いデータを、再び現在データへ戻さない
ことです。
つまり、
整合性を守るための情報です。
23. markerを立てるタイミングも重要
例えば、
Migrationを始める前に、
migrationCompleted = true
としてしまったとします。
その後、
IndexedDBへの書き込みが失敗したら、
移行できていない
+
移行済み扱い
になります。
これでは困ります。
24. 正常完了してから完了を記録する
基本的には、
旧データ確認
↓
変換
↓
IndexedDBへ保存
↓
保存結果確認
↓
Migration完了を記録
という順序にします。
つまり、
migration marker自体も、
Migrationの一部です。
25. 移行失敗時に黙って切り替えない
正式版では、
IndexedDBの初期化に失敗したとき、
黙ってlocalStorageへfallbackする設計にはしていません。
一見すると、
IndexedDBが使えない
↓
localStorageを使えばゲームは動く
ので親切に見えます。
しかし、
保存先が状況によって変わると、
どちらが現在データなのか分からなくなる可能性があります。
26. 二つの保存先が同時に正本になる危険
例えば、
昨日
IndexedDBへ保存
今日
IndexedDB初期化失敗
↓
localStorageへ保存
となると、
IndexedDB
↓
昨日の状態
localStorage
↓
今日の状態
という、
二つの正本ができます。
次回IndexedDBが使えるようになったとき、
どちらを採用すればよいでしょうか。
27. fail-closedは保存媒体でも使える
そこで、
第32回で扱ったfail-closedの考え方を、
保存基盤にも適用できます。
IndexedDB初期化成功
↓
通常処理
IndexedDB初期化失敗
↓
黙って別保存先へ切り替えない
です。
ユーザー体験だけを見ると、
一時的に操作を止めることになります。
しかし、
データの正本を曖昧にしない方を優先します。
28. 保存場所を変えても操作は変えない
内部では、
localStorage
↓
IndexedDB
という大きな変更がありました。
しかし、
ユーザーから見える操作は、
できるだけ変えません。
セーブ
上書き
ロード
つづきから
という操作は、
以前と同じです。
29. ここでE2Eテストが役立つ
保存先を変えると、
内部実装は大きく変わります。
しかし、
プレイヤーから見た動作は変えたくありません。
そこで、
すでに作っていたPlaywrightのE2Eテストを、
回帰試験として使えます。
例えば、
タイトル
↓
ゲーム開始
↓
セーブ
↓
上書き
↓
ロード
です。
30. 破損復旧も同じように動くか確認する
単純なセーブ・ロードだけではありません。
IndexedDBへ移行後も、
これまで作った、
current破損
↓
backup確認
↓
復旧
↓
quarantine
という仕組みが、
同じように動く必要があります。
保存場所を変えた結果、
安全対策が壊れていては意味がありません。
31. future schema保護も回帰対象になる
同じく、
第32回で扱った、
future schema
↓
通常ロード拒否
↓
通常上書き拒否
も、
保存先がIndexedDBへ変わった後に維持される必要があります。
つまり、
保存媒体Migrationは、
単なるStorage API変更ではありません。
これまで作ってきたセーブ仕様すべてに対する、
回帰リスク
があります。
32. fake-indexeddbで単体テストする
IndexedDBはブラウザAPIなので、
テスト環境では扱いづらい部分があります。
そこで、
正式版では、
fake-indexeddb 6.2.5
も利用しています。
これにより、
IndexedDBを使うRepositoryなどを、
ブラウザE2Eだけに頼らずテストできます。
33. 単体テストと実ブラウザテストを分ける
ただし、
fake-indexeddbで通れば、
実ブラウザでも必ず問題ない、
とは限りません。
そこで、
Vitest
↓
ロジック・Repository・Migration
Playwright
↓
実際のブラウザ操作
のように役割を分けます。
34. 本当に旧localStorageから移れるか確認する
移行機能では、
特に、
旧localStorageデータを用意
↓
アプリ起動
↓
IndexedDBへ移行
↓
ロード可能
という、
実際のMigration経路を確認することが重要です。
新規作成したIndexedDBセーブが使えるだけでは、
Migrationのテストにはなりません。
35. migration markerもテストする
さらに、
一度移行したあと、
IndexedDB側で進行
↓
再起動
したとき、
古いlocalStorageデータが、
再び移行されないことも確認します。
つまり、
初回移行できること
だけでなく、
2回目に移行しないこと
も仕様です。
36. 「何もしないこと」もテスト対象になる
こうした処理では、
正常に何かが起こることだけでなく、
二度目は何もしない
ことも重要です。
例えば、
migration markerあり
↓
legacy dataあり
↓
再移行しない
です。
状態管理では、
起こるべき処理と、起きてはいけない処理
の両方を確認する必要があります。
37. 保存媒体を変えてもゲーム側を変えない
今回の設計を整理すると、
ゲーム側は、
save(slotId, state)
load(slotId)
のような操作を使います。
その先が、
localStorage
なのか、
IndexedDB
なのかは、
ゲームロジックが直接知る必要はありません。
38. Repositoryを挟む意味が見えてくる
開発初期では、
Repositoryを作ることが、
少し遠回りに見えることもあります。
しかし、
実際に保存基盤を、
localStorage
↓
IndexedDB
へ変更すると、
境界を作った意味が分かります。
UIやゲーム進行ロジックへ、
Storage APIの変更を広げずに済みます。
39. 正式版ではIndexedDBが正本になった
現在の正式公開版では、
保存媒体は、
IndexedDB
+
Dexie.js
です。
localStorageは、
現在の通常保存先ではありません。
役割としては、
旧保存データの移行元
になります。
正式公開版では、IndexedDB+Dexie.jsを永続化基盤とし、旧localStorageからの移行を一度だけ行い、migration markerで再移行を防止しています。
40. localStorageを使った経験も無駄ではない
ここまで見ると、
IndexedDBの方が、
すべて優れていたように見えるかもしれません。
しかし、
開発初期にlocalStorageを使ったことで、
先に、
何を保存するか
どうロードするか
どんなスロットが必要か
を確認できました。
保存基盤を作り込む前に、
ゲームとして必要なセーブ仕様を育てられたとも言えます。
41. 必要になった時点で基盤を変える
つまり、
今回の流れは、
最初から大きな保存基盤を作る
ではありません。
localStorageで小さく始める
↓
セーブ仕様を育てる
↓
役割が増える
↓
IndexedDBが必要になる
↓
既存データを安全に移行する
です。
小さく始めることと、
後から変えられる設計にすることを、
両立させています。
42. 今回のポイント
今回のポイントは3つです。
- 保存媒体をlocalStorageからIndexedDBへ変更しても、既存セーブを切り捨てない
- current/backup/quarantineなど、データの値だけでなく役割も維持して移行する
- migration markerを使い、一度移行した古いlocalStorageデータを再び現在データへ戻さない
整理すると、
旧localStorage
↓
移行対象確認
↓
互換性確認
↓
必要ならSchema Migration
↓
IndexedDBへ保存
↓
検証
↓
Migration完了を記録
↓
以後IndexedDBを正本として使う
です。
保存媒体Migrationで大切なのは、
新しい保存先へ書けること
だけではありません。
保存場所が変わっても、それまでの旅がそのまま続くこと
です。
43. 第6章のまとめ
第30回から、
セーブ機能を、
公開後の変更に耐えられる設計へ改善してきました。
第30回
appVersionとsaveSchemaVersionを分ける
第31回
古いschemaをMigrationする
第32回
future schemaをfail-closedで保護する
第33回
localStorageからIndexedDBへ移行する
ここまでで、
アプリが変わる
データ形式が変わる
保存場所が変わる
という変化に対して、
セーブデータをどう守るかを整理できました。
これで、
第6章「課題に対応しながら設計を改善する」
を一区切りとします。
44. 次回
次回からは、
第7章「テストと品質管理」
へ進みます。
最初のテーマは、
壊れたセーブデータをどう検出するか【第34回】
です。
これまでにも、
invalid
backup
quarantine
recovery
という言葉が出てきました。
次回からは、
その中身を具体的に見ていきます。
例えば、
JSONとして読めない
必須項目がない
想定外の値が入っている
schemaは対応しているのに
GameStateとして成立しない
といった状態を、
どう判定するのか。
そして、
読めなかったから削除する
のではなく、
どう復旧へつなげるのかを整理します。
この記事を最後まで読んでいただき、ありがとうございます。
「いいね」を押していただけるとうれしいです。これからの記事づくりの励みになります。
もし気に入っていただけましたら、フォローもよろしくお願いします。
前の記事
第32回 未来のセーブデータを無理に読まない――future schemaをfail-closedで保護する
次の記事
連載トップ
第0回 React+TypeScriptで異世界RPGを作る――連載の目的と開発ロードマップ
note関連記事
localStorageからIndexedDBへ、セーブを移す #22
noteでは、なぜlocalStorageからIndexedDBへ移行したのか、既存セーブやbackup/quarantineをどう引き継いだのか、制作側の視点から書いています。([note][1])