GFramework/docs/zh-CN/game/storage.md
gewuyou 4b5a760643 docs(game): 收口 Game 持久化文档波次
- 重写 Game 数据、存储、序列化与设置专题页,统一为当前运行时采用路径说明

- 补充 DataRepository、SaveRepository、FileStorage、JsonSerializer 与 SettingsModel 的职责边界和最小接入路径

- 更新 documentation-full-coverage-governance 的 RP-016 恢复点与验证结果
2026-04-23 11:54:58 +08:00

5.8 KiB
Raw Blame History

title, description
title description
Game 存储系统 以当前 GFramework.Game 源码与持久化测试为准,说明 FileStorage 与 ScopedStorage 的职责、路径语义和复用方式。

Game 存储系统

GFramework.Game 在存储这一层只提供宿主无关的 IStorage 默认实现和作用域包装器。

当前真正对外需要理解的入口只有两个:

  • FileStorage
    • 负责 key -> 文件路径 -> 序列化内容 的落盘读写
  • ScopedStorage
    • 负责给同一份底层存储加前缀作用域,避免不同子系统直接拼字符串抢同一片键空间

它们不负责:

  • 设置 section 的聚合语义
  • 存档槽位目录约定
  • 业务数据迁移

这些都属于上层 repository。

当前公开入口

FileStorage

FileStorageIStorage 的默认文件系统实现。按当前源码,它的职责比较集中:

  • 把业务 key 映射到根目录下的层级文件路径
  • 通过构造函数注入的 ISerializer 负责对象序列化和反序列化
  • 对同一目标路径使用 IAsyncKeyLockManager 做细粒度串行化
  • 写入时先落 .tmp 临时文件,再原子替换目标文件
  • 自动创建父目录
  • 拒绝包含 .. 的非法 key并清理路径段中的非法文件名字符

默认文件扩展名是 .dat,也可以在构造时改成 .json 或其他后缀:

using GFramework.Core.Abstractions.Storage;
using GFramework.Game.Serializer;
using GFramework.Game.Storage;

var serializer = new JsonSerializer();
IStorage storage = new FileStorage("GameData", serializer, ".json");

ScopedStorage

ScopedStorage 不额外实现一套落盘逻辑,只是给底层 IStorage 包一层前缀。

它适合做的是:

  • settings/profiles/runtime-cache/ 这类键空间隔离开
  • 让多个 repository 或 utility 共用同一份根存储
  • 避免项目层到处手写 "settings/xxx""save/slot_1/xxx" 之类的字符串拼接

当前实现还支持继续嵌套:

var rootStorage = new FileStorage("GameData", new JsonSerializer(), ".json");
var settingsStorage = new ScopedStorage(rootStorage, "settings");
var audioStorage = settingsStorage.Scope("audio");

await audioStorage.WriteAsync("master", 0.8f);

最终实际写入的 key 会是 settings/audio/master

路径语义

key 到文件路径的映射

FileStorage 会把 key 中的 / 当成目录分隔符,把最后一段作为文件名,并自动附加扩展名。

例如:

key: profile/player
root: GameData
extension: .json

会落到:

GameData/profile/player.json

这意味着 key 的语义应该保持“逻辑路径”,而不是“完整文件名”。不要在业务层再自己补一遍 .json,否则会得到双重后缀。

安全边界

当前实现会:

  1. \ 统一成 /
  2. 拒绝包含 .. 的 key
  3. 清理每个路径段中的非法文件名字符

这套规则能挡住明显的路径逃逸和非法文件名问题,但它不代替业务层做目录规划。哪些 key 属于设置、存档还是缓存,仍应由上层模块统一约定。

同步与异步 API

ReadWriteExistsDelete 这些同步方法只是对异步 API 的阻塞包装。

在 UI 线程或带同步上下文的宿主中,优先使用:

  • ReadAsync<T>()
  • WriteAsync<T>()
  • ExistsAsync()
  • DeleteAsync()

只有在无法继续异步传播时,再退回同步封装。

最小接入路径

如果你只想先拿到一个可复用的本地持久化底座,最短路径如下:

using GFramework.Core.Abstractions.Serializer;
using GFramework.Core.Abstractions.Storage;
using GFramework.Game.Serializer;
using GFramework.Game.Storage;

var serializer = new JsonSerializer();
IStorage storage = new FileStorage("GameData", serializer, ".json");

await storage.WriteAsync("profiles/player", new Dictionary<string, int>
{
    ["level"] = 12
});

var loaded = await storage.ReadAsync<Dictionary<string, int>>("profiles/player");

如果项目里同时有设置、存档和运行时缓存,推荐先在组合根把作用域拆开:

var serializer = new JsonSerializer();
var rootStorage = new FileStorage("GameData", serializer, ".json");

var settingsStorage = new ScopedStorage(rootStorage, "settings");
var saveStorage = new ScopedStorage(rootStorage, "saves");
var cacheStorage = new ScopedStorage(rootStorage, "runtime-cache");

不过在默认仓库接法里,项目通常不需要直接创建 saveStorage 这种 scoped instance因为 SaveRepository<TSaveData> 会再根据 SaveConfiguration 自己组织槽位目录。

与上层 repository 的关系

FileStorage / ScopedStorage 是持久化最底层,不是最终采用入口。当前更常见的实际分工是:

  • DataRepository
    • 每个 IDataLocation 对应一份独立持久化对象
  • UnifiedSettingsDataRepository
    • 把多个设置 section 聚合到同一个统一文件里保存
  • SaveRepository<TSaveData>
    • 负责存档槽位、文件名和迁移链

也就是说:

  • 业务层如果想保存一份独立数据,优先看 data.md
  • 业务层如果想保存设置,优先看 setting.md
  • 业务层如果只是需要底层存储实现,才直接依赖 IStorage

当前边界

  • FileStorage 已经会通过注入的 ISerializer 自动序列化对象;默认接法不需要先手工 Serialize(...) 再把字符串写回
  • FileStorage 负责目录列举与目录创建,但不负责“列出所有存档槽位”的业务语义
  • ScopedStorage 只做 key 前缀,不做权限、事务或迁移控制
  • 锁粒度是“当前实例内的目标路径”,不是跨进程文件锁
  • 原子写入只覆盖单文件替换,不等于多文件事务

继续阅读

  1. 数据与存档系统
  2. 设置系统
  3. 序列化系统
  4. Game 入口