diff --git a/ai-plan/public/documentation-full-coverage-governance/todos/documentation-full-coverage-governance-tracking.md b/ai-plan/public/documentation-full-coverage-governance/todos/documentation-full-coverage-governance-tracking.md index 7980bf5b..23926915 100644 --- a/ai-plan/public/documentation-full-coverage-governance/todos/documentation-full-coverage-governance-tracking.md +++ b/ai-plan/public/documentation-full-coverage-governance/todos/documentation-full-coverage-governance-tracking.md @@ -12,17 +12,27 @@ ## 当前恢复点 -- 恢复点编号:`DOCUMENTATION-FULL-COVERAGE-GOV-RP-015` +- 恢复点编号:`DOCUMENTATION-FULL-COVERAGE-GOV-RP-016` - 当前阶段:`Phase 5 - Governance Maintenance` - 当前焦点: - - 继续巡检 `Godot` / `Game` 相关 README、landing page、tutorial 与 API reference 的 cross-link 是否回漂 - - 保持 `Godot` family 的模块 README、生成器 README 与站内专题页使用同一套 owner / adoption path 叙述 - - 重点观察 `storage.md` / `setting.md` 这类子页是否继续沿用当前 applicator / adoption path 口径 - - 仅在发现新的入口漂移时补最小修复,不重复改写已经稳定的 landing page + - 保持 `Game` family 的 persistence docs surface 与当前 `README`、源码、`PersistenceTests` 使用同一套 owner / adoption path 叙述 + - 将 `data.md`、`storage.md`、`serialization.md`、`setting.md` 视为 `Game` family 当前需要一起巡检的核心页面集,而不是分散的旧 API 手册页 + - 重点观察 `DataRepository` / `UnifiedSettingsDataRepository` / `SaveRepository`、`FileStorage` / `ScopedStorage` 与 `SettingsModel` 的职责边界是否再次回漂 + - 在 `Game` runtime public API 或 README 再次变动前,优先做 targeted 巡检,不重复改写已稳定的 landing page ## 当前状态摘要 - 已归档的 `documentation-governance-and-refresh` 仅保留为历史证据,不再作为默认 `boot` 入口 +- `2026-04-23` 的 `Game` persistence docs wave 新增结论: + - `docs/zh-CN/game/storage.md` 之前仍停留在旧版“通用存储 API 手册”写法,没有反映 `FileStorage` / `ScopedStorage` 与上层 repository 的分工,也没有强调当前同步 API 只是异步阻塞包装 + - `docs/zh-CN/game/data.md` 之前缺少 `DataRepository`、`UnifiedSettingsDataRepository` 与 `SaveRepository` 三层分工,以及 `PersistenceTests` 已覆盖的备份 / 批量事件 / 存档迁移语义 + - `docs/zh-CN/game/serialization.md` 之前仍沿用“业务层手工 Serialize 再写回 storage”的旧示例,没有反映当前 `FileStorage` 已直接复用注入的 `ISerializer` + - `docs/zh-CN/game/setting.md` 虽然已回到 `ISettingsModel` / `RegisterApplicator(...)` 口径,但缺少 frontmatter,且还没有和新的 `Game` persistence docs surface 使用同一套结构 +- `2026-04-23` 的 `Game` persistence docs wave 治理动作: + - 重写 `docs/zh-CN/game/storage.md`,将其改为 `FileStorage` / `ScopedStorage` 的职责、路径语义、作用域复用与 repository 边界页 + - 重写 `docs/zh-CN/game/data.md`,补齐 `DataRepository`、`UnifiedSettingsDataRepository`、`SaveRepository` 与 `DataRepositoryOptions` / `SaveConfiguration` 的当前契约 + - 重写 `docs/zh-CN/game/serialization.md`,收敛到 `JsonSerializer` 的配置生命周期、运行时类型序列化与和 storage / repository 的分工 + - 重写 `docs/zh-CN/game/setting.md`,使其与 `SettingsModel`、`SettingsSystem`、迁移缓存和统一设置仓库的当前实现保持一致 - 本轮已消化的 PR #271 review follow-up: - 为 `.agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py` 补齐 WSL worktree 下的显式 Linux Git 绑定,避免 `git.exe` 在当前会话触发 `Exec format error` - 同步更新 `.agents/skills/gframework-pr-review/SKILL.md`,改为与 `AGENTS.md` 一致的 Git 策略,并把命令示例统一到 `.agents/...` 路径 @@ -96,7 +106,7 @@ | --- | --- | --- | --- | | `Core` / `Core.Abstractions` | `README / landing / 类型族级 XML inventory 已收口,成员级审计待补齐` | 根 README、模块 README、`docs/zh-CN/core/**`、`docs/zh-CN/abstractions/core-abstractions.md` 已对齐当前目录与类型族基线 | 进入巡检;如有新 API 变更,再追加成员级 XML 审计 | | `Cqrs` / `Cqrs.Abstractions` / `Cqrs.SourceGenerators` | `README / landing / generator topic / 类型族级 XML inventory 已收口,成员级审计待补齐` | `GFramework.Cqrs/README.md`、`GFramework.Cqrs.Abstractions/README.md`、`GFramework.Cqrs.SourceGenerators/README.md`、`docs/zh-CN/core/cqrs.md`、`docs/zh-CN/source-generators/cqrs-handler-registry-generator.md`、`docs/zh-CN/api-reference/index.md` 已对齐当前源码与测试 | 转入巡检;下一波切到 `Game` family 的 XML / 教程链路审计 | -| `Game` / `Game.Abstractions` / `Game.SourceGenerators` | `README / landing / abstractions / 类型族级 XML inventory 已收口,成员级审计待补齐` | `GFramework.Game/README.md`、`GFramework.Game.Abstractions/README.md`、`GFramework.Game.SourceGenerators/README.md`、`docs/zh-CN/game/index.md`、`docs/zh-CN/abstractions/game-abstractions.md` 已对齐当前源码与目录基线 | 转入巡检;优先抽查 `config-system`、`scene`、`ui` 与 `source-generators` 交叉链路是否回漂 | +| `Game` / `Game.Abstractions` / `Game.SourceGenerators` | `README / landing / abstractions / persistence topic pages / 类型族级 XML inventory 已收口,成员级审计待补齐` | `GFramework.Game/README.md`、`GFramework.Game.Abstractions/README.md`、`GFramework.Game.SourceGenerators/README.md`、`docs/zh-CN/game/index.md`、`docs/zh-CN/abstractions/game-abstractions.md`、`docs/zh-CN/game/data.md`、`storage.md`、`serialization.md`、`setting.md` 已对齐当前源码、README 与 `PersistenceTests` | 转入巡检;优先观察后续分支是否再次把 `Game` persistence docs 写回旧 API 手册口径 | | `Godot` / `Godot.SourceGenerators` | `README / 生成器 README / landing / topic / tutorial / API reference 入口已重新对齐,成员级 XML 审计不在本轮范围` | `GFramework.Godot/README.md`、`GFramework.Godot.SourceGenerators/README.md`、`docs/zh-CN/godot/index.md`、`architecture.md`、`scene.md`、`ui.md`、`storage.md`、`setting.md`、`signal.md`、`extensions.md`、`logging.md`、`docs/zh-CN/tutorials/godot-integration.md` | 进入巡检周期,优先观察后续分支是否再次把 README / API 入口写回过时边界 | | `Ecs.Arch` / `Ecs.Arch.Abstractions` | `README / landing / abstractions / 类型族级 XML inventory 已收口,成员级审计待补齐` | `GFramework.Ecs.Arch/README.md`、`GFramework.Ecs.Arch.Abstractions/README.md`、`docs/zh-CN/ecs/**`、`docs/zh-CN/abstractions/ecs-arch-abstractions.md` 已对齐当前源码与测试 | 转入巡检;后续仅在运行时公共 API 变动时补成员级 XML 细审 | | `SourceGenerators.Common` 与 `*.SourceGenerators.Abstractions` | `已判定为内部支撑` | `*.csproj` 明确 `IsPackable=false` | 由所属模块 README 与生成器栏目说明 owner,不建独立采用页 | @@ -137,7 +147,10 @@ - 结果:通过;PR `#271` 已关闭,latest reviewed commit 为 `df91d3706ba9db71737e803ef2f40f4841ecbbf1`,当前 `2` 条 open thread 都是已被本地文件满足的陈旧信号,不再构成本轮阻塞 - 最新构建结论: - `2026-04-23` `cd docs && bun run build` - - 结果:通过;在修正 `docs/zh-CN/godot/storage.md` 与 `setting.md` 后再次验证通过,仅保留既有 VitePress 大 chunk warning,无构建失败 + - 结果:通过;在重写 `docs/zh-CN/game/data.md`、`storage.md`、`serialization.md`、`setting.md` 后再次验证通过,仅保留既有 VitePress 大 chunk warning,无构建失败 +- 最新 `Game` persistence docs wave 结论: + - `2026-04-23` 基于 `GFramework.Game` 源码、`GFramework.Game/README.md`、`JsonSerializerTests`、`SettingsModelTests` 与 `PersistenceTests` + - 结果:通过;`docs/zh-CN/game/data.md`、`storage.md`、`serialization.md`、`setting.md` 当前已回到同一套 `Game` runtime 持久化采用路径,不再沿用旧版 API 手册叙述 - 最新稳定性巡检结论: - `2026-04-23` 重新执行 `Godot` docs surface 巡检 - 结果:通过;根入口链路保持稳定,并额外发现 `docs/zh-CN/godot/storage.md`、`setting.md` 两页存在旧版叙述残留,当前已按源码口径完成最小修复 @@ -168,10 +181,15 @@ - `2026-04-23` `python3 .agents/skills/gframework-doc-refresh/scripts/scan_module_evidence.py Godot`:通过(boot 后复核) - `2026-04-23` `bash .agents/skills/gframework-doc-refresh/scripts/validate-all.sh docs/zh-CN/godot/storage.md`:通过(boot 后复核) - `2026-04-23` `bash .agents/skills/gframework-doc-refresh/scripts/validate-all.sh docs/zh-CN/godot/setting.md`:通过(boot 后复核) - - `2026-04-23` `cd docs && bun run build`:通过(boot 后复核;仅保留既有 VitePress 大 chunk warning) + - `2026-04-23` `python3 .agents/skills/gframework-doc-refresh/scripts/scan_module_evidence.py Game`:通过 + - `2026-04-23` `bash .agents/skills/gframework-doc-refresh/scripts/validate-all.sh docs/zh-CN/game/data.md`:通过 + - `2026-04-23` `bash .agents/skills/gframework-doc-refresh/scripts/validate-all.sh docs/zh-CN/game/storage.md`:通过 + - `2026-04-23` `bash .agents/skills/gframework-doc-refresh/scripts/validate-all.sh docs/zh-CN/game/serialization.md`:通过 + - `2026-04-23` `bash .agents/skills/gframework-doc-refresh/scripts/validate-all.sh docs/zh-CN/game/setting.md`:通过 + - `2026-04-23` `cd docs && bun run build`:通过(本轮 `Game` persistence docs wave 复核;仅保留既有 VitePress 大 chunk warning) ## 下一步 -1. 若后续分支继续调整 `GFramework.Godot` 运行时入口,优先复核 `docs/zh-CN/godot/storage.md`、`setting.md` 与根 `README.md` / landing page 是否仍保持同一套职责边界 -2. 当后续分支再修改 `Godot` / `Game` family 的 README、docs 或公共 API 时,回到对应模块追加 targeted 巡检与验证 +1. 若后续分支继续调整 `GFramework.Game` 的 persistence runtime、README 或公共 API,优先复核 `docs/zh-CN/game/data.md`、`storage.md`、`serialization.md`、`setting.md` 与 landing page 是否仍保持同一套职责边界 +2. 当 `Godot` / `Game` family 再次出现交叉入口漂移时,沿用当前 README -> landing -> topic page -> API reference 的最小修复顺序 3. 仅在需要阶段级细节时再读取 `documentation-governance-and-refresh` archive,而不是把 archive 重新当作默认 `boot` 入口 diff --git a/ai-plan/public/documentation-full-coverage-governance/traces/documentation-full-coverage-governance-trace.md b/ai-plan/public/documentation-full-coverage-governance/traces/documentation-full-coverage-governance-trace.md index 656660f7..964b7ede 100644 --- a/ai-plan/public/documentation-full-coverage-governance/traces/documentation-full-coverage-governance-trace.md +++ b/ai-plan/public/documentation-full-coverage-governance/traces/documentation-full-coverage-governance-trace.md @@ -481,6 +481,70 @@ `README.md` / landing page 是否仍保持同一套职责边界 2. 当后续分支再修改 `Godot` / `Game` family 的 README、docs 或公共 API 时,回到对应模块追加 targeted 巡检与验证 +### 当前恢复点:RP-016 + +- 用户明确要求“继续下一步的文档治理,并形成足够体量的 PR”后,当前 topic 不再停留在 validation-only 巡检, + 而是切到一个可独立成波次的 `Game` persistence docs surface: + - `docs/zh-CN/game/data.md` + - `docs/zh-CN/game/storage.md` + - `docs/zh-CN/game/serialization.md` + - `docs/zh-CN/game/setting.md` +- 先执行 `python3 .agents/skills/gframework-doc-refresh/scripts/scan_module_evidence.py Game`,确认 `Game` 的默认 docs surface + 包含 `data`、`storage`、`serialization`、`setting`、`scene`、`ui`、`config-system` 与 landing / API fallback +- 结合 `GFramework.Game/README.md`、`FileStorage.cs`、`ScopedStorage.cs`、`DataRepository.cs`、 + `UnifiedSettingsDataRepository.cs`、`SaveRepository.cs`、`JsonSerializer.cs`、`SettingsModel.cs`、 + `SettingsSystem.cs`、`JsonSerializerTests.cs`、`SettingsModelTests.cs` 与 `PersistenceTests.cs`,确认四个旧页面存在持续性漂移: + - `storage.md` 仍按旧版通用 API 手册组织,没有强调 `FileStorage` / `ScopedStorage` 与 repository 的职责边界 + - `data.md` 缺少 `DataRepository`、`UnifiedSettingsDataRepository` 与 `SaveRepository` 三层分工,以及当前备份 / + 批量事件 / 存档迁移语义 + - `serialization.md` 仍沿用“业务层手工 Serialize 再写回 storage”的旧接法,没有反映当前 `FileStorage` + 已直接复用注入的 `ISerializer` + - `setting.md` 虽已回到 `ISettingsModel` / `RegisterApplicator(...)` 口径,但结构仍未与当前 `Game` family 的 runtime topic + 页面统一,也缺少 frontmatter +- 因此本轮执行的最小但成组修复集是: + - 重写 `docs/zh-CN/game/storage.md` + - 重写 `docs/zh-CN/game/data.md` + - 重写 `docs/zh-CN/game/serialization.md` + - 重写 `docs/zh-CN/game/setting.md` + - 更新 active tracking 的恢复点、治理结论与下一步 + +### 当前决策(RP-016) + +- 这轮不去扩张到 `Game` tutorial 或 root README,而是把同一子领域里仍残留的旧页一次性收口,形成清晰的 PR 边界 +- `Game` persistence docs surface 统一采用“当前公开入口 -> 最小接入路径 -> 当前边界 -> 继续阅读”的结构, + 不再维护分散的伪 API 手册页 +- 文档示例只保留可直接映射到当前框架类型、测试行为或已验证 consumer wiring 的内容,避免继续写虚构接线名 + +### 当前验证(RP-016) + +- 模块扫描: + - `python3 .agents/skills/gframework-doc-refresh/scripts/scan_module_evidence.py Game`:通过 +- 文档校验: + - `bash .agents/skills/gframework-doc-refresh/scripts/validate-all.sh docs/zh-CN/game/data.md`:通过 + - `bash .agents/skills/gframework-doc-refresh/scripts/validate-all.sh docs/zh-CN/game/storage.md`:通过 + - `bash .agents/skills/gframework-doc-refresh/scripts/validate-all.sh docs/zh-CN/game/serialization.md`:通过 + - `bash .agents/skills/gframework-doc-refresh/scripts/validate-all.sh docs/zh-CN/game/setting.md`:通过 +- 构建校验: + - `cd docs && bun run build`:通过;仅保留既有 VitePress 大 chunk warning,无构建失败 +- 代码 / 测试证据: + - `GFramework.Game/README.md` + - `GFramework.Game/Storage/FileStorage.cs` + - `GFramework.Game/Storage/ScopedStorage.cs` + - `GFramework.Game/Data/DataRepository.cs` + - `GFramework.Game/Data/UnifiedSettingsDataRepository.cs` + - `GFramework.Game/Data/SaveRepository.cs` + - `GFramework.Game/Serializer/JsonSerializer.cs` + - `GFramework.Game/Setting/SettingsModel.cs` + - `GFramework.Game/Setting/SettingsSystem.cs` + - `GFramework.Game.Tests/Data/PersistenceTests.cs` + - `GFramework.Game.Tests/Serializer/JsonSerializerTests.cs` + - `GFramework.Game.Tests/Setting/SettingsModelTests.cs` + +### 下一步 + +1. 回填 tracking 的最新验证结果,并按仓库规则提交本轮 `Game` persistence docs wave +2. 若后续分支继续调整 `GFramework.Game` 的 persistence runtime 或 README,优先复核这四个 topic page 与 landing page 的一致性 + ### 当前恢复点:RP-015 - 通过 `$gframework-boot` 恢复当前 worktree 后,继续按 `documentation-full-coverage-governance` 的默认下一步执行一次 diff --git a/docs/zh-CN/game/data.md b/docs/zh-CN/game/data.md index b2e3fb71..3f17d98e 100644 --- a/docs/zh-CN/game/data.md +++ b/docs/zh-CN/game/data.md @@ -1,709 +1,189 @@ --- title: 数据与存档系统 -description: 数据与存档系统提供统一的数据持久化基础能力,支持多槽位存档、版本化数据和仓库抽象。 +description: 以当前 GFramework.Game 源码与 PersistenceTests 为准,说明 DataRepository、UnifiedSettingsDataRepository 和 SaveRepository 的职责边界。 --- # 数据与存档系统 -## 概述 +`GFramework.Game` 的数据持久化不是“只有一个万能仓库”。 -数据与存档系统是 GFramework.Game 中用于管理游戏数据持久化的核心组件。它提供了统一的数据加载和保存接口,支持多槽位存档管理、版本化数据模式,以及与存储系统、序列化系统的组合使用。 +当前更准确的理解是三层分工: -通过数据系统,你可以将游戏数据保存到本地存储,支持多个存档槽位,并在需要时于应用层实现版本迁移。 +- `DataRepository` + - 面向“一个 location 对应一份持久化对象”的通用数据仓库 +- `UnifiedSettingsDataRepository` + - 面向“多个设置 section 聚合到同一个文件”的设置仓库 +- `SaveRepository` + - 面向“按槽位组织的版本化存档” -**主要特性**: +如果先把这三类入口分开理解,后续采用路径会清晰很多。 -- 统一的数据持久化接口 -- 多槽位存档管理 -- 数据版本控制模式 -- 异步加载和保存 -- 批量数据操作 -- 与存储系统集成 +## 什么时候用哪个仓库 -## 核心概念 +### `DataRepository` -### 数据接口 +适合: -`IData` 标记数据类型: +- 单份玩家档案 +- 单份运行时缓存 +- 一条 location 对应一个文件的普通业务数据 + +默认语义是: + +- `IDataLocation` 决定 key +- 一条 location 对应一份对象 +- 覆盖保存时可按 `DataRepositoryOptions.AutoBackup` 创建 `.backup` +- `SaveAllAsync(...)` 视为一次批量提交,只发送批量事件,不重复发送单项保存事件 + +### `UnifiedSettingsDataRepository` + +适合: + +- 音频、图形、语言等多个设置 section 统一落到一份文件 +- 启动时一次性加载所有设置,再交给 `SettingsModel` 编排 + +默认语义是: + +- 底层持久化文件只有一份,默认文件名是 `settings.json` +- 各个设置 section 仍然通过 `IDataLocation` 的 key 区分 +- 保存、删除时会整文件回写,而不是只改单个 section 文件 +- 开启 `AutoBackup` 时,备份粒度也是整个统一文件,不是单个 section + +### `SaveRepository` + +适合: + +- 多槽位存档 +- 需要版本迁移的 save data +- 需要列举现有槽位和删除槽位 + +默认语义是: + +- 按 `SaveRoot` / `SaveSlotPrefix` / `SaveFileName` 组织目录 +- 槽位不存在时,`LoadAsync(slot)` 返回新的 `TSaveData` 实例,而不是 `null` +- `ListSlotsAsync()` 只返回真实存在存档文件的槽位,并按升序排列 +- 迁移成功后会把升级后的结果自动回写到槽位文件 + +## 当前公开入口 + +### `DataRepository` + +`DataRepository` 是最通用的默认实现。当前仓库和测试确认的行为有几条需要特别记住: + +- `LoadAsync(location)` 在文件不存在时返回 `new T()`,不是抛异常 +- `DeleteAsync(location)` 只有在目标数据真实存在并被删除时才发送删除事件 +- `SaveAllAsync(...)` 会抑制逐项 `DataSavedEvent`,只保留一次 `DataBatchSavedEvent` +- `AutoBackup = true` 时,覆盖旧值前会先把旧值写到 `.backup` + +最小接法通常是:项目先准备一个 `IDataLocation` 或 `IDataLocationProvider`,再把它交给 `DataRepository` 做 +`location -> key` 的映射;repository 自己不负责推导业务对象应该落在哪个位置。 + +### `UnifiedSettingsDataRepository` + +当前 `SettingsModel` 依赖的默认设置仓库就是它。 + +它和普通 `DataRepository` 的关键区别不是接口,而是落盘形态: + +- `DataRepository` + - 每个 location 对应一个独立文件 +- `UnifiedSettingsDataRepository` + - 所有 section 聚合到同一个统一文件 + +还有两个容易遗漏的点: + +- `LoadAllAsync()` 依赖 `RegisterDataType(location, type)` 建立 section -> 运行时类型映射 +- 仓库内部会先把统一文件加载进缓存,再在保存 / 删除时基于快照整文件提交 + +这就是为什么 `SettingsModel` 会在拿到 `GetData()` 或 `RegisterApplicator(...)` 后主动把类型注册回 repository。 + +### `SaveRepository` + +`SaveRepository` 用于槽位存档,不直接复用 `IDataLocation`。 + +最重要的公开配置是 `SaveConfiguration`: ```csharp -public interface IData +var config = new SaveConfiguration { - // 标记接口,用于标识可持久化的数据 -} + SaveRoot = "saves", + SaveSlotPrefix = "slot_", + SaveFileName = "save.json" +}; ``` -### 数据仓库 +按这个配置,槽位 `1` 的默认文件结构就是: -`IDataRepository` 提供通用的数据操作: - -```csharp -public interface IDataRepository : IUtility -{ - Task LoadAsync(IDataLocation location) where T : class, IData, new(); - Task SaveAsync(IDataLocation location, T data) where T : class, IData; - Task ExistsAsync(IDataLocation location); - Task DeleteAsync(IDataLocation location); - Task SaveAllAsync(IEnumerable<(IDataLocation, IData)> dataList); -} +```text +saves/ + slot_1/ + save.json ``` -`IDataRepository` 描述的是“仓库语义”,不是固定的落盘格式。实现可以选择每个数据项独立成文件,也可以把多个 section 聚合到同一个文件里。 +当前实现内部会先把根存储包装成 `ScopedStorage(storage, config.SaveRoot)`,再按槽位继续加前缀,因此项目层一般不需要手工再拼一次 `"saves/slot_1"`。 -当前内建实现里: +## 存档迁移的真实语义 -- `DataRepository` 采用“每个 location 一份持久化对象”的模型 -- `UnifiedSettingsDataRepository` 采用“所有设置聚合到一个统一文件”的模型 +`SaveRepository` 只有在 `TSaveData` 实现了 `IVersionedData` 时,才支持 `RegisterMigration(...)`。 -两者对外遵守同一套约定: +当前源码和 `PersistenceTests` 明确约束了下面这些行为: -- `SaveAllAsync(...)` 视为一次批量提交,只发送 `DataBatchSavedEvent`,不会再为每个条目重复发送 `DataSavedEvent` -- `DeleteAsync(...)` 只有在目标数据真实存在并被删除时才会发送删除事件 -- 当 `DataRepositoryOptions.AutoBackup = true` 时,覆盖已有数据前会先保留上一份快照 -- 对 `UnifiedSettingsDataRepository` 来说,备份粒度是整个统一文件,而不是单个设置 section +- 非版本化 save type 注册迁移器会直接失败 +- 同一个 `FromVersion` 不能重复注册迁移器 +- 迁移链缺口会显式抛错,不会静默返回半升级结果 +- 迁移器声明的 `ToVersion` 必须与实际返回对象的版本一致 +- 如果读到比当前运行时代码更高版本的存档,也会明确失败 +- 单次加载会先固定一份迁移表快照,避免并发注册让同一次加载看到变化中的链路 -### 存档仓库 +也就是说,`SaveRepository` 的迁移语义更偏“严格升级管线”,而不是“尽量帮你读出来”。 -`ISaveRepository` 专门用于管理游戏存档: - -```csharp -public interface ISaveRepository : IUtility - where TSaveData : class, IData, new() -{ - ISaveRepository RegisterMigration(ISaveMigration migration); - Task ExistsAsync(int slot); - Task LoadAsync(int slot); - Task SaveAsync(int slot, TSaveData data); - Task DeleteAsync(int slot); - Task> ListSlotsAsync(); -} -``` - -`ISaveMigration` 定义单步迁移: - -```csharp -public interface ISaveMigration - where TSaveData : class, IData -{ - int FromVersion { get; } - int ToVersion { get; } - TSaveData Migrate(TSaveData oldData); -} -``` - -### 版本化数据 - -`IVersionedData` 支持数据版本管理: - -```csharp -public interface IVersionedData : IData -{ - int Version { get; } - DateTime LastModified { get; } -} -``` - -## 基本用法 - -### 定义数据类型 +## 最小接入路径 + +下面是当前 `Game` 层最常见的一套组合方式: ```csharp +using GFramework.Core.Abstractions.Serializer; +using GFramework.Core.Abstractions.Storage; using GFramework.Game.Abstractions.Data; - -// 简单数据 -public class PlayerData : IData -{ - public string Name { get; set; } - public int Level { get; set; } - public int Experience { get; set; } -} - -// 版本化数据 -public class SaveData : IVersionedData -{ - public int Version { get; set; } = 1; - public PlayerData Player { get; set; } - public DateTime SaveTime { get; set; } -} -``` - -### 使用存档仓库 - -```csharp -using GFramework.Core.Abstractions.Controller; -using GFramework.Core.SourceGenerators.Abstractions.Rule; - -[ContextAware] -public partial class SaveController : IController -{ - public async Task SaveGame(int slot) - { - var saveRepo = this.GetUtility>(); - - // 创建存档数据 - var saveData = new SaveData - { - Player = new PlayerData - { - Name = "Player1", - Level = 10, - Experience = 1000 - }, - SaveTime = DateTime.Now - }; - - // 保存到指定槽位 - await saveRepo.SaveAsync(slot, saveData); - Console.WriteLine($"游戏已保存到槽位 {slot}"); - } - - public async Task LoadGame(int slot) - { - var saveRepo = this.GetUtility>(); - - // 检查存档是否存在 - if (!await saveRepo.ExistsAsync(slot)) - { - Console.WriteLine($"槽位 {slot} 不存在存档"); - return; - } - - // 加载存档 - var saveData = await saveRepo.LoadAsync(slot); - Console.WriteLine($"加载存档: {saveData.Player.Name}, 等级 {saveData.Player.Level}"); - } - - public async Task DeleteSave(int slot) - { - var saveRepo = this.GetUtility>(); - - // 删除存档 - await saveRepo.DeleteAsync(slot); - Console.WriteLine($"已删除槽位 {slot} 的存档"); - } -} -``` - -### 注册存档仓库 - -```csharp -using GFramework.Game.Data; - -public class GameArchitecture : Architecture -{ - protected override void Init() - { - // 获取存储系统 - var storage = this.GetUtility(); - - // 创建存档配置 - var saveConfig = new SaveConfiguration - { - SaveRoot = "saves", - SaveSlotPrefix = "slot_", - SaveFileName = "save.json" - }; - - // 注册存档仓库 - var saveRepo = new SaveRepository(storage, saveConfig); - RegisterUtility>(saveRepo); - } -} -``` - -## 高级用法 - -### 列出所有存档 - -```csharp -public async Task ShowSaveList() -{ - var saveRepo = this.GetUtility>(); - - // 获取所有存档槽位 - var slots = await saveRepo.ListSlotsAsync(); - - Console.WriteLine($"找到 {slots.Count} 个存档:"); - foreach (var slot in slots) - { - var saveData = await saveRepo.LoadAsync(slot); - Console.WriteLine($"槽位 {slot}: {saveData.Player.Name}, " + - $"等级 {saveData.Player.Level}, " + - $"保存时间 {saveData.SaveTime}"); - } -} -``` - -### 自动保存 - -```csharp -using GFramework.Core.Abstractions.Controller; -using GFramework.Core.SourceGenerators.Abstractions.Rule; - -[ContextAware] -public partial class AutoSaveController : IController -{ - private CancellationTokenSource? _autoSaveCts; - - public void StartAutoSave(int slot, TimeSpan interval) - { - _autoSaveCts = new CancellationTokenSource(); - - Task.Run(async () => - { - while (!_autoSaveCts.Token.IsCancellationRequested) - { - await Task.Delay(interval, _autoSaveCts.Token); - - try - { - await SaveGame(slot); - Console.WriteLine("自动保存完成"); - } - catch (Exception ex) - { - Console.WriteLine($"自动保存失败: {ex.Message}"); - } - } - }, _autoSaveCts.Token); - } - - public void StopAutoSave() - { - _autoSaveCts?.Cancel(); - _autoSaveCts?.Dispose(); - _autoSaveCts = null; - } - - private async Task SaveGame(int slot) - { - var saveRepo = this.GetUtility>(); - var saveData = CreateSaveData(); - await saveRepo.SaveAsync(slot, saveData); - } - - private SaveData CreateSaveData() - { - // 从游戏状态创建存档数据 - return new SaveData(); - } -} -``` - -### 数据版本迁移 - -`SaveRepository` 现在支持注册正式的迁移器,并在 `LoadAsync(slot)` 时自动升级旧版本存档。 - -迁移规则如下: - -- `TSaveData` 需要实现 `IVersionedData` -- 仓库以 `new TSaveData().Version` 作为当前运行时目标版本 -- 每个迁移器负责一个 `FromVersion -> ToVersion` 跳转 -- 同一个 `FromVersion` 只能注册一个迁移器,且 `ToVersion` 必须严格大于 `FromVersion` -- 加载时仓库会按链路连续执行迁移,并在成功后自动回写升级后的存档 -- 迁移器返回数据上的 `Version` 必须与声明的 `ToVersion` 一致 -- 如果缺少中间迁移器,或者读到了比当前运行时更高的版本,`LoadAsync` 会抛出异常,避免静默加载错误数据 - -```csharp -public sealed class SaveData : IVersionedData -{ - // 当前运行时代码支持的最新版本 - public int Version { get; set; } = 2; - public string PlayerName { get; set; } - public int Level { get; set; } - public int Experience { get; set; } - public DateTime LastModified { get; set; } -} - -public sealed class SaveDataMigrationV1ToV2 : ISaveMigration -{ - public int FromVersion => 1; - - public int ToVersion => 2; - - public SaveData Migrate(SaveData oldData) - { - return new SaveData - { - Version = 2, - PlayerName = oldData.PlayerName, - Level = oldData.Level, - Experience = oldData.Level * 100, - LastModified = DateTime.UtcNow - }; - } -} - -public sealed class SaveModule : AbstractModule -{ - public override void Install(IArchitecture architecture) - { - var storage = architecture.GetUtility(); - var saveConfig = new SaveConfiguration - { - SaveRoot = "saves", - SaveSlotPrefix = "slot_", - SaveFileName = "save" - }; - - var saveRepo = new SaveRepository(storage, saveConfig) - .RegisterMigration(new SaveDataMigrationV1ToV2()); - - architecture.RegisterUtility>(saveRepo); - } -} - -public async Task LoadGame(int slot) -{ - var saveRepo = this.GetUtility>(); - - // 如果槽位里是 v1,仓库会自动迁移到 v2,并把新版本重新写回存储。 - return await saveRepo.LoadAsync(slot); -} -``` - -`ISaveMigration` 接收和返回的是同一个存档类型。也就是说,框架提供的是“当前类型内的版本升级管线”, -而不是跨 CLR 类型的双模型反序列化系统。如果旧版本缺失了新字段,反序列化会先使用当前类型的默认值,再由迁移器补齐。 - -### 使用数据仓库 - -```csharp -using GFramework.Core.Abstractions.Controller; -using GFramework.Core.SourceGenerators.Abstractions.Rule; - -[ContextAware] -public partial class SettingsController : IController -{ - public async Task SaveSettings() - { - var dataRepo = this.GetUtility(); - - var settings = new GameSettings - { - MasterVolume = 0.8f, - MusicVolume = 0.6f, - SfxVolume = 0.7f - }; - - // 定义数据位置 - var location = new DataLocation("settings", "game_settings.json"); - - // 保存设置 - await dataRepo.SaveAsync(location, settings); - } - - public async Task LoadSettings() - { - var dataRepo = this.GetUtility(); - var location = new DataLocation("settings", "game_settings.json"); - - // 检查是否存在 - if (!await dataRepo.ExistsAsync(location)) - { - return new GameSettings(); // 返回默认设置 - } - - // 加载设置 - return await dataRepo.LoadAsync(location); - } -} -``` - -### 批量保存数据 - -```csharp -public async Task SaveAllGameData() -{ - var dataRepo = this.GetUtility(); - - var dataList = new List<(IDataLocation, IData)> - { - (new DataLocation("player", "profile.json"), playerData), - (new DataLocation("inventory", "items.json"), inventoryData), - (new DataLocation("quests", "progress.json"), questData) - }; - - // 批量保存 - await dataRepo.SaveAllAsync(dataList); - Console.WriteLine("所有数据已保存"); -} -``` - -`SaveAllAsync(...)` 的事件语义和逐项调用 `SaveAsync(...)` 不同。它代表一次显式的批量提交,因此适合让监听器在收到 `DataBatchSavedEvent` 时统一刷新 UI、缓存或元数据,而不是对每个条目单独响应。 - -### 聚合设置仓库 - -如果你希望把设置统一保存到单个文件中,可以使用 `UnifiedSettingsDataRepository`: - -```csharp using GFramework.Game.Data; using GFramework.Game.Serializer; +using GFramework.Game.Storage; -public sealed class GameArchitecture : Architecture -{ - protected override void Init() +var serializer = new JsonSerializer(); +var storage = new FileStorage("GameData", serializer, ".json"); + +ISettingsDataRepository settingsRepository = new UnifiedSettingsDataRepository( + storage, + serializer, + new DataRepositoryOptions { - var storage = this.GetUtility(); - var serializer = new JsonSerializer(); + BasePath = "settings", + AutoBackup = true + }); - var settingsRepo = new UnifiedSettingsDataRepository( - storage, - serializer, - new DataRepositoryOptions - { - AutoBackup = true, - EnableEvents = true - }, - "settings.json"); - - settingsRepo.RegisterDataType(new DataLocation("settings", "graphics"), typeof(GraphicsSettings)); - settingsRepo.RegisterDataType(new DataLocation("settings", "audio"), typeof(AudioSettings)); - - RegisterUtility(settingsRepo); - } -} -``` - -这个实现依然满足 `IDataRepository` 的通用契约,但有两个实现层面的差异需要明确: - -- 它把所有设置 section 缓存在内存中,并在保存或删除时整文件回写 -- 开启自动备份时,备份的是整个 `settings.json` 文件,因此适合做“上一次完整设置快照”的恢复,而不是 section 级别回滚 - -如果你需要 `LoadAllAsync()`,或者希望在同一个统一文件里混合反序列化多个 section,必须先为每个 section 注册类型: - -```csharp -public async Task PrintSettingsSnapshot() +var saveConfiguration = new SaveConfiguration { - var repo = this.GetUtility(); - - var all = await repo.LoadAllAsync(); - - var graphics = (GraphicsSettings)all["graphics"]; - var audio = (AudioSettings)all["audio"]; - - Console.WriteLine($"Resolution: {graphics.ResolutionWidth}x{graphics.ResolutionHeight}"); - Console.WriteLine($"MasterVolume: {audio.MasterVolume}"); -} + SaveRoot = "saves", + SaveSlotPrefix = "slot_", + SaveFileName = "save.json" +}; ``` -最小采用要求: +分工应保持清晰: -- 项目需要可用的 `IStorage` -- 项目需要一个可用的序列化器实例,例如 `GFramework.Game.Serializer.JsonSerializer` -- 在注册仓库时,把所有需要参与 `LoadAllAsync()` 或混合 section 反序列化的 location/type 对显式调用一次 `RegisterDataType(...)` +- `storage` 只负责底层文件读写 +- `settingsRepository` 负责统一设置文件 +- `SaveRepository` 负责槽位目录和存档迁移 -兼容性说明: +## 当前边界 -- 现在 `UnifiedSettingsDataRepository.LoadAsync()` 发送的是 `DataLoadedEvent`,而不是 `DataLoadedEvent` -- 如果你之前监听的是 `DataLoadedEvent`,需要改成订阅具体类型,例如 `DataLoadedEvent` 或 `DataLoadedEvent` +- `DataRepositoryOptions` 描述的是仓库公开行为契约,不是某一种固定落盘格式 +- `UnifiedSettingsDataRepository` 不是通用万能仓库,它专门服务“多 section 聚合单文件”的场景 +- `SaveRepository` 不负责业务层的 autosave 策略、云同步或存档选择 UI +- `LoadAsync(...)` 返回新实例的行为适合默认启动路径;如果项目需要“缺档即报错”,应在业务层显式调用 `ExistsAsync(...)` -### 存档备份 +## 继续阅读 -```csharp -public async Task BackupSave(int slot) -{ - var saveRepo = this.GetUtility>(); - - if (!await saveRepo.ExistsAsync(slot)) - { - Console.WriteLine("存档不存在"); - return; - } - - // 加载原存档 - var saveData = await saveRepo.LoadAsync(slot); - - // 保存到备份槽位 - int backupSlot = slot + 100; - await saveRepo.SaveAsync(backupSlot, saveData); - - Console.WriteLine($"存档已备份到槽位 {backupSlot}"); -} - -public async Task RestoreBackup(int slot) -{ - int backupSlot = slot + 100; - var saveRepo = this.GetUtility>(); - - if (!await saveRepo.ExistsAsync(backupSlot)) - { - Console.WriteLine("备份不存在"); - return; - } - - // 加载备份 - var backupData = await saveRepo.LoadAsync(backupSlot); - - // 恢复到原槽位 - await saveRepo.SaveAsync(slot, backupData); - - Console.WriteLine($"已从备份恢复到槽位 {slot}"); -} -``` - -## 最佳实践 - -1. **使用版本化数据**:为存档数据实现 `IVersionedData` - ```csharp - ✓ public class SaveData : IVersionedData { public int Version { get; set; } = 1; } - ✗ public class SaveData : IData { } // 无法进行版本管理 - ``` - -2. **定期自动保存**:避免玩家数据丢失 - ```csharp - // 每 5 分钟自动保存 - StartAutoSave(currentSlot, TimeSpan.FromMinutes(5)); - ``` - -3. **保存前验证数据**:确保数据完整性 - ```csharp - public async Task SaveGame(int slot) - { - var saveData = CreateSaveData(); - - if (!ValidateSaveData(saveData)) - { - throw new InvalidOperationException("存档数据无效"); - } - - await saveRepo.SaveAsync(slot, saveData); - } - ``` - -4. **处理保存失败**:使用 try-catch 捕获异常 - ```csharp - try - { - await saveRepo.SaveAsync(slot, saveData); - } - catch (Exception ex) - { - Logger.Error($"保存失败: {ex.Message}"); - ShowErrorMessage("保存失败,请重试"); - } - ``` - -5. **提供多个存档槽位**:让玩家可以管理多个存档 - ```csharp - // 支持 10 个存档槽位 - for (int i = 1; i <= 10; i++) - { - if (await saveRepo.ExistsAsync(i)) - { - ShowSaveSlot(i); - } - } - ``` - -6. **在关键时刻保存**:场景切换、关卡完成等 - ```csharp - public async Task OnLevelComplete() - { - // 关卡完成时自动保存 - await SaveGame(currentSlot); - } - ``` - -## 常见问题 - -### 问题:如何实现多个存档槽位? - -**解答**: -使用 `ISaveRepository` 的槽位参数: - -```csharp -// 保存到不同槽位 -await saveRepo.SaveAsync(1, saveData); // 槽位 1 -await saveRepo.SaveAsync(2, saveData); // 槽位 2 -await saveRepo.SaveAsync(3, saveData); // 槽位 3 -``` - -### 问题:如何处理数据版本升级? - -**解答**: -实现 `IVersionedData`,并在仓库初始化阶段注册 `ISaveMigration`。之后 `LoadAsync(slot)` 会自动执行迁移并回写: - -```csharp -var saveRepo = new SaveRepository(storage, saveConfig) - .RegisterMigration(new SaveDataMigrationV1ToV2()) - .RegisterMigration(new SaveDataMigrationV2ToV3()); - -var data = await saveRepo.LoadAsync(slot); -``` - -### 问题:存档数据保存在哪里? - -**解答**: -由存储系统决定,通常在: - -- Windows: `%AppData%/GameName/saves/` -- Linux: `~/.local/share/GameName/saves/` -- macOS: `~/Library/Application Support/GameName/saves/` - -### 问题:如何实现云存档? - -**解答**: -实现自定义的 `IStorage`,将数据保存到云端: - -```csharp -public class CloudStorage : IStorage -{ - public async Task WriteAsync(string path, byte[] data) - { - await UploadToCloud(path, data); - } - - public async Task ReadAsync(string path) - { - return await DownloadFromCloud(path); - } -} -``` - -### 问题:如何加密存档数据? - -**解答**: -在保存和加载时进行加密/解密: - -```csharp -public async Task SaveEncrypted(int slot, SaveData data) -{ - var json = JsonSerializer.Serialize(data); - var encrypted = Encrypt(json); - await storage.WriteAsync(path, encrypted); -} - -public async Task LoadEncrypted(int slot) -{ - var encrypted = await storage.ReadAsync(path); - var json = Decrypt(encrypted); - return JsonSerializer.Deserialize(json); -} -``` - -### 问题:存档损坏怎么办? - -**解答**: -实现备份和恢复机制: - -```csharp -public async Task SaveWithBackup(int slot, SaveData data) -{ - // 先备份旧存档 - if (await saveRepo.ExistsAsync(slot)) - { - var oldData = await saveRepo.LoadAsync(slot); - await saveRepo.SaveAsync(slot + 100, oldData); - } - - // 保存新存档 - await saveRepo.SaveAsync(slot, data); -} -``` - -## 相关文档 - -- [设置系统](/zh-CN/game/setting) - 游戏设置管理 -- [场景系统](/zh-CN/game/scene) - 场景切换时保存 -- [存档系统实现教程](/zh-CN/tutorials/save-system) - 完整示例 -- [Godot 集成](/zh-CN/godot/index) - Godot 中的数据管理 +1. [设置系统](./setting.md) +2. [存储系统](./storage.md) +3. [序列化系统](./serialization.md) +4. [Game 入口](./index.md) diff --git a/docs/zh-CN/game/serialization.md b/docs/zh-CN/game/serialization.md index fdc24ba1..d07487c5 100644 --- a/docs/zh-CN/game/serialization.md +++ b/docs/zh-CN/game/serialization.md @@ -1,811 +1,162 @@ --- title: 序列化系统 -description: 序列化系统提供了统一的对象序列化和反序列化接口,支持 JSON 格式和运行时类型处理。 +description: 以当前 GFramework.Game.JsonSerializer 与 JsonSerializerTests 为准,说明 JSON 序列化器的配置生命周期和使用边界。 --- # 序列化系统 -## 概述 +`GFramework.Game` 当前在序列化这一层的默认公开入口只有 `JsonSerializer`。 -序列化系统是 GFramework.Game 中用于对象序列化和反序列化的核心组件。它提供了统一的序列化接口,支持将对象转换为字符串格式(如 -JSON)进行存储或传输,并能够将字符串数据还原为对象。 +它实现的是: -序列化系统与数据存储、配置管理、存档系统等模块深度集成,为游戏数据的持久化提供了基础支持。 +- `ISerializer` +- `IRuntimeTypeSerializer` -**主要特性**: +它不负责: -- 统一的序列化接口 -- JSON 格式支持 -- 运行时类型序列化 -- 泛型和非泛型 API -- 与存储系统无缝集成 -- 类型安全的反序列化 +- schema 驱动配置生成 +- 存档槽位管理 +- 文件路径或目录布局 -## 核心概念 +这些能力分别属于 source generator、repository 和 storage。 -### 序列化器接口 +## 当前公开入口 -`ISerializer` 定义了基本的序列化操作: +### `JsonSerializer` -```csharp -public interface ISerializer : IUtility -{ - // 将对象序列化为字符串 - string Serialize<T>(T value); - - // 将字符串反序列化为对象 - T Deserialize<T>(string data); -} -``` - -### 运行时类型序列化器 - -`IRuntimeTypeSerializer` 扩展了基本接口,支持运行时类型处理: - -```csharp -public interface IRuntimeTypeSerializer : ISerializer -{ - // 使用运行时类型序列化对象 - string Serialize(object obj, Type type); - - // 使用运行时类型反序列化对象 - object Deserialize(string data, Type type); -} -``` - -### JSON 序列化器 - -`JsonSerializer` 是基于 Newtonsoft.Json 的实现。它会直接复用构造时提供的 -`JsonSerializerSettings` 与 `Converters` 集合,因此推荐在组合根统一完成配置,再把同一个实例注册到架构或仓库中复用: - -```csharp -public sealed class JsonSerializer : IRuntimeTypeSerializer -{ - string Serialize<T>(T value); - T Deserialize<T>(string data); - string Serialize(object obj, Type type); - object Deserialize(string data, Type type); -} -``` - -## 基本用法 - -### 注册序列化器 - -在架构中注册序列化器: +`JsonSerializer` 基于 `Newtonsoft.Json`,既支持泛型 API,也支持运行时类型 API: ```csharp using GFramework.Core.Abstractions.Serializer; using GFramework.Game.Serializer; -public class GameArchitecture : Architecture -{ - protected override void Init() - { - // 在启动阶段一次性完成配置,后续将该实例视为只读 - var jsonSerializer = new JsonSerializer(); - jsonSerializer.Converters.Add(new PlayerDataJsonConverter()); - - RegisterUtility(jsonSerializer); - RegisterUtility(jsonSerializer); - } -} +ISerializer serializer = new JsonSerializer(); +IRuntimeTypeSerializer runtimeSerializer = new JsonSerializer(); ``` -### 序列化对象 +当前测试覆盖的核心行为包括: -使用泛型 API 序列化对象: +- 普通对象可正常 round-trip +- 注入的 `JsonSerializerSettings` 会直接生效 +- `Settings` 与 `Converters` 暴露的是同一个活动配置实例 +- 运行时类型序列化 / 反序列化可处理 `object + Type` +- 非法 JSON 会抛出带目标类型上下文的 `InvalidOperationException` +- 非法参数(例如空字符串)会保留 `ArgumentException` +- 运行时类型序列化允许 `null`,输出 `"null"` + +## 配置生命周期 + +这部分是当前实现最容易被旧文档说错的地方。 + +`JsonSerializer` 不会复制你传入的 `JsonSerializerSettings`。它会直接持有并复用这份实例,以及里面的 `Converters` 集合。 + +这意味着推荐模式是: + +1. 在组合根创建序列化器 +2. 一次性完成 settings / converters 配置 +3. 再把同一个实例注册给存储、repository 或 architecture + +推荐写法: ```csharp -using GFramework.Core.SourceGenerators.Abstractions.Rule; +using Newtonsoft.Json; -public class PlayerData -{ - public string Name { get; set; } - public int Level { get; set; } - public int Experience { get; set; } -} - -[ContextAware] -public partial class SaveController : IController -{ - public void SavePlayer() - { - var serializer = this.GetUtility(); - - var player = new PlayerData - { - Name = "Player1", - Level = 10, - Experience = 1000 - }; - - // 序列化为 JSON 字符串 - string json = serializer.Serialize(player); - Console.WriteLine(json); - // 输出: {"Name":"Player1","Level":10,"Experience":1000} - } -} -``` - -### 反序列化对象 - -从字符串还原对象: - -```csharp -public void LoadPlayer() -{ - var serializer = this.GetUtility(); - - string json = "{\"Name\":\"Player1\",\"Level\":10,\"Experience\":1000}"; - - // 反序列化为对象 - var player = serializer.Deserialize(json); - - Console.WriteLine($"玩家: {player.Name}, 等级: {player.Level}"); -} -``` - -### 运行时类型序列化 - -处理不确定类型的对象: - -```csharp -public void SerializeRuntimeType() -{ - var serializer = this.GetUtility(); - - object data = new PlayerData { Name = "Player1", Level = 10 }; - Type dataType = data.GetType(); - - // 使用运行时类型序列化 - string json = serializer.Serialize(data, dataType); - - // 使用运行时类型反序列化 - object restored = serializer.Deserialize(json, dataType); - - var player = restored as PlayerData; - Console.WriteLine($"玩家: {player?.Name}"); -} -``` - -### 配置生命周期约束 - -`JsonSerializer` 不会复制 `JsonSerializerSettings`。这意味着: - -- 传给构造函数的 settings 会被原样保留 -- `serializer.Settings` 与 `serializer.Converters` 返回的都是活动配置对象 -- 一旦序列化器实例已经注册给其他模块或开始被并发调用,就不应继续修改这些配置 - -推荐模式: - -```csharp var settings = new JsonSerializerSettings { Formatting = Formatting.Indented, NullValueHandling = NullValueHandling.Ignore }; -settings.Converters.Add(new Vector2JsonConverter()); +settings.Converters.Add(new CoordinateConverter()); var serializer = new JsonSerializer(settings); +``` + +不推荐写法: + +```csharp +var serializer = architecture.GetUtility(); + +// 序列化器已经被多个组件共享后,再继续改 converter,容易让并发调用看到不稳定配置。 +((JsonSerializer)serializer).Converters.Add(new LateBoundConverter()); +``` + +## 最小接入路径 + +### 作为底层 serializer 注册 + +当前更常见的采用方式不是“业务代码直接到处调 serializer”,而是把它注册给存储和 repository 复用: + +```csharp +using GFramework.Core.Abstractions.Serializer; +using GFramework.Game.Serializer; + +var serializer = new JsonSerializer(); architecture.RegisterUtility(serializer); architecture.RegisterUtility(serializer); ``` -不推荐模式: +然后由: + +- `FileStorage` +- `UnifiedSettingsDataRepository` +- 其他依赖 `ISerializer` / `IRuntimeTypeSerializer` 的组件 + +统一复用这一份实例。 + +### 直接处理运行时类型 + +当业务层拿到的是 `object + Type` 组合,而不是静态泛型类型时,再使用运行时 API: ```csharp -var serializer = architecture.GetUtility(); +var serializer = new JsonSerializer(); -// 已经共享给运行时后再修改配置,容易让并发调用得到不稳定行为 -((JsonSerializer)serializer).Converters.Add(new LateBoundConverter()); +object data = new PlayerState +{ + Name = "Runtime", + Level = 11 +}; + +var json = serializer.Serialize(data, data.GetType()); +var restored = serializer.Deserialize(json, data.GetType()); ``` -## 高级用法 +## 与存储系统的关系 -### 与存储系统集成 +`FileStorage` 已经会调用注入的 `ISerializer` 自己完成对象读写,因此当前默认接法里: -序列化器与存储系统配合使用: +- 你可以直接 `storage.WriteAsync("profile/player", profile)` +- 不需要先手工 `serializer.Serialize(profile)` 再把字符串写回存储 -```csharp -using GFramework.Core.Abstractions.Storage; -using GFramework.Game.Storage; -using GFramework.Core.SourceGenerators.Abstractions.Rule; +手工显式调用 `Serialize(...)` 更适合这些场景: -[ContextAware] -public partial class DataManager : IController -{ - public async Task SaveData() - { - var serializer = this.GetUtility(); - var storage = this.GetUtility(); +- 需要把 JSON 发到网络或日志 +- 需要和外部文本格式做中转 +- 需要直接调试序列化输出内容 - var gameData = new GameData - { - Score = 1000, - Coins = 500 - }; +如果目标只是本地持久化,优先让 `IStorage` / repository 复用 serializer。 - // 序列化数据 - string json = serializer.Serialize(gameData); +## 与配置系统的关系 - // 写入存储 - await storage.WriteAsync("game_data", json); - } +不要把 `JsonSerializer` 和 `Game` 的 YAML 配置系统混在一起: - public async Task LoadData() - { - var serializer = this.GetUtility(); - var storage = this.GetUtility(); +- `JsonSerializer` + - 负责运行时对象 JSON 序列化 +- `Game.SourceGenerators + YamlConfigLoader` + - 负责 schema 驱动的配置表生成与 YAML 读取 - // 从存储读取 - string json = await storage.ReadAsync("game_data"); +如果你的目标是静态内容配置表,而不是运行时持久化对象,请改看 [`config-system.md`](./config-system.md)。 - // 反序列化数据 - return serializer.Deserialize(json); - } -} -``` +## 当前边界 -### 序列化复杂对象 +- 当前公开默认实现只有 JSON,没有内建 MessagePack、Binary 或 ProtoBuf 实现 +- `JsonSerializer` 负责序列化,不负责对象版本迁移;版本迁移属于 `SettingsModel` 或 `SaveRepository` +- 序列化器共享后应视为只读配置对象,避免在运行期继续修改 settings / converters -处理嵌套和集合类型: +## 继续阅读 -```csharp -public class InventoryData -{ - public List Items { get; set; } - public Dictionary Resources { get; set; } -} - -public class ItemData -{ - public string Id { get; set; } - public string Name { get; set; } - public int Quantity { get; set; } -} - -public void SerializeComplexData() -{ - var serializer = this.GetUtility(); - - var inventory = new InventoryData - { - Items = new List - { - new ItemData { Id = "sword_01", Name = "铁剑", Quantity = 1 }, - new ItemData { Id = "potion_hp", Name = "生命药水", Quantity = 5 } - }, - Resources = new Dictionary - { - { "gold", 1000 }, - { "wood", 500 } - } - }; - - // 序列化复杂对象 - string json = serializer.Serialize(inventory); - - // 反序列化 - var restored = serializer.Deserialize(json); - - Console.WriteLine($"物品数量: {restored.Items.Count}"); - Console.WriteLine($"金币: {restored.Resources["gold"]}"); -} -``` - -### 处理多态类型 - -序列化继承层次结构: - -```csharp -public abstract class EntityData -{ - public string Id { get; set; } - public string Type { get; set; } -} - -public class PlayerEntityData : EntityData -{ - public int Level { get; set; } - public int Experience { get; set; } -} - -public class EnemyEntityData : EntityData -{ - public int Health { get; set; } - public int Damage { get; set; } -} - -public void SerializePolymorphic() -{ - var serializer = this.GetUtility(); - - // 创建不同类型的实体 - EntityData player = new PlayerEntityData - { - Id = "player_1", - Type = "Player", - Level = 10, - Experience = 1000 - }; - - EntityData enemy = new EnemyEntityData - { - Id = "enemy_1", - Type = "Enemy", - Health = 100, - Damage = 20 - }; - - // 使用运行时类型序列化 - string playerJson = serializer.Serialize(player, player.GetType()); - string enemyJson = serializer.Serialize(enemy, enemy.GetType()); - - // 根据类型反序列化 - var restoredPlayer = serializer.Deserialize(playerJson, typeof(PlayerEntityData)); - var restoredEnemy = serializer.Deserialize(enemyJson, typeof(EnemyEntityData)); -} -``` - -### 自定义序列化逻辑 - -虽然 GFramework 使用 Newtonsoft.Json,但你可以通过特性控制序列化行为: - -```csharp -using Newtonsoft.Json; - -public class CustomData -{ - // 忽略此属性 - [JsonIgnore] - public string InternalId { get; set; } - - // 使用不同的属性名 - [JsonProperty("player_name")] - public string Name { get; set; } - - // 仅在值不为 null 时序列化 - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public string? OptionalField { get; set; } - - // 格式化日期 - [JsonProperty("created_at")] - [JsonConverter(typeof(IsoDateTimeConverter))] - public DateTime CreatedAt { get; set; } -} -``` - -### 批量序列化 - -处理多个对象的序列化: - -```csharp -public async Task SaveMultipleData() -{ - var serializer = this.GetUtility(); - var storage = this.GetUtility(); - - var dataList = new Dictionary - { - { "player", new PlayerData { Name = "Player1", Level = 10 } }, - { "inventory", new InventoryData { Items = new List() } }, - { "settings", new SettingsData { Volume = 0.8f } } - }; - - // 批量序列化和保存 - foreach (var (key, data) in dataList) - { - string json = serializer.Serialize(data); - await storage.WriteAsync(key, json); - } - - Console.WriteLine($"已保存 {dataList.Count} 个数据文件"); -} -``` - -### 错误处理 - -处理序列化和反序列化错误: - -```csharp -public void SafeDeserialize() -{ - var serializer = this.GetUtility(); - - string json = "{\"Name\":\"Player1\",\"Level\":\"invalid\"}"; // 错误的数据 - - try - { - var player = serializer.Deserialize(json); - } - catch (ArgumentException ex) - { - Console.WriteLine($"反序列化失败: {ex.Message}"); - // 返回默认值或重新尝试 - } - catch (JsonException ex) - { - Console.WriteLine($"JSON 格式错误: {ex.Message}"); - } -} - -public PlayerData DeserializeWithFallback(string json) -{ - var serializer = this.GetUtility(); - - try - { - return serializer.Deserialize(json); - } - catch - { - // 返回默认数据 - return new PlayerData - { - Name = "DefaultPlayer", - Level = 1, - Experience = 0 - }; - } -} -``` - -### 版本兼容性 - -处理数据结构变化: - -```csharp -// 旧版本数据 -public class PlayerDataV1 -{ - public string Name { get; set; } - public int Level { get; set; } -} - -// 新版本数据(添加了新字段) -public class PlayerDataV2 -{ - public string Name { get; set; } - public int Level { get; set; } - public int Experience { get; set; } = 0; // 新增字段,提供默认值 - public DateTime LastLogin { get; set; } = DateTime.Now; // 新增字段 -} - -public PlayerDataV2 LoadWithMigration(string json) -{ - var serializer = this.GetUtility(); - - try - { - // 尝试加载新版本 - return serializer.Deserialize(json); - } - catch - { - // 如果失败,尝试加载旧版本并迁移 - var oldData = serializer.Deserialize(json); - return new PlayerDataV2 - { - Name = oldData.Name, - Level = oldData.Level, - Experience = oldData.Level * 100, // 根据等级计算经验 - LastLogin = DateTime.Now - }; - } -} -``` - -## 最佳实践 - -1. **优先依赖接口,在组合根统一实例化**:业务代码依赖 `ISerializer`,具体 `JsonSerializer` 在启动阶段配置一次即可 - ```csharp - ✓ var serializer = this.GetUtility(); - ✓ var serializer = new JsonSerializer(settings); // 仅在组合根/启动阶段配置 - ✗ var serializer = new JsonSerializer(); // 避免在业务逻辑中临时创建 - ``` - -2. **为数据类提供默认值**:确保反序列化的健壮性 - ```csharp - public class GameData - { - public string Name { get; set; } = "Default"; - public int Score { get; set; } = 0; - public List Items { get; set; } = new(); - } - ``` - -3. **处理反序列化异常**:避免程序崩溃 - ```csharp - try - { - var data = serializer.Deserialize(json); - } - catch (Exception ex) - { - Logger.Error($"反序列化失败: {ex.Message}"); - return GetDefaultData(); - } - ``` - -4. **避免序列化敏感数据**:使用 `[JsonIgnore]` 标记 - ```csharp - public class UserData - { - public string Username { get; set; } - - [JsonIgnore] - public string Password { get; set; } // 不序列化密码 - } - ``` - -5. **使用运行时类型处理多态**:保持类型信息 - ```csharp - var serializer = this.GetUtility(); - string json = serializer.Serialize(obj, obj.GetType()); - ``` - -6. **验证反序列化的数据**:确保数据完整性 - ```csharp - var data = serializer.Deserialize(json); - if (string.IsNullOrEmpty(data.Name) || data.Score < 0) - { - throw new InvalidDataException("数据验证失败"); - } - ``` - -## 性能优化 - -### 减少序列化开销 - -```csharp -// 避免频繁序列化大对象 -public class CachedSerializer -{ - private string? _cachedJson; - private GameData? _cachedData; - - public string GetJson(GameData data) - { - if (_cachedData == data && _cachedJson != null) - { - return _cachedJson; - } - - var serializer = GetSerializer(); - _cachedJson = serializer.Serialize(data); - _cachedData = data; - return _cachedJson; - } -} -``` - -### 异步序列化 - -```csharp -public async Task SaveDataAsync() -{ - var serializer = this.GetUtility(); - var storage = this.GetUtility(); - - var data = GetLargeData(); - - // 在后台线程序列化 - string json = await Task.Run(() => serializer.Serialize(data)); - - // 异步写入存储 - await storage.WriteAsync("large_data", json); -} -``` - -### 分块序列化 - -```csharp -public async Task SaveLargeDataset() -{ - var serializer = this.GetUtility(); - var storage = this.GetUtility(); - - var largeDataset = GetLargeDataset(); - - // 分块保存 - const int chunkSize = 100; - for (int i = 0; i < largeDataset.Count; i += chunkSize) - { - var chunk = largeDataset.Skip(i).Take(chunkSize).ToList(); - string json = serializer.Serialize(chunk); - await storage.WriteAsync($"data_chunk_{i / chunkSize}", json); - } -} -``` - -## 常见问题 - -### 问题:如何序列化循环引用的对象? - -**解答**: -Newtonsoft.Json 默认不支持循环引用,需要配置: - -```csharp -// 注意:GFramework 的 JsonSerializer 使用默认设置 -// 如需处理循环引用,避免创建循环引用的数据结构 -// 或使用 [JsonIgnore] 打破循环 - -public class Node -{ - public string Name { get; set; } - public List Children { get; set; } - - [JsonIgnore] // 忽略父节点引用,避免循环 - public Node? Parent { get; set; } -} -``` - -### 问题:序列化后的 JSON 太大怎么办? - -**解答**: -使用压缩或分块存储: - -```csharp -public async Task SaveCompressed() -{ - var serializer = this.GetUtility(); - var storage = this.GetUtility(); - - var data = GetLargeData(); - string json = serializer.Serialize(data); - - // 压缩 JSON - byte[] compressed = Compress(json); - - // 保存压缩数据 - await storage.WriteAsync("data_compressed", compressed); -} - -private byte[] Compress(string text) -{ - using var output = new MemoryStream(); - using (var gzip = new GZipStream(output, CompressionMode.Compress)) - using (var writer = new StreamWriter(gzip)) - { - writer.Write(text); - } - return output.ToArray(); -} -``` - -### 问题:如何处理不同平台的序列化差异? - -**解答**: -使用平台无关的数据类型: - -```csharp -public class CrossPlatformData -{ - // 使用 string 而非 DateTime(避免时区问题) - public string CreatedAt { get; set; } = DateTime.UtcNow.ToString("O"); - - // 使用 double 而非 float(精度一致) - public double Score { get; set; } - - // 明确指定编码 - public string Text { get; set; } -} -``` - -### 问题:反序列化失败时如何恢复? - -**解答**: -实现备份和恢复机制: - -```csharp -public async Task LoadWithBackup(string key) -{ - var serializer = this.GetUtility(); - var storage = this.GetUtility(); - - try - { - // 尝试加载主数据 - string json = await storage.ReadAsync(key); - return serializer.Deserialize(json); - } - catch - { - // 尝试加载备份 - try - { - string backupJson = await storage.ReadAsync($"{key}_backup"); - return serializer.Deserialize(backupJson); - } - catch - { - // 返回默认数据 - return new GameData(); - } - } -} -``` - -### 问题:如何加密序列化的数据? - -**解答**: -在序列化后加密: - -```csharp -public async Task SaveEncrypted(string key, GameData data) -{ - var serializer = this.GetUtility(); - var storage = this.GetUtility(); - - // 序列化 - string json = serializer.Serialize(data); - - // 加密 - byte[] encrypted = EncryptString(json); - - // 保存 - await storage.WriteAsync(key, encrypted); -} - -public async Task LoadEncrypted(string key) -{ - var serializer = this.GetUtility(); - var storage = this.GetUtility(); - - // 读取 - byte[] encrypted = await storage.ReadAsync(key); - - // 解密 - string json = DecryptToString(encrypted); - - // 反序列化 - return serializer.Deserialize(json); -} -``` - -### 问题:序列化器是线程安全的吗? - -**解答**: -`JsonSerializer` 的并发读行为只在“配置不再变化”这个前提下才安全。当前实现会暴露活动中的 -`JsonSerializerSettings` 与 `Converters` 集合,因此: - -- 可以在启动阶段创建并配置一个共享实例 -- 可以在配置冻结后把该实例注册为 Utility 或注入到仓库 -- 不应在序列化器已经被多个调用方使用时继续修改 settings、contract resolver 或 converters - -推荐按下面的方式在启动阶段完成配置,然后只做读操作: - -```csharp -// 启动阶段完成全部配置 -var serializer = new JsonSerializer(new JsonSerializerSettings -{ - NullValueHandling = NullValueHandling.Ignore -}); -serializer.Converters.Add(new GameDataJsonConverter()); - -architecture.RegisterUtility(serializer); - -// 运行阶段只复用,不再修改配置 -public async Task ParallelSave() -{ - var tasks = Enumerable.Range(0, 10).Select(async i => - { - var serializer = this.GetUtility(); - var data = new GameData { Score = i }; - string json = serializer.Serialize(data); - await SaveToStorage($"data_{i}", json); - }); - - await Task.WhenAll(tasks); -} -``` - -## 相关文档 - -- [数据与存档系统](/zh-CN/game/data) - 数据持久化 -- [存储系统](/zh-CN/game/storage) - 文件存储 -- [设置系统](/zh-CN/game/setting) - 设置数据序列化 -- [Utility 系统](/zh-CN/core/utility) - 工具类注册 +1. [存储系统](./storage.md) +2. [数据与存档系统](./data.md) +3. [配置系统](./config-system.md) +4. [Game 入口](./index.md) diff --git a/docs/zh-CN/game/setting.md b/docs/zh-CN/game/setting.md index 67f998e8..b5bc724e 100644 --- a/docs/zh-CN/game/setting.md +++ b/docs/zh-CN/game/setting.md @@ -1,207 +1,204 @@ +--- +title: 设置系统 +description: 以当前 SettingsModel、SettingsSystem 与相关测试为准,说明设置数据、applicator、迁移和持久化的真实接法。 +--- + # 设置系统 -设置系统负责管理 `ISettingsData`、持久化加载/保存,以及把设置真正应用到运行时环境。 +`GFramework.Game` 的设置系统负责三件事: -当前实现以 `SettingsModel` 和 `SettingsSystem` 为核心,已经不是旧文档中的 -`Get() / Register(IApplyAbleSettings)` 接口模型。 +- 管理 `ISettingsData` 实例的生命周期 +- 管理设置 applicator,并把设置真正作用到运行时环境 +- 在初始化时加载、迁移、保存和重置设置 -## 核心概念 +当前默认 owner 是: -### ISettingsData +- `SettingsModel` +- `SettingsSystem` -设置数据对象负责保存设置值、提供默认值,并在加载后把外部数据回填到当前实例。 +而不是旧文档里那种“只靠若干 `Get() / Register(...)` 辅助方法就能自动完成一切”的模型。 + +## 当前公开入口 + +### `ISettingsData` + +设置数据对象需要同时承担: + +- 默认值持有者 +- 版本化 section +- 从已加载数据回填到当前实例的入口 + +当前接口组合是: ```csharp public interface ISettingsData : IResettable, IVersionedData, ILoadableFrom; ``` -这意味着一个设置数据类型通常需要实现: +这意味着一个设置数据类型至少要处理: -- `Reset()`:恢复默认值 -- `Version` / `LastModified`:暴露版本化信息 -- `LoadFrom(ISettingsData)`:把已加载或迁移后的数据复制到当前实例 +- `Reset()` +- `Version` +- `LastModified` +- `LoadFrom(ISettingsData source)` -### IResetApplyAbleSettings +### `IResetApplyAbleSettings` -应用器负责把设置数据作用到引擎或运行时环境: +applicator 的职责不是保存数据,而是把设置结果作用到实际运行时对象。 + +它当前需要暴露: + +- `Data` +- `DataType` +- `Reset()` +- `ApplyAsync()` + +典型场景包括: + +- 把音量设置同步到音频系统 +- 把画质设置同步到窗口或渲染配置 +- 把语言设置同步到本地化服务 + +### `SettingsModel` + +这是当前设置系统的核心编排器。按当前源码,它负责: + +- `GetData()` + - 返回某个设置类型的唯一实例 +- `RegisterApplicator(...)` + - 注册 applicator,并把其 `Data` 一并纳入模型管理 +- `RegisterMigration(...)` + - 注册同一设置类型的前进式迁移链 +- `InitializeAsync()` + - 从 repository 读取所有设置、执行迁移、回填到当前实例 +- `SaveAllAsync()` + - 持久化所有已登记的设置数据 +- `ApplyAllAsync()` + - 依次应用所有 applicator +- `Reset() / ResetAll()` + - 重置单个或全部设置 + +### `SettingsSystem` + +`SettingsSystem` 是面向业务代码更直接的一层系统封装: + +- `ApplyAll()` +- `Apply()` +- `SaveAll()` +- `Reset()` +- `ResetAll()` + +它自己不持有独立设置状态,而是把工作委托给 `ISettingsModel`,并在应用时补发 settings 相关事件。 + +## 初始化与迁移的真实语义 + +`SettingsModel.InitializeAsync()` 的当前行为,比旧文档里“加载一下就好”更严格一些: + +- 它会先调用 `ISettingsDataRepository.LoadAllAsync()` +- 再逐个匹配当前模型里已经登记的设置类型 +- 如果读到了旧版本设置,会以“当前内存实例声明的 `Version`”为目标版本执行迁移 +- 迁移完成后通过 `LoadFrom(...)` 回填到现有实例,而不是直接替换对象引用 + +当前测试还确认了几个关键边界: + +- 同一设置类型的同一个 `FromVersion` 不能重复注册迁移器 +- 注册新迁移器后,类型级迁移缓存会失效并重建,不会继续使用旧快照 +- 如果迁移链缺口导致无法安全升级,模型会保留当前内存中的最新实例,而不是把不完整的旧数据覆盖进来 +- 单个设置 section 初始化失败时,模型会记录错误并继续处理其他 section + +这套语义更接近“尽量保证运行时实例总是可用”,而不是“任意旧设置都必须成功导入”。 + +## 最小接入路径 + +当前最常见的接法是: + +1. 准备一个 `IStorage` +2. 准备一个 `IRuntimeTypeSerializer` +3. 注册 `ISettingsDataRepository` +4. 注册 `IDataLocationProvider` +5. 创建并注册 `SettingsModel` +6. 注册 applicator +7. 注册 `SettingsSystem` + +示意代码: ```csharp -public interface IResetApplyAbleSettings : IResettable, IApplyAbleSettings -{ - ISettingsData Data { get; } - Type DataType { get; } -} +using GFramework.Core.Abstractions.Storage; +using GFramework.Game.Abstractions.Data; +using GFramework.Game.Abstractions.Setting; +using GFramework.Game.Data; +using GFramework.Game.Serializer; +using GFramework.Game.Setting; +using GFramework.Game.Storage; + +var serializer = new JsonSerializer(); +var storage = new FileStorage("GameData", serializer, ".json"); + +var repository = new UnifiedSettingsDataRepository( + storage, + serializer, + new DataRepositoryOptions + { + BasePath = "settings", + AutoBackup = true + }); + +architecture.RegisterUtility(storage); +architecture.RegisterUtility(serializer); +architecture.RegisterUtility(repository); +// 此处注册项目侧的 IDataLocationProvider 实现,用于把设置类型映射到 section key。 + +var settingsModel = new SettingsModel(null, null); +// 在注册到架构前,继续补 applicator 与 migration。 + +architecture.RegisterModel(settingsModel); +architecture.RegisterSystem(new SettingsSystem()); ``` -常见用途包括: - -- 把音量设置同步到音频总线 -- 把图形设置同步到窗口系统 -- 把语言设置同步到本地化管理器 - -## ISettingsModel - -当前 `ISettingsModel` 的主要 API 如下: +启动阶段通常是: ```csharp -public interface ISettingsModel : IModel -{ - bool IsInitialized { get; } - - T GetData() where T : class, ISettingsData, new(); - IEnumerable AllData(); - - ISettingsModel RegisterApplicator(T applicator) - where T : class, IResetApplyAbleSettings; - T? GetApplicator() where T : class, IResetApplyAbleSettings; - IEnumerable AllApplicators(); - - ISettingsModel RegisterMigration(ISettingsMigration migration); - - Task InitializeAsync(); - Task SaveAllAsync(); - Task ApplyAllAsync(); - void Reset() where T : class, ISettingsData, new(); - void ResetAll(); -} -``` - -行为说明: - -- `GetData()` 返回某个设置数据的唯一实例 -- `RegisterApplicator()` 注册应用器,并把其 `Data` 纳入模型管理 -- `InitializeAsync()` 从 `ISettingsDataRepository` 读取所有已注册设置,并在需要时执行迁移 -- `SaveAllAsync()` 持久化当前所有设置数据 -- `ApplyAllAsync()` 依次调用所有 applicator 的 `ApplyAsync()` - -## SettingsSystem - -`SettingsSystem` 是对模型的系统级封装,面向业务代码提供更直接的入口: - -```csharp -public interface ISettingsSystem : ISystem -{ - Task ApplyAll(); - Task Apply() where T : class, IResetApplyAbleSettings; - Task SaveAll(); - Task Reset() where T : class, ISettingsData, IResetApplyAbleSettings, new(); - Task ResetAll(); -} -``` - -它不会自己保存数据,而是把保存、重置和应用逻辑委托给 `ISettingsModel`。 - -## 基本用法 - -### 定义设置数据 - -```csharp -public sealed class GameplaySettings : ISettingsData -{ - public float GameSpeed { get; set; } = 1.0f; - - public int Version { get; private set; } = 1; - public DateTime LastModified { get; } = DateTime.UtcNow; - - public void Reset() - { - GameSpeed = 1.0f; - } - - public void LoadFrom(ISettingsData source) - { - if (source is not GameplaySettings settings) - { - return; - } - - GameSpeed = settings.GameSpeed; - Version = settings.Version; - } -} -``` - -### 定义 applicator - -```csharp -public sealed class GameplaySettingsApplicator : IResetApplyAbleSettings -{ - public GameplaySettingsApplicator(GameplaySettings data) - { - Data = data; - } - - public ISettingsData Data { get; } - public Type DataType => typeof(GameplaySettings); - - public void Reset() - { - Data.Reset(); - } - - public Task ApplyAsync() - { - var settings = (GameplaySettings)Data; - TimeScale.Current = settings.GameSpeed; - return Task.CompletedTask; - } -} -``` - -### 使用模型和系统 - -```csharp -var settingsModel = this.GetModel(); - -var gameplayData = settingsModel.GetData(); -gameplayData.GameSpeed = 1.25f; - -settingsModel.RegisterApplicator(new GameplaySettingsApplicator(gameplayData)); - await settingsModel.InitializeAsync(); -await settingsModel.SaveAllAsync(); - -var settingsSystem = this.GetSystem(); -await settingsSystem.ApplyAll(); +await settingsModel.ApplyAllAsync(); ``` -## 迁移 - -设置系统内建了迁移注册入口: +退出或显式保存时: ```csharp -public interface ISettingsMigration -{ - Type SettingsType { get; } - int FromVersion { get; } - int ToVersion { get; } - ISettingsData Migrate(ISettingsData oldData); -} +await settingsModel.SaveAllAsync(); ``` -当 `InitializeAsync()` 读取到旧版本设置时,会按已注册迁移链逐步升级,再通过 `LoadFrom` 回填到当前实例。 +## `GetData()` 和 `RegisterApplicator(...)` 的分工 -迁移规则如下: +这两个入口经常被混用,但职责不同: -- 同一个设置类型的同一个 `FromVersion` 只能注册一个迁移器 -- `ToVersion` 必须严格大于 `FromVersion` -- `InitializeAsync()` 会以当前运行时代码里该设置实例的 `Version` 作为目标版本 -- 如果迁移链缺口、迁移结果类型不兼容、迁移结果版本与声明不一致,或者读取到比当前运行时更高的版本,当前设置节不会覆盖内存中的最新实例,并会记录错误日志 -- 与 `SaveRepository` 不同,设置初始化阶段会跳过失败的设置节并继续处理其他设置节,而不是把异常继续向外抛出 +- `GetData()` + - 只保证某个设置数据实例存在,并在 repository / location provider 已就绪时把类型注册回去 +- `RegisterApplicator(...)` + - 同时注册 applicator 和 applicator 绑定的 `Data` -## 依赖项 +如果一个设置类型需要真正作用到运行时对象,推荐让它通过 applicator 进入模型;这样 `ApplyAllAsync()`、`ResetAll()` 和 +`SettingsSystem` 才能完整覆盖到它。 -要让设置系统完整工作,通常需要准备: +## 与 repository 的关系 -- `ISettingsDataRepository` -- `IDataLocationProvider` -- 一个具体的存储实现和序列化器 +设置系统默认不是直接写文件,而是依赖 `ISettingsDataRepository`。 -如果使用 `UnifiedSettingsDataRepository`,多个设置节会被合并到单个设置文件中统一保存。 +当前仓库里更推荐的默认实现是 `UnifiedSettingsDataRepository`,原因很直接: + +- 多个设置 section 会被聚合到同一份统一文件 +- 启动时能一次性 `LoadAllAsync()` +- `AutoBackup` 针对整个统一文件生效,更贴近“设置快照”的真实语义 + +如果你的项目明确需要“一类设置一个独立文件”,才考虑回到通用 `DataRepository` 路径。 ## 当前边界 -- 设置迁移是内建能力 -- 设置持久化是内建能力 -- 设置如何应用到具体引擎由 applicator 决定 -- 存档系统也支持内建版本迁移,但入口位于 `ISaveRepository.RegisterMigration(...)`,语义是槽位存档升级而不是设置节初始化 +- `SettingsModel` 负责数据生命周期,`SettingsSystem` 负责系统级调用入口;两者不要混成一个巨型服务 +- applicator 决定“怎么把数据应用到宿主”,repository 决定“怎么保存数据”,两层职责不要互相侵入 +- 设置迁移和存档迁移是两条不同管线;后者看 [`data.md`](./data.md) 里的 `SaveRepository` + +## 继续阅读 + +1. [数据与存档系统](./data.md) +2. [存储系统](./storage.md) +3. [Game 入口](./index.md) diff --git a/docs/zh-CN/game/storage.md b/docs/zh-CN/game/storage.md index 3de0170c..ad2f9856 100644 --- a/docs/zh-CN/game/storage.md +++ b/docs/zh-CN/game/storage.md @@ -1,735 +1,181 @@ --- -title: 存储系统详解 -description: 存储系统提供了灵活的文件存储和作用域隔离功能,支持跨平台数据持久化。 +title: Game 存储系统 +description: 以当前 GFramework.Game 源码与持久化测试为准,说明 FileStorage 与 ScopedStorage 的职责、路径语义和复用方式。 --- -# 存储系统详解 +# Game 存储系统 -## 概述 +`GFramework.Game` 在存储这一层只提供宿主无关的 `IStorage` 默认实现和作用域包装器。 -存储系统是 GFramework.Game 中用于管理文件存储的核心组件。它提供了统一的存储接口,支持键值对存储、作用域隔离、目录操作等功能,让你可以轻松实现游戏数据的持久化。 +当前真正对外需要理解的入口只有两个: -存储系统采用装饰器模式设计,通过 `IStorage` 接口定义统一的存储操作,`FileStorage` 提供基于文件系统的实现,`ScopedStorage` -提供作用域隔离功能。 +- `FileStorage` + - 负责 `key -> 文件路径 -> 序列化内容` 的落盘读写 +- `ScopedStorage` + - 负责给同一份底层存储加前缀作用域,避免不同子系统直接拼字符串抢同一片键空间 -**主要特性**: +它们不负责: -- 统一的键值对存储接口 -- 基于文件系统的持久化 -- 作用域隔离和命名空间管理 -- 线程安全的并发访问 -- 支持同步和异步操作 -- 目录和文件列举功能 -- 路径安全防护 -- 跨平台支持(包括 Godot) +- 设置 section 的聚合语义 +- 存档槽位目录约定 +- 业务数据迁移 -## 核心概念 +这些都属于上层 repository。 -### 存储接口 +## 当前公开入口 -`IStorage` 定义了统一的存储操作: +### `FileStorage` + +`FileStorage` 是 `IStorage` 的默认文件系统实现。按当前源码,它的职责比较集中: + +- 把业务 key 映射到根目录下的层级文件路径 +- 通过构造函数注入的 `ISerializer` 负责对象序列化和反序列化 +- 对同一目标路径使用 `IAsyncKeyLockManager` 做细粒度串行化 +- 写入时先落 `.tmp` 临时文件,再原子替换目标文件 +- 自动创建父目录 +- 拒绝包含 `..` 的非法 key,并清理路径段中的非法文件名字符 + +默认文件扩展名是 `.dat`,也可以在构造时改成 `.json` 或其他后缀: ```csharp -public interface IStorage : IUtility -{ - // 检查键是否存在 - bool Exists(string key); - Task ExistsAsync(string key); - - // 读取数据 - T Read<T>(string key); - T Read<T>(string key, T defaultValue); - Task<T> ReadAsync<T>(string key); - - // 写入数据 - void Write<T>(string key, T value); - Task WriteAsync<T>(string key, T value); - - // 删除数据 - void Delete(string key); - Task DeleteAsync(string key); - - // 目录操作 - Task> ListDirectoriesAsync(string path = ""); - Task> ListFilesAsync(string path = ""); - Task DirectoryExistsAsync(string path); - Task CreateDirectoryAsync(string path); -} -``` - -### 文件存储 - -`FileStorage` 是基于文件系统的存储实现: - -- 将数据序列化后保存为文件 -- 支持自定义文件扩展名(默认 `.dat`) -- 使用细粒度锁保证线程安全 -- 自动创建目录结构 -- 防止路径遍历攻击 - -### 作用域存储 - -`ScopedStorage` 提供命名空间隔离: - -- 为所有键添加前缀 -- 支持嵌套作用域 -- 透明包装底层存储 -- 实现逻辑分组 - -### 存储类型 - -`StorageKinds` 枚举定义了不同的存储方式: - -```csharp -[Flags] -public enum StorageKinds -{ - None = 0, - Local = 1 << 0, // 本地文件系统 - Memory = 1 << 1, // 内存存储 - Remote = 1 << 2, // 远程存储 - Database = 1 << 3 // 数据库存储 -} -``` - -## 基本用法 - -### 创建文件存储 - -```csharp -using GFramework.Game.Storage; +using GFramework.Core.Abstractions.Storage; using GFramework.Game.Serializer; - -// 创建序列化器 -var serializer = new JsonSerializer(); - -// 创Windows 示例) -var storage = new FileStorage(@"C:\MyGame\Data", serializer); - -// 或使用自定义扩展名 -var storage = new FileStorage(@"C:\MyGame\Data", serializer, ".json"); -``` - -### 写入和读取数据 - -```csharp -// 写入简单类型 -storage.Write("player_score", 1000); -storage.Write("player_name", "Alice"); - -// 写入复杂对象 -var settings = new GameSettings -{ - Volume = 0.8f, - Difficulty = "Hard", - Language = "zh-CN" -}; -storage.Write("settings", settings); - -// 读取数据 -int score = storage.Read("player_score"); -string name = storage.Read("player_name"); -var loadedSettings = storage.Read("settings"); - -// 读取数据(带默认值) -int highScore = storage.Read("high_score", 0); -``` - -### 异步操作 - -```csharp -// 异步写入 -await storage.WriteAsync("player_level", 10); - -// 异步读取 -int level = await storage.ReadAsync("player_level"); - -// 异步检查存在 -bool exists = await storage.ExistsAsync("player_level"); - -// 异步删除 -await storage.DeleteAsync("player_level"); -``` - -### 检查和删除 - -```csharp -// 检查键是否存在 -if (storage.Exists("player_score")) -{ - Console.WriteLine("存档存在"); -} - -// 删除数据 -storage.Delete("player_score"); - -// 异步检查 -bool exists = await storage.ExistsAsync("player_score"); -``` - -### 使用层级键 - -```csharp -// 使用 / 分隔符创建层级结构 -storage.Write("player/profile/name", "Alice"); -storage.Write("player/profile/level", 10); -storage.Write("player/inventory/gold", 1000); - -// 文件结构: -// Data/ -// player/ -// profile/ -// name.dat -// level.dat -// inventory/ -// gold.dat - -// 读取层级数据 -string name = storage.Read("player/profile/name"); -int gold = storage.Read("player/inventory/gold"); -``` - -## 作用域存储 - -### 创建作用域存储 - -```csharp using GFramework.Game.Storage; -// 基于文件存储创建作用域存储 -var baseStorage = new FileStorage(@"C:\MyGame\Data", serializer); -var playerStorage = new ScopedStorage(baseStorage, "player"); - -// 所有操作都会添加 "player/" 前缀 -playerStorage.Write("name", "Alice"); // 实际存储为 "player/name.dat" -playerStorage.Write("level", 10); // 实际存储为 "player/level.dat" - -// 读取时也使用相同的前缀 -string name = playerStorage.Read("name"); // 从 "player/name.dat" 读取 +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"` 之类的字符串拼接 + +当前实现还支持继续嵌套: ```csharp -// 创建嵌套作用域 -var settingsStorage = new ScopedStorage(baseStorage, "settings"); -var graphicsStorage = new ScopedStorage(settingsStorage, "graphics"); - -// 前缀变为 "settings/graphics/" -graphicsStorage.Write("resolution", "1920x1080"); -// 实际存储为 "settings/graphics/resolution.dat" - -// 或使用 Scope 方法 +var rootStorage = new FileStorage("GameData", new JsonSerializer(), ".json"); +var settingsStorage = new ScopedStorage(rootStorage, "settings"); var audioStorage = settingsStorage.Scope("audio"); -audioStorage.Write("volume", 0.8f); -// 实际存储为 "settings/audio/volume.dat" + +await audioStorage.WriteAsync("master", 0.8f); ``` -### 多作用域隔离 +最终实际写入的 key 会是 `settings/audio/master`。 -```csharp -// 创建不同作用域的存储 -var playerStorage = new ScopedStorage(baseStorage, "player"); -var gameStorage = new ScopedStorage(baseStorage, "game"); -var settingsStorage = new ScopedStorage(baseStorage, "settings"); +## 路径语义 -// 在不同作用域中使用相同的键不会冲突 -playerStorage.Write("level", 5); // player/level.dat -gameStorage.Write("level", "forest_area_1"); // game/level.dat -settingsStorage.Write("level", "high"); // settings/level.dat +### key 到文件路径的映射 -// 读取时各自独立 -int playerLevel = playerStorage.Read("level"); // 5 -string gameLevel = gameStorage.Read("level"); // "forest_area_1" -string settingsLevel = settingsStorage.Read("level"); // "high" +`FileStorage` 会把 key 中的 `/` 当成目录分隔符,把最后一段作为文件名,并自动附加扩展名。 + +例如: + +```text +key: profile/player +root: GameData +extension: .json ``` -## 高级用法 +会落到: -### 目录操作 +```text +GameData/profile/player.json +``` + +这意味着 key 的语义应该保持“逻辑路径”,而不是“完整文件名”。不要在业务层再自己补一遍 `.json`,否则会得到双重后缀。 + +### 安全边界 + +当前实现会: + +1. 把 `\` 统一成 `/` +2. 拒绝包含 `..` 的 key +3. 清理每个路径段中的非法文件名字符 + +这套规则能挡住明显的路径逃逸和非法文件名问题,但它不代替业务层做目录规划。哪些 key 属于设置、存档还是缓存,仍应由上层模块统一约定。 + +### 同步与异步 API + +`Read`、`Write`、`Exists`、`Delete` 这些同步方法只是对异步 API 的阻塞包装。 + +在 UI 线程或带同步上下文的宿主中,优先使用: + +- `ReadAsync()` +- `WriteAsync()` +- `ExistsAsync()` +- `DeleteAsync()` + +只有在无法继续异步传播时,再退回同步封装。 + +## 最小接入路径 + +如果你只想先拿到一个可复用的本地持久化底座,最短路径如下: ```csharp -// 列举子目录 -var directories = await storage.ListDirectoriesAsync("player"); -foreach (var dir in directories) +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 { - Console.WriteLine($"目录: {dir}"); -} + ["level"] = 12 +}); -// 列举文件 -var files = await storage.ListFilesAsync("player/inventory"); -foreach (var file in files) -{ - Console.WriteLine($"文件: {file}"); -} - -// 检查目录是否存在 -bool exists = await storage.DirectoryExistsAsync("player/quests"); - -// 创建目录 -await storage.CreateDirectoryAsync("player/achievements"); +var loaded = await storage.ReadAsync>("profiles/player"); ``` -### 批量操作 +如果项目里同时有设置、存档和运行时缓存,推荐先在组合根把作用域拆开: ```csharp -public async Task SaveAllPlayerData(PlayerData player) -{ - var playerStorage = new ScopedStorage(baseStorage, $"player_{player.Id}"); +var serializer = new JsonSerializer(); +var rootStorage = new FileStorage("GameData", serializer, ".json"); - // 批量写入 - var tasks = new List - { - playerStorage.WriteAsync("profile", player.Profile), - playerStorage.WriteAsync("inventory", player.Inventory), - playerStorage.WriteAsync("quests", player.Quests), - playerStorage.WriteAsync("achievements", player.Achievements) - }; - - await Task.WhenAll(tasks); - Console.WriteLine("所有玩家数据已保存"); -} - -public async Task LoadAllPlayerData(int playerId) -{ - var playerStorage = new ScopedStorage(baseStorage, $"player_{playerId}"); - - // 批量读取 - var tasks = new[] - { - playerStorage.ReadAsync("profile"), - playerStorage.ReadAsync("inventory"), - playerStorage.ReadAsync("quests"), - playerStorage.ReadAsync("achievements") - }; - - await Task.WhenAll(tasks); - - return new PlayerData - { - Id = playerId, - Profile = tasks[0].Result, - Inventory = tasks[1].Result, - Quests = tasks[2].Result, - Achievements = tasks[3].Result - }; -} +var settingsStorage = new ScopedStorage(rootStorage, "settings"); +var saveStorage = new ScopedStorage(rootStorage, "saves"); +var cacheStorage = new ScopedStorage(rootStorage, "runtime-cache"); ``` -### 存储迁移 +不过在默认仓库接法里,项目通常不需要直接创建 `saveStorage` 这种 scoped instance,因为 `SaveRepository` +会再根据 `SaveConfiguration` 自己组织槽位目录。 -```csharp -public async Task MigrateStorage(IStorage oldStorage, IStorage newStorage, string path = "") -{ - // 列举所有文件 - var files = await oldStorage.ListFilesAsync(path); +## 与上层 repository 的关系 - foreach (var file in files) - { - var key = string.IsNullOrEmpty(path) ? file : $"{path}/{file}"; +`FileStorage` / `ScopedStorage` 是持久化最底层,不是最终采用入口。当前更常见的实际分工是: - // 读取旧数据 - var data = await oldStorage.ReadAsync(key); +- `DataRepository` + - 每个 `IDataLocation` 对应一份独立持久化对象 +- `UnifiedSettingsDataRepository` + - 把多个设置 section 聚合到同一个统一文件里保存 +- `SaveRepository` + - 负责存档槽位、文件名和迁移链 - // 写入新存储 - await newStorage.WriteAsync(key, data); +也就是说: - Console.WriteLine($"已迁移: {key}"); - } +- 业务层如果想保存一份独立数据,优先看 [`data.md`](./data.md) +- 业务层如果想保存设置,优先看 [`setting.md`](./setting.md) +- 业务层如果只是需要底层存储实现,才直接依赖 `IStorage` - // 递归处理子目录 - var directories = await oldStorage.ListDirectoriesAsync(path); - foreach (var dir in directories) - { - var subPath = string.IsNullOrEmpty(path) ? dir : $"{path}/{dir}"; - await MigrateStorage(oldStorage, newStorage, subPath); - } -} -``` +## 当前边界 -### 存储备份 +- `FileStorage` 已经会通过注入的 `ISerializer` 自动序列化对象;默认接法不需要先手工 `Serialize(...)` 再把字符串写回 +- `FileStorage` 负责目录列举与目录创建,但不负责“列出所有存档槽位”的业务语义 +- `ScopedStorage` 只做 key 前缀,不做权限、事务或迁移控制 +- 锁粒度是“当前实例内的目标路径”,不是跨进程文件锁 +- 原子写入只覆盖单文件替换,不等于多文件事务 -```csharp -public class StorageBackupSystem -{ - private readonly IStorage _storage; - private readonly string _backupPrefix = "backup"; +## 继续阅读 - public async Task CreateBackup(string sourcePath) - { - var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); - var backupPath = $"{_backupPrefix}/{timestamp}"; - - await CopyDirectory(sourcePath, backupPath); - Console.WriteLine($"备份已创建: {backupPath}"); - } - - public async Task RestoreBackup(string backupName, string targetPath) - { - var backupPath = $"{_backupPrefix}/{backupName}"; - - if (!await _storage.DirectoryExistsAsync(backupPath)) - { - throw new DirectoryNotFoundException($"备份不存在: {backupName}"); - } - - await CopyDirectory(backupPath, targetPath); - Console.WriteLine($"已从备份恢复: {backupName}"); - } - - private async Task CopyDirectory(string source, string target) - { - var files = await _storage.ListFilesAsync(source); - foreach (var file in files) - { - var sourceKey = $"{source}/{file}"; - var targetKey = $"{target}/{file}"; - var data = await _storage.ReadAsync(sourceKey); - await _storage.WriteAsync(targetKey, data); - } - - var directories = await _storage.ListDirectoriesAsync(source); - foreach (var dir in directories) - { - await CopyDirectory($"{source}/{dir}", $"{target}/{dir}"); - } - } -} -``` - -### 缓存层 - -```csharp -public class CachedStorage : IStorage -{ - private readonly IStorage _innerStorage; - private readonly ConcurrentDictionary _cache = new(); - - public CachedStorage(IStorage innerStorage) - { - _innerStorage = innerStorage; - } - - public T Read<T>(string key) - { - // 先从缓存读取 - if (_cache.TryGetValue(key, out var cached)) - { - return (T)cached; - } - - // 从存储读取并缓存 - var value = _innerStorage.Read<T>(key); - _cache[key] = value; - return value; - } - - public void Write<T>(string key, T value) - { - // 写入存储 - _innerStorage.Write(key, value); - - // 更新缓存 - _cache[key] = value; - } - - public void Delete(string key) - { - _innerStorage.Delete(key); - _cache.TryRemove(key, out _); - } - - public void ClearCache() - { - _cache.Clear(); - } -} -``` - -## Godot 集成 - -### 使用 Godot 文件存储 - -```csharp -using GFramework.Godot.Storage; - -// 创建 Godot 文件存储 -var storage = new GodotFileStorage(serializer); - -// 使用 user:// 路径(用户数据目录) -storage.Write("user://saves/slot1.dat", saveData); -var data = storage.Read("user://saves/slot1.dat"); - -// 使用 res:// 路径(资源目录,只读) -var config = storage.Read("res://config/default.json"); - -// 普通文件路径也支持 -storage.Write("/tmp/temp_data.dat", tempData); -``` - -### Godot 路径说明 - -```csharp -// user:// - 用户数据目录 -// Windows: %APPDATA%/Godot/app_userdata/[project_name] -// Linux: ~/.local/share/godot/app_userdata/[project_name] -// macOS: ~/Library/Application Support/Godot/app_userdata/[project_name] -storage.Write("user://save.dat", data); - -// res:// - 项目资源目录(只读) -var config = storage.Read("res://data/config.json"); - -// 绝对路径 -storage.Write("/home/user/game/data.dat", data); -``` - -## 最佳实践 - -1. **使用作用域隔离不同类型的数据** - ```csharp - ✓ var playerStorage = new ScopedStorage(baseStorage, "player"); - ✓ var settingsStorage = new ScopedStorage(baseStorage, "settings"); - ✗ storage.Write("player_name", name); // 不使用作用域 - ``` - -2. **使用异步操作避免阻塞** - ```csharp - ✓ await storage.WriteAsync("data", value); - ✗ storage.Write("data", value); // 在 UI 线程中同步操作 - ``` - -3. **读取时提供默认值** - ```csharp - ✓ int score = storage.Read("score", 0); - ✗ int score = storage.Read("score"); // 键不存在时抛异常 - ``` - -4. **使用层级键组织数据** - ```csharp - ✓ storage.Write("player/inventory/gold", 1000); - ✗ storage.Write("player_inventory_gold", 1000); - ``` - -5. **处理存储异常** - ```csharp - try - { - await storage.WriteAsync("data", value); - } - catch (IOException ex) - { - Logger.Error($"存储失败: {ex.Message}"); - ShowErrorMessage("保存失败,请检查磁盘空间"); - } - ``` - -6. **定期清理过期数据** - ```csharp - public async Task CleanupOldData(TimeSpan maxAge) - { - var files = await storage.ListFilesAsync("temp"); - foreach (var file in files) - { - var data = await storage.ReadAsync($"temp/{file}"); - if (DateTime.Now - data.Timestamp > maxAge) - { - await storage.DeleteAsync($"temp/{file}"); - } - } - } - ``` - -7. **使用合适的序列化器** - ```csharp - // JSON - 可读性好,适合配置文件 - var jsonStorage = new FileStorage(path, new JsonSerializer(), ".json"); - - // 二进制 - 性能好,适合大量数据 - var binaryStorage = new FileStorage(path, new BinarySerializer(), ".dat"); - ``` - -## 常见问题 - -### 问题:如何实现跨平台存储路径? - -**解答**: -使用 `Environment.GetFolderPath` 获取平台特定路径: - -```csharp -public static string GetStoragePath() -{ - var appData = Environment.GetFolderPath( - Environment.SpecialFolder.ApplicationData); - return Path.Combine(appData, "MyGame", "Data"); -} - -var storage = new FileStorage(GetStoragePath(), serializer); -``` - -### 问题:存储系统是否线程安全? - -**解答**: -是的,`FileStorage` 使用细粒度锁机制保证线程安全: - -```csharp -// 不同键的操作可以并发执行 -Task.Run(() => storage.Write("key1", value1)); -Task.Run(() => storage.Write("key2", value2)); - -// 相同键的操作会串行化 -Task.Run(() => storage.Write("key", value1)); -Task.Run(() => storage.Write("key", value2)); // 等待第一个完成 -``` - -### 问题:如何实现存储加密? - -**解答**: -创建加密存储包装器: - -```csharp -public class EncryptedStorage : IStorage -{ - private readonly IStorage _innerStorage; - private readonly IEncryption _encryption; - - public void Write<T>(string key, T value) - { - var json = JsonSerializer.Serialize(value); - var encrypted = _encryption.Encrypt(json); - _innerStorage.Write(key, encrypted); - } - - public T Read<T>(string key) - { - var encrypted = _innerStorage.Read(key); - var json = _encryption.Decrypt(encrypted); - return JsonSerializer.Deserialize<T>(json); - } -} -``` - -### 问题:如何限制存储大小? - -**解答**: -实现配额管理: - -```csharp -public class QuotaStorage : IStorage -{ - private readonly IStorage _innerStorage; - private readonly long _maxSize; - private long _currentSize; - - public void Write<T>(string key, T value) - { - var data = Serialize(value); - var size = data.Length; - - if (_currentSize + size > _maxSize) - { - throw new InvalidOperationException("存储配额已满"); - } - - _innerStorage.Write(key, value); - _currentSize += size; - } -} -``` - -### 问题:如何实现存储压缩? - -**解答**: -使用压缩序列化器: - -```csharp -public class CompressedSerializer : ISerializer -{ - private readonly ISerializer _innerSerializer; - - public string Serialize<T>(T value) - { - var json = _innerSerializer.Serialize(value); - var bytes = Encoding.UTF8.GetBytes(json); - var compressed = Compress(bytes); - return Convert.ToBase64String(compressed); - } - - public T Deserialize<T>(string data) - { - var compressed = Convert.FromBase64String(data); - var bytes = Decompress(compressed); - var json = Encoding.UTF8.GetString(bytes); - return _innerSerializer.Deserialize<T>(json); - } - - private byte[] Compress(byte[] data) - { - using var output = new MemoryStream(); - using (var gzip = new GZipStream(output, CompressionMode.Compress)) - { - gzip.Write(data, 0, data.Length); - } - return output.ToArray(); - } - - private byte[] Decompress(byte[] data) - { - using var input = new MemoryStream(data); - using var gzip = new GZipStream(input, CompressionMode.Decompress); - using var output = new MemoryStream(); - gzip.CopyTo(output); - return output.ToArray(); - } -} -``` - -### 问题:如何监控存储操作? - -**解答**: -实现日志存储包装器: - -```csharp -public class LoggingStorage : IStorage -{ - private readonly IStorage _innerStorage; - private readonly ILogger _logger; - - public void Write<T>(string key, T value) - { - var stopwatch = Stopwatch.StartNew(); - try - { - _innerStorage.Write(key, value); - _logger.Info($"写入成功: {key}, 耗时: {stopwatch.ElapsedMilliseconds}ms"); - } - catch (Exception ex) - { - _logger.Error($"写入失败: {key}, 错误: {ex.Message}"); - throw; - } - } - - public T Read<T>(string key) - { - var stopwatch = Stopwatch.StartNew(); - try - { - var value = _innerStorage.Read<T>(key); - _logger.Info($"读取成功: {key}, 耗时: {stopwatch.ElapsedMilliseconds}ms"); - return value; - } - catch (Exception ex) - { - _logger.Error($"读取失败: {key}, 错误: {ex.Message}"); - throw; - } - } -} -``` - -## 相关文档 - -- [数据与存档系统](/zh-CN/game/data) - 数据持久化 -- [序列化系统](/zh-CN/game/serialization) - 数据序列化 -- [Godot 集成](/zh-CN/godot/index) - Godot 中的存储 -- [存档系统教程](/zh-CN/tutorials/save-system) - 完整示例 +1. [数据与存档系统](./data.md) +2. [设置系统](./setting.md) +3. [序列化系统](./serialization.md) +4. [Game 入口](./index.md)