セーブデータを途中で壊さない:File.Replace による原子的保存と 3 世代バックアップ
書き込み途中のクラッシュで本体が中途半端にならない保証を OS に委ねる。1 世代の .bak では足りなかった理由と、既定インターフェース実装で 7 パッケージを変更せず拡張した話。
何に困っていたか
セーブデータやユーザー設定は、書き込みの途中でアプリが落ちても前の内容を失ってはいけない。素朴に File.WriteAllText すると、途中のクラッシュや電源断で本体が中途半端な内容になり、進行データが丸ごと飛ぶ。
MimiCatHub では Analytics だけが「一時ファイルへ書いて置き換える」処理を持っていた。Save と Options も同じ保証を必要としたので、共通部品 McFileStorage として切り出した。
原子的な書き込み
手順は 3 つ。
- ディレクトリが無ければ作る。
<名前>.tmpへ UTF-8(BOM なし)で書く。- 本体があれば
File.Replace(tmp, 本体, bak, ignoreMetadataErrors: true)、無ければFile.Move。
File.Replace は Windows では OS の ReplaceFile に当たり、本体の置き換えと .bak への退避を 1 回の操作で行う。途中で落ちた場合の結果はこう限られる。
- 手順 2 の途中なら、本体と
.bakは無傷で、不完全な.tmpが残るだけ。次回上書きされる。 - 手順 3 の途中なら、OS 側で原子的に扱われるので本体は旧内容か新内容のどちらか。
本体が途中の内容になることはない。 PlatformNotSupportedException の環境では「.bak 削除 → 本体を .bak へ移動 → .tmp を本体へ移動」で代替する。
意図的に持たせなかったものも多い。非同期 API、暗号化とチェックサム、バイナリ、スレッド安全性はすべて呼び出し側または上位の責務にした。「本体が壊れているか」は JSON の解析でしか分からないので、フォールバックも自動化せず TryRead と TryReadBackup を並べるだけにした。
if (storage.TryRead("slot0.json", out string json) && _TryParse(json, out data))
{
return data;
}
if (storage.TryReadBackup("slot0.json", out string backup) && _TryParse(backup, out data))
{
// 直前の保存内容へ戻したことを警告する
return data;
}
1 世代では足りなかった
運用してみると、.bak が 1 世代しかないことが問題になった。1 世代の .bak は「壊れたことにその場で気付ける」ことを暗黙に仮定している。実際には壊れたセーブに気付くのが遅れ、その間に正常な保存が走って .bak も壊れた内容で上書きされる。
ローカルの既定を 3 世代(.bak・.bak2・.bak3)にした。世代 1 をあえて .bak のまま据え置いたので、旧版のセーブはそのまま世代 1 として読める。移行コードが要らない。
世代の繰り上げ(.bak2 → .bak3)は File.Move の連続で、本体の差し替え(File.Replace)の前に行う。途中でクラッシュしても失われるのは古い世代だけで、本体と直前の .bak は壊れない。
Steam クラウド側は容量の都合で 1 世代のままにした。
公開インターフェースを壊さずに拡張する
IMcFileStorage は 7 パッケージが実装し、テストダブルが 6 件あり、ゲーム側の独自実装も想定している。ここへメンバーを素朴に追加すると全部がコンパイルエラーになる。C# 8 の既定インターフェース実装(Unity 6 の .NET Standard 2.1 で使える)で追加した。
public interface IMcFileStorage
{
bool TryReadBackup(string pFileName, out string pText);
// 追加(既定実装付き)
bool TryReadBackup(string pFileName, int sGeneration, out string pText)
{
if (sGeneration == 1) { return TryReadBackup(pFileName, out pText); }
pText = null;
return false;
}
IEnumerable<string> EnumerateFileNames() => Array.Empty<string>();
}
既存実装は一切変更せずに済んだ。以後、Core の公開インターフェースへメンバーを足すときは既定実装を付ける、を規約にした。
教訓
- 原子性は OS に委ねる。 自前でロックやフラグファイルを組むより、
File.Replace1 呼び出しの方が短くクラッシュ窓が小さい。 - 部品の責務は「保証」で切る。 持つのは「本体が中途半端にならない」だけ。だから用途の違う相手に同じ部品を配れる。
- 公開インターフェースの拡張には既定実装を。 実装が散っている状況では、「変更ゼロ」と「全箇所修正」を分ける。
- 世代を足すときは既存世代の名前を変えない。 移行コードが要らなくなる。
- バックアップ本数は「検知の遅れ」の関数として決める。 壊れたデータへの気付きは遅れる前提で設計する。
未解決
「2 世代前へ戻す」を明示的に選ぶ API はまだ無く、読み込み時の自動復旧だけである。同じファイルへの同時アクセスの直列化は呼び出し側の責務のままである。
Related Articles