4Hえんぴつ|React+TypeScriptでWeb RPGを作る
第8章「Web公開・互換性・永続化」
第41回:IndexedDBを直接扱わない――Dexie.jsでセーブRepositoryを組む
1. 今回のテーマ
前回までの第7章では、
- 壊れたセーブデータの検出
- current/backup/quarantine
- backupからのRecovery
- Vitest
- Playwright E2E
- console warning/pageerror
- WebKit・iPhone・A11y
まで、公開前の品質管理を扱いました。
今回から第8章です。
ここでは、Webアプリとして公開するために整えた、
永続化・SPA・production環境・rollback
を順番に見ていきます。
最初のテーマは、IndexedDBです。
現在のWeb MVPでは、セーブデータを
IndexedDB
+
Dexie.js
で保存しています。
ただし、ゲームの各画面からIndexedDBを直接操作しているわけではありません。
間に、
Save Repository
という境界を置いています。
2. なぜIndexedDBを直接呼ばないのか
例えば、町の画面でセーブするときに、
Town画面
↓
IndexedDBへ直接書き込む
という実装もできます。
ロード画面でも、
Save画面
↓
IndexedDBから直接読む
とできます。
小さいアプリなら、それでも動きます。
しかし、セーブ処理が育ってくると、
画面側が知ることが増えていきます。
例えば、
どのDBを使うのか
どのstoreへ保存するのか
currentをどう扱うのか
backupをいつ作るのか
quarantineをどう保存するのか
Migration済みかどうか
です。
これらをReactコンポーネントへ持ち込むと、UIと永続化処理が強く結びついてしまいます。
3. ゲーム側が知りたいのは「保存できるか」
ゲーム側から見れば、本当に必要なのはもっと単純です。
例えば、
このスロットへ保存したい
このスロットを読み込みたい
このスロットの状態を確認したい
という操作です。
ゲームロジック側は、
IndexedDB
Dexie.js
object store
transaction
といった保存技術の詳細まで知る必要はありません。
そこで、
Game / UI
↓
Save Service
↓
Save Repository
↓
Dexie.js
↓
IndexedDB
という境界を作ります。
4. Repositoryは「保存方法」を隠す
Repositoryの役割は、
ゲーム側から見た、
セーブデータの入口と出口
です。
以下は考え方を説明するため、実装を簡略化しています。
interface SaveRepository {
save(
slotId: SaveSlotId,
saveData: SaveData
): Promise<void>;
load(
slotId: SaveSlotId
): Promise<SaveData | null>;
remove(
slotId: SaveSlotId
): Promise<void>;
}
ゲーム側は、
このインターフェースを通してセーブを扱います。
その先がIndexedDBなのか、別の保存方法なのかを意識しません。
5. Dexie.jsをIndexedDBとの境界にする
IndexedDBはブラウザ標準のAPIですが、そのまま扱うと処理が比較的低レベルになります。
そこで現在のWeb MVPでは、
Dexie.js 4.4.3
を使っています。
正式公開版の永続化構成は、
Database
adventure-web-mvp
主な保存領域
saveSlots
metadata
です。
saveSlotsはセーブスロット側のデータを扱い、
metadataは保存基盤全体に関係する情報を扱います。
6. saveSlotsとmetadataを分ける
この二つは、役割が違います。
saveSlotsには、
各スロットのセーブ
current
backup
quarantine
など、ゲームの保存データに関係する情報があります。
一方、metadataには、
例えば、
localStorageからのMigrationが完了しているか
のような、保存システムそのものの状態を持たせられます。
つまり、
saveSlots
↓
ゲームのセーブ
metadata
↓
保存基盤の状態
と分けています。
7. Migration markerもゲーム状態ではない
第33回では、
localStorageからIndexedDBへ移行したあと、
古いデータを再び移行して巻き戻さないために、
migration marker
を使いました。
このmarkerは、
プレイヤーのHPや依頼進捗ではありません。
そのため、GameStateへ入れるものでもありません。
GameState
↓
ゲーム世界の現在状態
metadata
↓
保存基盤の管理情報
と分けることで、責務が整理できます。
8. SaveDataとStorageを分離する
ここで重要なのは、
SaveDataそのものと、
それをどこへ保存するかを分けることです。
現在のSaveDataは、おおむね、
SaveData
├ saveSchemaVersion
├ appVersion
├ slotId
├ savedAt
└ state
└ GameState
という構造です。
この意味は、保存先がlocalStorageでもIndexedDBでも変わりません。
変わるのは、
永続化する方法
です。
9. Repositoryがあると保存先を変更しやすい
実際、このWeb RPGでは、
最初はlocalStorageを中心に使い、
その後、
localStorage
↓
IndexedDB + Dexie.js
へ移行しました。
もし画面のあちこちで、
localStorage.getItem(...)
localStorage.setItem(...)
を直接使っていたら、
移行時に広い範囲を修正することになります。
Repositoryを境界にしておけば、
Game / UI
↓
Repository
という呼び出し側の考え方は維持しながら、
その下を、
LocalStorage implementation
↓
IndexedDB / Dexie implementation
へ変更できます。
10. current/backup/quarantineもRepository側へ寄せる
セーブ処理では、
単純なsave/loadだけではなくなりました。
現在は、
current
backup
quarantine
を扱います。
例えば保存時には、
旧current
↓
backup
新しいSaveData
↓
current
という処理があります。
破損時には、
invalid current
↓
quarantine
valid backup
↓
currentへRecovery
という処理があります。
こうした永続化上の操作をUI側へ散らすと、画面ごとに微妙に挙動が違う危険があります。
そのため、Repositoryやその上のSave Serviceへまとめます。
11. UIは「どう保存するか」を決めない
例えば、セーブ画面側は、
スロット2へ保存する
ことを要求します。
しかし、
先にcurrentをbackupへ移す
Dexieのtransactionを使う
どのtableへputする
といったことまでは決めません。
考え方としては、
UI
↓
「slot_2へ保存して」
Save Service / Repository
↓
「安全な保存手順を実行する」
です。
これにより、UIの責務を小さくできます。
12. transactionを使いたくなる理由
currentとbackupのように、複数のデータを関連して変更する場合があります。
例えば、
currentをbackupへ保存
↓
新しいcurrentを書き込む
という処理です。
途中で片方だけ成功すると、
保存状態が中途半端になる可能性があります。
Dexie.jsでは、IndexedDBのtransactionを扱いやすくできます。
以下は考え方を説明するため、実装を簡略化しています。
await db.transaction(
"rw",
db.saveSlots,
async () => {
await saveCurrentAsBackup(slotId);
await writeCurrent(slotId, saveData);
}
);
実際のテーブル構成や更新方法は実装に合わせますが、
考え方としては、
関連する保存操作を一つのまとまりとして扱う
ことが重要です。
13. 「書けた」だけではRepositoryの仕事は終わらない
この連載では、何度か、
書き込み成功
≠
正常なセーブ
と扱ってきました。
Repositoryがデータを書けたとしても、
そのSaveDataが、
対応schemaなのか
必要項目を持っているか
ロードして利用できるか
は別の問題です。
そのため、
Validation
Compatibility Check
Repository
Recovery
を組み合わせます。
Repositoryだけですべてを判断させるのではなく、役割を分けます。
14. fake-indexeddbでRepositoryをテストする
IndexedDBを使うRepositoryは、
ブラウザE2Eだけでなく、
Vitestでも確認しています。
そのために使っているのが、
fake-indexeddb 6.2.5
です。
考え方としては、
Vitest
↓
Save Repository
↓
Dexie.js
↓
fake-indexeddb
です。
これにより、
保存できる
読み戻せる
上書きできる
Recoveryできる
別スロットを壊さない
といった処理を、
Playwrightより小さい単位で繰り返し確認できます。
15. Repositoryを完全にmockしないテストも必要になる
Save Serviceのテストだけなら、
Repositoryをmockする方法もあります。
しかし、すべてをmockすると、
Dexieへの書き込みが間違っている
キー設計が間違っている
更新方法が間違っている
といった永続化層の問題を見つけられません。
そこで、
小さなロジック
↓
mockでも確認可能
Repositoryそのもの
↓
Dexie.js + fake-indexeddb
ユーザー操作全体
↓
Playwright + 実ブラウザ
と層を分けます。
16. 公開環境でも同じRepositoryを使う
最終的には、
開発環境だけでなく、
Netlifyへ公開したproduction URLでも、
同じ保存処理を使います。
正式公開版では、
保存
↓
上書き
↓
ロード
↓
再読み込み
を公開URLでも確認し、
IndexedDBの保持まで受入しています。
つまりRepositoryは、
開発用の抽象化ではなく、
実際のproductionでもセーブ品質を支える境界
になっています。
17. Repositoryは将来の変更点を閉じ込める場所になる
今後、保存方法がさらに変わる可能性もあります。
ただし、そのときも、
ゲーム側へ、
新しいStorage APIの都合を広げたくありません。
Game Logic
↓
Save API
──────── 境界 ────────
Repository
↓
Storage Technology
という形を維持しておけば、
変更点を境界の下へ閉じ込めやすくなります。
重要なのは、
IndexedDBを隠すためだけのRepositoryではなく、ゲームと永続化技術の境界として置く
ことです。
18. 今回のポイント
今回のポイントは3つです。
- ReactコンポーネントやゲームロジックからIndexedDBを直接操作せず、Repositoryを境界にする
- Dexie.jsを使って
saveSlotsとmetadataを管理し、ゲームデータと保存基盤の管理情報を分ける - Vitest+fake-indexeddb、Playwright、production受入を組み合わせ、Repositoryの内側から実ブラウザまで段階的に確認する
整理すると、
React / Game Logic
↓
Save Service
↓
Save Repository
↓
Dexie.js
↓
IndexedDB
です。
IndexedDBへ移行したことで大切だったのは、
単に、
localStorageより高機能な保存先を使う
ことではありません。
ゲーム側と保存技術の間に境界を作り、保存基盤を変更してもゲーム全体へ影響を広げにくくすること
でした。
19. 次回
次回は、
React RouterのSPAをNetlifyで直URL・再読み込み対応する【第42回】
です。
Web RPGをNetlifyへ公開すると、
トップページから順番に操作したときは動いても、
/character
/saves?mode=manage
/town
へ直接アクセスしたり、
そのURLでブラウザを再読み込みしたりすると、
問題が出ることがあります。
次回は、
React Router
+
Netlify
+
SPA fallback
を使って、
SPAでも直URL・再読み込みを成立させる方法
を扱います。
この記事を最後まで読んでいただき、ありがとうございます。
「いいね」を押していただけるとうれしいです。これからの記事づくりの励みになります。
もし気に入っていただけましたら、フォローもよろしくお願いします。
前の記事
第40回 Chromiumだけで終わらせない――WebKit・iPhone・A11yまで確認する
次の記事
第42回 React RouterのSPAをNetlifyで直URL・再読み込み対応する
連載トップ
第0回 React+TypeScriptで異世界RPGを作る――連載の目的と開発ロードマップ
note関連記事
localStorageからIndexedDBへセーブを移す #22
noteでは、なぜ保存先をlocalStorageからIndexedDBへ変更したのか、既存セーブやbackup/quarantineをどう引き継いだのかを、制作側の視点から書いています。