4Hえんぴつ|React+TypeScriptでWeb RPGを作る
第6章「課題に対応しながら設計を改善する」
第31回:古いセーブデータを新しい形式へ引き継ぐ――Migrationの考え方
note関連記事
セーブ形式を変えても、旅を失わないために #19
noteでは、なぜ古いセーブをそのまま切り捨てず、新しい形式へ引き継ぐことにしたのかを、制作側の視点から書いています。
本連載では、設計・実装・テスト・改善にAIを活用しながら、React+TypeScriptによるWeb RPG開発を進めています。
1. 今回のテーマ
前回は、
appVersion
↓
アプリケーションのバージョン
と、
saveSchemaVersion
↓
セーブデータ構造のバージョン
を分けました。
これによって、
現在のアプリ
Save Schema 3
保存されているセーブ
Save Schema 2
のような状態を識別できます。
では、
古いSchema 2のセーブを見つけたら、
どうすればよいのでしょうか。
最も簡単なのは、
古い形式なので読み込めません
とすることです。
しかし、それでは、
アプリを更新しただけで、
それまでの進行を失うことになります。
そこで今回は、
古いセーブデータを現在の形式へ変換して引き継ぐMigration
について考えます。
2. セーブ形式は開発とともに変わっていく
ゲームを作り始めたころは、
必要なセーブ項目も少なくて済みます。
例えば、
以下は考え方を説明するため、実装を簡略化しています。
interface SaveDataV1 {
hp: number;
gold: number;
}
しかし、
ゲームが育つにつれて、
保存したい状態も増えていきます。
評判
現在地
依頼進行
イベント進行
街道途中の状態
フラグ
解放済みの町
などです。
結果として、
セーブ構造そのものも変わります。
3. 新しい型を作るだけでは古いデータは変わらない
例えば、
新しいセーブ形式を、
以下のように変更したとします。
以下は考え方を説明するため、実装を簡略化しています。
interface SaveDataV3 {
saveSchemaVersion: 3;
appVersion: string;
slotId: SaveSlotId;
savedAt: string;
state: GameState;
}
アプリ側のTypeScriptを変更すれば、
これから作るセーブ
は新しい形式になります。
しかし、
ブラウザには、
以前保存した古いデータが残っています。
アプリ側
↓
Schema 3
保存済みデータ
↓
Schema 2
です。
TypeScriptの型を書き換えても、
保存済みJSONが自動的に新しくなるわけではありません。
4. そこでMigrationが必要になる
Migrationは、
古いデータ構造を、
現在のデータ構造へ変換する処理です。
今回のWeb MVPでは、
セーブスキーマv2 → v3
への移行を行いました。note側でも、この移行を「旧形式を見つけたら新形式へ自動変換する」仕組みとして整理しています。([note(ノート)][1])
基本的な流れは、
v2セーブを発見
↓
内容を確認
↓
v3へ変換
↓
v3として保存
↓
ゲームを続ける
です。
5. プレイヤーに変換作業をさせない
Migrationを、
プレイヤー自身に、
旧セーブをエクスポートしてください
↓
変換ツールを使ってください
↓
新しいセーブをインポートしてください
と操作してもらう方法もあります。
しかし、
通常のゲームでそこまで求めるのは、
あまり親切ではありません。
可能なら、
ゲームを開く
↓
旧セーブを検出
↓
内部でMigration
↓
そのまま続きを遊べる
ようにしたいところです。
6. Migrationの目的は「新しくすること」だけではない
Migrationというと、
古いデータ
↓
新しいデータ
へ変換することだけに目が向きます。
しかし、
本当に守りたいのは、
データ形式ではありません。
守りたいのは、
選んだ職業
受けた依頼
通った街道
解決したイベント
現在のHP
持っているゴールド
解放した町
といった、
プレイヤーの進行です。
つまり、
Migrationの目的は、
形式を変更しても、ゲーム上の意味をできるだけ維持すること
です。
7. まず古いSchemaを識別する
Migrationを行うには、
最初に、
そのセーブが何世代なのかを知る必要があります。
そこで、
前回扱った、
saveSchemaVersion
が使われます。
例えば、
saveSchemaVersion = 2
なら、
Schema 2として読む
↓
Schema 2 → 3 Migrationを実行
できます。
8. 中身から古い形式を推測しない
例えば、
この項目がないから
たぶんv2
のように、
データ内容だけから世代を推測する方法もあります。
しかし、
その項目がない理由が、
旧形式だから
なのか、
データが壊れたから
なのか、
判断しにくくなります。
そのため、
saveSchemaVersion = 2
という明示的な情報を使います。
9. Migration前にデータを検証する
ただし、
saveSchemaVersion = 2
と書いてあるからといって、
すぐ変換してよいとは限りません。
例えば、
本来必要な、
slotId
player
state
などが欠けている可能性があります。
そこで、
考え方としては、
Schema 2を検出
↓
Schema 2として妥当か確認
↓
妥当
↓
Migration
とします。
10. 「旧形式」と「壊れている」は違う
これは重要です。
例えば、
Schema 2
必要項目がそろっている
なら、
古いが正常
です。
一方、
Schema 2と書いてある
↓
でも必須項目がない
なら、
破損している可能性
があります。
つまり、
old
と、
invalid
は別です。
11. Migration前にbackupを作る
今回の実装で重要なのが、
Migration前backup
です。
いきなり、
旧セーブ
↓
新形式で上書き
とはしません。
先に、
旧セーブ
↓
backup
↓
Migration
↓
新しいcurrent
という順序にします。
note #19でも、「新しい形式へ変えることより、失敗したときに戻れること」を優先した考え方として、この手順を説明しています。([note(ノート)][1])
12. なぜ先にbackupするのか
例えば、
Migration処理に不具合があったとします。
Schema 2
↓
Migration
↓
変換結果がおかしい
ここで、
元のSchema 2をすでに上書きしていたら、
元へ戻せません。
しかし、
先にbackupしておけば、
current
↓
変換失敗
backup
↓
元のSchema 2
を残せます。
13. Migrationは失敗する前提で考える
Migrationコードを書いていると、
つい、
正しく変換できる
ことを前提に考えます。
しかし、
公開後のデータを扱うなら、
変換途中で例外
想定外の値
書き込み失敗
保存領域の問題
新形式の検証失敗
なども考える必要があります。
そこで、
Migrationに失敗しても、元データまで失わせない
ことを設計条件にします。
14. Migration関数のイメージ
例えば、
以下は考え方を説明するため、実装を簡略化しています。
function migrateV2ToV3(
saveV2: SaveDataV2
): SaveDataV3 {
return {
saveSchemaVersion: 3,
appVersion: saveV2.appVersion,
slotId: saveV2.slotId,
savedAt: saveV2.savedAt,
state: convertStateV2ToV3(saveV2),
migratedFromSchemaVersion: 2,
};
}
ポイントは、
単純に、
saveSchemaVersion: 3
へ書き換えているだけではないことです。
15. バージョン番号だけ変えてもMigrationにはならない
例えば、
save.saveSchemaVersion = 3;
だけ実行しても、
中身がSchema 2のままなら、
実際には新形式になっていません。
ラベル
Schema 3
中身
Schema 2
という、
より危険な状態になります。
そのため、
新しいschemaが要求する意味へデータを変換する
必要があります。
16. 足りない状態をどう補うか
新しいSchemaで、
以前存在しなかった項目が増えた場合があります。
例えば、
以下は考え方を説明するため、実装を簡略化しています。
flags: Record<string, boolean>;
が新しく必要になったとします。
Schema 2には、
その値がありません。
そこで、
Migration時に、
flags: {}
のような初期値を入れることがあります。
17. デフォルト値には意味が必要
ただし、
何でも、
なければ0
なければfalse
なければ空配列
とすればよいわけではありません。
例えば、
questCompleted = false
と補うことで、
本当は完了済みだった依頼を、
未完了に戻してしまうかもしれません。
つまり、
Migrationで値を補う場合も、
旧データの意味を新構造でどう表現するか
を考える必要があります。
18. マスターデータから再構築できるものもある
第25回では、
マスターデータとセーブデータを分けました。
例えば、
Town.name
Quest.description
GameEvent.title
などは、
セーブ側へコピーせず、
IDからマスターデータを参照します。
そのため、
Migrationでも、
マスターデータから求められる情報を、
無理に旧セーブから作る必要はありません。
19. IDはできるだけ維持する
例えば、
旧セーブに、
currentTownId = town_001
が入っているなら、
新形式でも、
同じIDを使える方が移行しやすくなります。
Schema 2
town_001
↓
Schema 3
town_001
です。
逆に、
ゲーム更新でIDまで変更していると、
Migration時に、
town_001
↓
town_start
のような変換表が必要になります。
20. IDの安定性はMigrationコストにも影響する
つまり、
IDを安易に変更すると、
単なるマスターデータ変更では終わりません。
すでに、
セーブデータから参照されているIDなら、
旧IDをどう新IDへ変換するか
まで必要になります。
第25回でIDを安定させることが重要だった理由が、
Migrationでも出てきます。
21. スロットごとにMigrationする
今回のWeb MVPには、
3つのセーブスロットがあります。
すると、
例えば、
slot_1
Schema 3
slot_2
Schema 2
slot_3
Schema 3
という状態もあり得ます。
このとき、
すべてを一括して、
セーブ全体が古い
と判断する必要はありません。
22. 古いslotだけを移行する
例えば、
slot_1
↓
そのまま
slot_2
↓
Migration
slot_3
↓
そのまま
とできます。
これなら、
一つの古いセーブがあっても、
正常な他スロットまで巻き込みません。
23. 一つのMigration失敗で全スロットを止めない
例えば、
slot_1
Migration成功
slot_2
Migration失敗
slot_3
正常
という状態になったとします。
この場合、
slot_2に問題があるからといって、
slot_1やslot_3まで使えなくする必要はありません。
つまり、
問題の影響範囲をslot単位に閉じ込める
という考え方です。
24. Migration後のデータも検証する
Migration関数が、
例外なく終了したからといって、
変換成功とは限りません。
例えば、
関数は終了した
↓
でも必須項目が欠けている
可能性があります。
そこで、
Migration
↓
Schema 3として検証
↓
PASS
↓
新しいcurrentとして採用
とします。
25. 「変換できた」と「使える」は分ける
整理すると、
Migration関数が値を返した
ことと、
現在のSaveDataとして安全に使える
ことは別です。
第27~30回まででも、
読めた
≠
採用できる
という考え方を使ってきました。
Migrationでも同じです。
26. Migration成功後に新形式として保存する
新形式として検証できたら、
はじめて、
Schema 3
として保存します。
流れは、
Schema 2 current
↓
backup
↓
Migration
↓
Schema 3 validation
↓
Schema 3 current保存
です。
27. migratedFromSchemaVersionを残す
現在のSaveDataでは、
概ね、
migratedFromSchemaVersion?: 2;
のような情報を持てる設計になっています。
これは、
現在
Schema 3
元
Schema 2
だったことを残せます。
28. 移行元を残すと調査しやすい
例えば、
Migration後に、
特定のセーブだけ問題が発生したとします。
そのとき、
最初からSchema 3だった
のか、
Schema 2からMigrationされた
のかで、
調査対象が変わることがあります。
そのため、
移行元情報は、
トラブルシュートにも役立ちます。
29. 何度もMigrationしない
もう一つ重要なのが、
同じデータを何度もMigrationしない
ことです。
例えば、
一度、
Schema 2
↓
Schema 3
へ変換したのに、
アプリ起動のたび、
また、
Migration
を実行する必要はありません。
30. 現在schemaならMigrationしない
ロード時に、
まず、
saveSchemaVersion
を確認します。
Schema 3
↓
Current
↓
Migration不要
です。
Schema 2
↓
Old
↓
Migration候補
となります。
schema番号そのものが、
二重Migrationを防ぐ判断材料になります。
31. Migrationは順番を持つこともある
将来、
Schema 4ができたとします。
すると、
古いSchema 2から、
いきなりSchema 4へ変換する方法と、
Schema 2
↓
Schema 3
↓
Schema 4
と、
段階的に変換する方法があります。
32. 小さなMigrationをつなぐ方法
例えば、
以下は考え方を説明するため、実装を簡略化しています。
const v3 =
migrateV2ToV3(v2);
const v4 =
migrateV3ToV4(v3);
のように、
1世代ずつMigrationできます。
この方式なら、
v2 → v3で何を変えたか
v3 → v4で何を変えたか
を分けて管理できます。
33. 大きなMigrationを一つ作る方法もある
逆に、
v1
v2
v3
すべてから、
現在のv4へ直接変換する、
専用Migrationを作る方法もあります。
どちらがよいかは、
セーブ構造やサポート期間によって変わります。
今回の連載では、
まず、
一つ前の形式を現在形式へ安全に渡す
ところから考えます。
34. Migrationコードも長く残る可能性がある
一度Migrationが成功したら、
もう古いコードだから削除してよい
とは限りません。
まだSchema 2のセーブを持っている人が、
久しぶりにゲームを開くかもしれません。
そのため、
旧形式をサポートする期間は、
Migrationコードも製品の一部になります。
35. どこまで古い形式を支えるかは別途決める
将来、
Schema 1
Schema 2
Schema 3
Schema 4
Schema 5
と増えていけば、
すべてを永久に支えるのは難しくなるかもしれません。
そこで、
どこまで旧形式をサポートするか
という方針も必要になります。
ただし、
今回のWeb MVPでは、
まず、
既存のv2をv3へ安全に引き継ぐこと
を優先しました。
36. Migrationと破損復旧は同じではない
ここで、
Migrationと、
破損データの復旧を分けます。
Migration
↓
古いが正常なデータを
新しい形式へ変換する
一方、
Recovery
↓
壊れたデータから
正常な状態へ戻す
です。
似ていますが、
目的が違います。
37. oldとinvalidを混ぜない
例えば、
Schema 2
正常
なら、
Migration対象です。
一方、
Schema 2
JSON破損
なら、
まず破損データとして扱う必要があります。
単純に、
古いからMigration
してはいけません。
38. future schemaはMigrationしない
さらに、
Schema 99
のような未来形式は、
現在のアプリが知らない形式です。
これを、
とりあえずSchema 3に変換
することもできません。
変換元の意味を知らないからです。
正式公開版ではfuture schemaをunsupportedとして通常ロード・通常上書きから保護しています。
39. Migration対象を明確に分ける
整理すると、
現在がSchema 3なら、
Schema 3
↓
current
↓
通常ロード
Schema 2
↓
old / supported
↓
Migration
Schema 99
↓
future / unsupported
↓
通常ロードしない
破損
↓
invalid
↓
Recovery側へ
となります。
40. fail-closed寄りに考える
判断できないデータを、
たぶん大丈夫
として進めるより、
安全だと確認できなければ
通常ロードしない
方向にします。
正式公開版でも、セーブ品質設計ではfail-closedを設計原則の一つとしており、future schemaの通常ロード/通常上書きを拒否しています。
41. Migrationの基本フロー
ここまでをまとめると、
Migrationは、
SaveData取得
↓
saveSchemaVersion確認
↓
旧形式か?
↓
旧形式としてvalidation
↓
元データをbackup
↓
Migration
↓
現在schemaとしてvalidation
↓
新しいcurrentを保存
↓
GameStateをロード
となります。
重要なのは、
変換処理だけではない
ということです。
42. backupを含めてMigrationと考える
例えば、
単純なMigrationなら、
v2
↓
v3
です。
しかし、
公開品質として考えるなら、
v2
↓
backup
↓
v3へ変換
↓
検証
↓
保存
までを、
一連の処理として考えます。
43. 正式公開版ではセーブ品質の一部として実装した
現在正式公開しているWeb MVPでは、
セーブ基盤として、
-
GameState schemaVersionとSave envelope saveSchemaVersionの分離 - current / backup / quarantine
- 破損currentからのbackup復旧
- future schema保護
- 旧保存形式からの移行
まで実装しています。
これらはPhase Gの正式完了項目です。
44. セーブは単なるJSONではなくなった
開発初期なら、
セーブは、
JSON.stringify()
↓
保存
という程度でも成立します。
しかし、
アプリを更新し続けるようになると、
version
validation
migration
backup
unsupported
invalid
recovery
といった、
時間をまたいでデータを守る仕組み
が必要になります。
45. 今回のポイント
今回のポイントは3つです。
- 旧セーブを切り捨てず、現在schemaへ変換する処理をMigrationとして分離する
- Migration前には元データをbackupし、変換失敗時にも元の進行を失わせない
- Migration後も現在schemaとして検証し、安全に使えることを確認してから採用する
整理すると、
Schema 2
↓
Validation
↓
Backup
↓
Migration
↓
Schema 3
↓
Validation
↓
Current Save
です。
Migrationの目的は、
新しい形式にすること
ではありません。
アプリの形式が変わっても、それまでの旅をできるだけ失わせないこと
です。
46. 次回
次回は、
未来のセーブデータを無理に読まない――future schemaをfail-closedで保護する【第32回】
です。
今回は、
古い形式
↓
意味を知っている
↓
Migrationできる
ケースを扱いました。
次回は逆に、
現在
Schema 3
保存データ
Schema 99
のような、
現在のアプリが知らない新しい形式
を扱います。
なぜ、
読めるところだけ読んでみる
ではなく、
通常ロードを拒否する
通常上書きも拒否する
のか。
unsupportedとinvalidの違いも含めて、
知らないデータを壊さないためのfail-closed設計
を整理します。
この記事を最後まで読んでいただき、ありがとうございます。
「いいね」を押していただけるとうれしいです。これからの記事づくりの励みになります。
もし気に入っていただけましたら、フォローもよろしくお願いします。
前の記事
第30回 アプリのバージョンとセーブ形式のバージョンを分ける
次の記事
第32回 未来のセーブデータを無理に読まない――future schemaをfail-closedで保護する
連載トップ
第0回 React+TypeScriptで異世界RPGを作る――連載の目的と開発ロードマップ
note関連記事
セーブ形式を変えても、旅を失わないために #19
noteでは、v2→v3へのMigration、移行前backup、future schemaを無理に扱わない判断などを、制作側の視点から書いています。