From 7114a763772aad1675c34c5f8ad9c50c10b9f1a6 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:21:28 +0800 Subject: [PATCH 1/2] =?UTF-8?q?docs(data):=20=E6=B7=BB=E5=8A=A0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=8E=E5=AD=98=E6=A1=A3=E7=B3=BB=E7=BB=9F=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=B9=B6=E5=AE=9E=E7=8E=B0=E6=95=B0=E6=8D=AE=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增数据与存档系统详细文档,涵盖核心概念和使用方法 - 实现 DataRepository 类提供统一数据持久化接口 - 添加 DataRepositoryOptions 配置选项支持备份和事件功能 - 实现完整的数据仓库测试用例验证持久化行为 - 支持多槽位存档管理和版本化数据迁移功能 - 提供批量数据操作和事件通知机制 - 实现自动备份功能防止数据丢失 - 支持聚合设置仓库统一管理多个配置项 --- .../Data/DataRepositoryOptions.cs | 35 +- .../Data/PersistenceTests.cs | 272 ++++++++++++++ .../Setting/SettingsSystemTests.cs | 347 ++++++++++++++++++ GFramework.Game/Data/DataRepository.cs | 90 ++++- .../Data/UnifiedSettingsDataRepository.cs | 91 +++-- docs/zh-CN/game/data.md | 37 ++ 6 files changed, 819 insertions(+), 53 deletions(-) create mode 100644 GFramework.Game.Tests/Setting/SettingsSystemTests.cs diff --git a/GFramework.Game.Abstractions/Data/DataRepositoryOptions.cs b/GFramework.Game.Abstractions/Data/DataRepositoryOptions.cs index e0774650..040e4ab1 100644 --- a/GFramework.Game.Abstractions/Data/DataRepositoryOptions.cs +++ b/GFramework.Game.Abstractions/Data/DataRepositoryOptions.cs @@ -14,22 +14,47 @@ namespace GFramework.Game.Abstractions.Data; /// -/// 数据仓库配置选项 +/// 数据仓库配置选项。 /// +/// +/// 该选项描述的是仓库层的公开行为契约,而不是某一种固定的落盘格式。 +/// 因此不同实现可以分别使用“每项单文件”或“统一聚合文件”存储,只要对外遵守同一套备份与事件语义: +/// +/// +/// +/// 在覆盖已有持久化数据前保留上一份可恢复快照。对于聚合型仓库,备份粒度是整个统一文件。 +/// +/// +/// +/// +/// 仅控制公开仓库操作产生的事件;内部缓存预热、迁移回写或批量保存中的子步骤不会额外发出单项事件。 +/// +/// +/// +/// public class DataRepositoryOptions { /// - /// 存储基础路径(如 "user://data/") + /// 获取或设置仓库使用的基础存储路径。 /// + /// + /// 具体实现会在该路径下组织自己的键空间。调用方应将其视为仓库级根目录,而不是具体文件名。 + /// public string BasePath { get; set; } = ""; /// - /// 是否在保存时自动备份 + /// 获取或设置是否在覆盖已有持久化数据前自动创建备份。 /// + /// + /// 该选项只影响覆盖写入;首次写入不会生成备份。聚合型仓库会为统一文件创建单份备份,而不是为内部 section 分别备份。 + /// public bool AutoBackup { get; set; } = false; /// - /// 是否启用加载/保存事件 + /// 获取或设置是否启用仓库层加载、保存、删除与批量保存事件。 /// + /// + /// 当该值为 时,SaveAllAsync 只会发出批量事件,不会重复发出每个条目的单项保存事件。 + /// public bool EnableEvents { get; set; } = true; -} \ No newline at end of file +} diff --git a/GFramework.Game.Tests/Data/PersistenceTests.cs b/GFramework.Game.Tests/Data/PersistenceTests.cs index ef2a5e85..74f1e2e2 100644 --- a/GFramework.Game.Tests/Data/PersistenceTests.cs +++ b/GFramework.Game.Tests/Data/PersistenceTests.cs @@ -1,5 +1,11 @@ using System.IO; +using GFramework.Core.Abstractions.Events; +using GFramework.Core.Abstractions.Rule; +using GFramework.Core.Architectures; +using GFramework.Core.Events; +using GFramework.Core.Ioc; using GFramework.Game.Abstractions.Data; +using GFramework.Game.Abstractions.Data.Events; using GFramework.Game.Data; using GFramework.Game.Serializer; using GFramework.Game.Storage; @@ -219,6 +225,272 @@ public class PersistenceTests Assert.That(all[location.Key], Is.TypeOf()); } + /// + /// 验证通用数据仓库在覆盖已有数据时会创建备份文件,并保留覆盖前的旧值。 + /// + /// 表示异步测试完成的任务。 + [Test] + public async Task DataRepository_SaveAsync_Should_Create_Backup_When_Overwriting_Existing_Data() + { + var root = CreateTempRoot(); + using var storage = new FileStorage(root, new JsonSerializer(), ".json"); + var repository = new DataRepository( + storage, + new DataRepositoryOptions + { + AutoBackup = true, + EnableEvents = false + }); + var location = new TestDataLocation("options", namespaceValue: "profile"); + + await repository.SaveAsync(location, new TestSimpleData { Value = 1 }); + await repository.SaveAsync(location, new TestSimpleData { Value = 2 }); + + var current = await repository.LoadAsync(location); + var backup = await storage.ReadAsync("profile/options.backup"); + + Assert.Multiple(() => + { + Assert.That(current.Value, Is.EqualTo(2)); + Assert.That(backup.Value, Is.EqualTo(1)); + }); + } + + /// + /// 验证通用数据仓库的批量保存只发送批量事件,不重复发送单项保存事件。 + /// + /// 表示异步测试完成的任务。 + [Test] + public async Task DataRepository_SaveAllAsync_Should_Emit_Only_Batch_Event() + { + var root = CreateTempRoot(); + using var storage = new FileStorage(root, new JsonSerializer(), ".json"); + var repository = new DataRepository( + storage, + new DataRepositoryOptions + { + AutoBackup = false, + EnableEvents = true + }); + var context = CreateEventContext(); + ((IContextAware)repository).SetContext(context); + + var location1 = new TestDataLocation("graphics", namespaceValue: "settings"); + var location2 = new TestDataLocation("audio", namespaceValue: "settings"); + var savedEventCount = 0; + var batchEventCount = 0; + + context.RegisterEvent>(_ => savedEventCount++); + context.RegisterEvent(_ => batchEventCount++); + + await repository.SaveAllAsync( + [ + (location1, (IData)new TestSimpleData { Value = 10 }), + (location2, (IData)new TestSimpleData { Value = 20 }) + ]); + + Assert.Multiple(() => + { + Assert.That(savedEventCount, Is.Zero); + Assert.That(batchEventCount, Is.EqualTo(1)); + }); + } + + /// + /// 验证统一设置仓库在批量覆盖时会为整个聚合文件创建备份,并只发送批量事件。 + /// + /// 表示异步测试完成的任务。 + [Test] + public async Task UnifiedSettingsDataRepository_SaveAllAsync_Should_Create_Backup_And_Emit_Only_Batch_Event() + { + var root = CreateTempRoot(); + var location1 = new TestDataLocation("settings/graphics"); + var location2 = new TestDataLocation("settings/audio"); + + using (var seedStorage = new FileStorage(root, new JsonSerializer(), ".json")) + { + var seedRepository = new UnifiedSettingsDataRepository( + seedStorage, + new JsonSerializer(), + new DataRepositoryOptions + { + AutoBackup = true, + EnableEvents = false + }, + "settings.json"); + seedRepository.RegisterDataType(location1, typeof(TestSimpleData)); + seedRepository.RegisterDataType(location2, typeof(TestSimpleData)); + + await seedRepository.SaveAsync(location1, new TestSimpleData { Value = 1 }); + } + + using var storage = new FileStorage(root, new JsonSerializer(), ".json"); + var repository = new UnifiedSettingsDataRepository( + storage, + new JsonSerializer(), + new DataRepositoryOptions + { + AutoBackup = true, + EnableEvents = true + }, + "settings.json"); + repository.RegisterDataType(location1, typeof(TestSimpleData)); + repository.RegisterDataType(location2, typeof(TestSimpleData)); + + var context = CreateEventContext(); + ((IContextAware)repository).SetContext(context); + + var savedEventCount = 0; + var batchEventCount = 0; + + context.RegisterEvent>(_ => savedEventCount++); + context.RegisterEvent(_ => batchEventCount++); + + await repository.SaveAllAsync( + [ + (location1, (IData)new TestSimpleData { Value = 2 }), + (location2, (IData)new TestSimpleData { Value = 3 }) + ]); + + var current = await repository.LoadAsync(location1); + var backupJson = File.ReadAllText(Path.Combine(root, "settings.json.backup.json")); + + Assert.Multiple(() => + { + Assert.That(current.Value, Is.EqualTo(2)); + Assert.That(savedEventCount, Is.Zero); + Assert.That(batchEventCount, Is.EqualTo(1)); + Assert.That(backupJson, Does.Contain("settings/graphics")); + Assert.That(backupJson, Does.Contain("\\\"Value\\\":1")); + }); + } + + /// + /// 验证统一设置仓库在删除某个 section 时会回写聚合文件,并保留删除前的统一文件备份。 + /// + /// 表示异步测试完成的任务。 + [Test] + public async Task UnifiedSettingsDataRepository_DeleteAsync_Should_Persist_Deletion_And_Create_Backup() + { + var root = CreateTempRoot(); + var location1 = new TestDataLocation("settings/graphics"); + var location2 = new TestDataLocation("settings/audio"); + + using (var storage = new FileStorage(root, new JsonSerializer(), ".json")) + { + var repository = new UnifiedSettingsDataRepository( + storage, + new JsonSerializer(), + new DataRepositoryOptions + { + AutoBackup = true, + EnableEvents = false + }, + "settings.json"); + repository.RegisterDataType(location1, typeof(TestSimpleData)); + repository.RegisterDataType(location2, typeof(TestSimpleData)); + + await repository.SaveAllAsync( + [ + (location1, (IData)new TestSimpleData { Value = 7 }), + (location2, (IData)new TestSimpleData { Value = 11 }) + ]); + } + + using var verifyStorage = new FileStorage(root, new JsonSerializer(), ".json"); + var verifyRepository = new UnifiedSettingsDataRepository( + verifyStorage, + new JsonSerializer(), + new DataRepositoryOptions + { + AutoBackup = true, + EnableEvents = false + }, + "settings.json"); + verifyRepository.RegisterDataType(location1, typeof(TestSimpleData)); + verifyRepository.RegisterDataType(location2, typeof(TestSimpleData)); + + await verifyRepository.DeleteAsync(location2); + + var remaining = await verifyRepository.LoadAsync(location1); + var removedExists = await verifyRepository.ExistsAsync(location2); + var backupJson = File.ReadAllText(Path.Combine(root, "settings.json.backup.json")); + + Assert.Multiple(() => + { + Assert.That(remaining.Value, Is.EqualTo(7)); + Assert.That(removedExists, Is.False); + Assert.That(backupJson, Does.Contain("settings/audio")); + Assert.That(backupJson, Does.Contain("\\\"Value\\\":11")); + }); + } + + /// + /// 验证统一设置仓库在启用事件时,只为显式仓库操作发送加载、保存、批量保存和删除事件。 + /// + /// 表示异步测试完成的任务。 + [Test] + public async Task UnifiedSettingsDataRepository_WithEvents_Should_Emit_Only_Public_Operation_Events() + { + var root = CreateTempRoot(); + using var storage = new FileStorage(root, new JsonSerializer(), ".json"); + var repository = new UnifiedSettingsDataRepository( + storage, + new JsonSerializer(), + new DataRepositoryOptions + { + AutoBackup = true, + EnableEvents = true + }, + "settings.json"); + var context = CreateEventContext(); + ((IContextAware)repository).SetContext(context); + + var location1 = new TestDataLocation("settings/graphics"); + var location2 = new TestDataLocation("settings/audio"); + repository.RegisterDataType(location1, typeof(TestSimpleData)); + repository.RegisterDataType(location2, typeof(TestSimpleData)); + + var loadedEventCount = 0; + var savedEventCount = 0; + var batchEventCount = 0; + var deletedEventCount = 0; + + context.RegisterEvent>(_ => loadedEventCount++); + context.RegisterEvent>(_ => savedEventCount++); + context.RegisterEvent(_ => batchEventCount++); + context.RegisterEvent(_ => deletedEventCount++); + + _ = await repository.LoadAsync(location1); + await repository.SaveAsync(location1, new TestSimpleData { Value = 5 }); + await repository.SaveAllAsync( + [ + (location1, (IData)new TestSimpleData { Value = 6 }), + (location2, (IData)new TestSimpleData { Value = 7 }) + ]); + await repository.DeleteAsync(location2); + + Assert.Multiple(() => + { + Assert.That(loadedEventCount, Is.EqualTo(1)); + Assert.That(savedEventCount, Is.EqualTo(1)); + Assert.That(batchEventCount, Is.EqualTo(1)); + Assert.That(deletedEventCount, Is.EqualTo(1)); + }); + } + + /// + /// 创建带事件总线的真实架构上下文,供上下文感知仓库测试使用。 + /// + /// 可用于发送和监听事件的架构上下文。 + private static ArchitectureContext CreateEventContext() + { + var container = new MicrosoftDiContainer(); + container.Register(new EventBus()); + container.Freeze(); + return new ArchitectureContext(container); + } + private sealed class TestSaveMigrationV1ToV2 : ISaveMigration { public int FromVersion => 1; diff --git a/GFramework.Game.Tests/Setting/SettingsSystemTests.cs b/GFramework.Game.Tests/Setting/SettingsSystemTests.cs new file mode 100644 index 00000000..4a2786a5 --- /dev/null +++ b/GFramework.Game.Tests/Setting/SettingsSystemTests.cs @@ -0,0 +1,347 @@ +using GFramework.Core.Abstractions.Architectures; +using GFramework.Core.Abstractions.Enums; +using GFramework.Core.Abstractions.Events; +using GFramework.Core.Abstractions.Rule; +using GFramework.Core.Architectures; +using GFramework.Core.Events; +using GFramework.Core.Ioc; +using GFramework.Game.Abstractions.Setting; +using GFramework.Game.Setting; +using GFramework.Game.Setting.Events; + +namespace GFramework.Game.Tests.Setting; + +/// +/// 覆盖 的系统层语义,确保系统对模型编排、事件发送和重置流程保持稳定。 +/// +[TestFixture] +public sealed class SettingsSystemTests +{ + /// + /// 验证 会尝试应用全部 applicator,并为成功与失败结果分别发送事件。 + /// + /// 表示异步测试完成的任务。 + [Test] + public async Task ApplyAll_Should_Apply_All_Applicators_And_Publish_Result_Events() + { + var successfulApplicator = new PrimaryTestSettings(); + var failingApplicator = new SecondaryTestSettings(throwOnApply: true); + var model = new FakeSettingsModel(successfulApplicator, failingApplicator); + var context = CreateContext(model); + var system = CreateSystem(context); + + var applyingEventCount = 0; + var appliedEventCount = 0; + var failedEventCount = 0; + + context.RegisterEvent>(_ => applyingEventCount++); + context.RegisterEvent>(eventData => + { + appliedEventCount++; + if (!eventData.Success) + { + failedEventCount++; + } + }); + + await system.ApplyAll(); + + Assert.Multiple(() => + { + Assert.That(successfulApplicator.ApplyCount, Is.EqualTo(1)); + Assert.That(failingApplicator.ApplyCount, Is.EqualTo(1)); + Assert.That(applyingEventCount, Is.EqualTo(2)); + Assert.That(appliedEventCount, Is.EqualTo(2)); + Assert.That(failedEventCount, Is.EqualTo(1)); + }); + } + + /// + /// 验证 会直接委托给模型层统一保存。 + /// + /// 表示异步测试完成的任务。 + [Test] + public async Task SaveAll_Should_Delegate_To_Model() + { + var model = new FakeSettingsModel(new PrimaryTestSettings()); + var system = CreateSystem(CreateContext(model)); + + await system.SaveAll(); + + Assert.That(model.SaveAllCallCount, Is.EqualTo(1)); + } + + /// + /// 验证 会先委托模型统一重置,再重新应用全部 applicator。 + /// + /// 表示异步测试完成的任务。 + [Test] + public async Task ResetAll_Should_Reset_Model_And_Reapply_All_Applicators() + { + var applicator = new PrimaryTestSettings(); + var model = new FakeSettingsModel(applicator); + var system = CreateSystem(CreateContext(model)); + + await system.ResetAll(); + + Assert.Multiple(() => + { + Assert.That(model.ResetAllCallCount, Is.EqualTo(1)); + Assert.That(applicator.ApplyCount, Is.EqualTo(1)); + }); + } + + /// + /// 验证 会重置目标数据类型,并只重新应用对应的 applicator。 + /// + /// 表示异步测试完成的任务。 + [Test] + public async Task Reset_Should_Reset_Target_Data_And_Reapply_Target_Applicator() + { + var applicator = new PrimaryTestSettings(); + var model = new FakeSettingsModel(applicator); + var system = CreateSystem(CreateContext(model)); + + await system.Reset(); + + Assert.Multiple(() => + { + Assert.That(model.ResetTypes, Is.EquivalentTo(new[] { typeof(PrimaryTestSettings) })); + Assert.That(applicator.ApplyCount, Is.EqualTo(1)); + }); + } + + /// + /// 创建带事件总线和设置模型的真实架构上下文。 + /// + /// 测试使用的设置模型。 + /// 可供系统解析依赖与发送事件的上下文。 + private static ArchitectureContext CreateContext(ISettingsModel model) + { + var container = new MicrosoftDiContainer(); + container.Register(new EventBus()); + container.Register(model); + container.Freeze(); + return new ArchitectureContext(container); + } + + /// + /// 创建并初始化绑定到指定上下文的设置系统。 + /// + /// 系统运行所需的架构上下文。 + /// 已完成初始化的设置系统实例。 + private static SettingsSystem CreateSystem(IArchitectureContext context) + { + var system = new SettingsSystem(); + ((IContextAware)system).SetContext(context); + system.Initialize(); + return system; + } + + /// + /// 用于系统层测试的简化设置模型,记录系统对模型的调用行为。 + /// + private sealed class FakeSettingsModel : ISettingsModel + { + private readonly IReadOnlyDictionary _applicators; + + /// + /// 初始化测试模型,并注册参与测试的 applicator 集合。 + /// + /// 测试使用的 applicator。 + public FakeSettingsModel(params IResetApplyAbleSettings[] applicators) + { + _applicators = applicators.ToDictionary(applicator => applicator.GetType()); + } + + /// + /// 获取保存全量设置的调用次数。 + /// + public int SaveAllCallCount { get; private set; } + + /// + /// 获取重置全部设置的调用次数。 + /// + public int ResetAllCallCount { get; private set; } + + /// + /// 获取被请求重置的设置数据类型列表。 + /// + public List ResetTypes { get; } = []; + + /// + public bool IsInitialized => true; + + /// + public void SetContext(IArchitectureContext context) + { + } + + /// + public IArchitectureContext GetContext() + { + throw new NotSupportedException("Fake settings model does not expose a context."); + } + + /// + public void OnArchitecturePhase(ArchitecturePhase phase) + { + } + + /// + public void Initialize() + { + } + + /// + public T GetData() where T : class, ISettingsData, new() + { + return new T(); + } + + /// + public IEnumerable AllData() + { + return []; + } + + /// + public ISettingsModel RegisterApplicator(T applicator) where T : class, IResetApplyAbleSettings + { + return this; + } + + /// + public T? GetApplicator() where T : class, IResetApplyAbleSettings + { + return _applicators.TryGetValue(typeof(T), out var applicator) ? (T)applicator : null; + } + + /// + public IEnumerable AllApplicators() + { + return _applicators.Values; + } + + /// + public ISettingsModel RegisterMigration(ISettingsMigration migration) + { + return this; + } + + /// + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + /// + public Task SaveAllAsync() + { + SaveAllCallCount++; + return Task.CompletedTask; + } + + /// + public Task ApplyAllAsync() + { + return Task.CompletedTask; + } + + /// + public void Reset() where T : class, ISettingsData, new() + { + ResetTypes.Add(typeof(T)); + } + + /// + public void ResetAll() + { + ResetAllCallCount++; + } + } + + /// + /// 为系统层测试提供的最小设置数据实现。 + /// + private abstract class TestSettingsBase : ISettingsData, IResetApplyAbleSettings + { + /// + public int Version { get; set; } = 1; + + /// + public DateTime LastModified { get; } = DateTime.UtcNow; + + /// + /// 获取测试用的数值字段,用于确认重置与加载行为。 + /// + public int Value { get; private set; } + + /// + /// 获取应用操作被调用的次数。 + /// + public int ApplyCount { get; private set; } + + /// + public ISettingsData Data => this; + + /// + public Type DataType => GetType(); + + /// + /// 获取或设置是否在应用时抛出异常。 + /// + protected bool ThrowOnApply { get; init; } + + /// + public void Reset() + { + Value = 0; + } + + /// + public void LoadFrom(ISettingsData source) + { + if (source is TestSettingsBase data) + { + Value = data.Value; + Version = data.Version; + } + } + + /// + public async Task Apply() + { + ApplyCount++; + + await Task.Yield(); + + if (ThrowOnApply) + { + throw new InvalidOperationException("Test applicator failed."); + } + } + } + + /// + /// 代表主设置分支的测试设置对象。 + /// + private sealed class PrimaryTestSettings : TestSettingsBase + { + } + + /// + /// 代表第二个设置分支的测试设置对象,可选择在应用时失败。 + /// + private sealed class SecondaryTestSettings : TestSettingsBase + { + /// + /// 初始化第二个测试设置对象。 + /// + /// 是否在应用时抛出异常。 + public SecondaryTestSettings(bool throwOnApply = false) + { + ThrowOnApply = throwOnApply; + } + } +} diff --git a/GFramework.Game/Data/DataRepository.cs b/GFramework.Game/Data/DataRepository.cs index 5276ec0b..b4820119 100644 --- a/GFramework.Game/Data/DataRepository.cs +++ b/GFramework.Game/Data/DataRepository.cs @@ -11,6 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System.Reflection; using GFramework.Core.Abstractions.Storage; using GFramework.Core.Extensions; using GFramework.Core.Utility; @@ -21,13 +22,17 @@ using GFramework.Game.Extensions; namespace GFramework.Game.Data; /// -/// 数据仓库类,用于管理游戏数据的存储和读取 +/// 数据仓库类,用于管理游戏数据的存储和读取。 /// /// 存储接口实例 /// 数据仓库配置选项 public class DataRepository(IStorage? storage, DataRepositoryOptions? options = null) : AbstractContextUtility, IDataRepository { + private static readonly MethodInfo SaveCoreGenericMethod = + typeof(DataRepository).GetMethod(nameof(SaveCoreAsync), BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Method {nameof(SaveCoreAsync)} not found."); + private readonly DataRepositoryOptions _options = options ?? new DataRepositoryOptions(); private IStorage? _storage = storage; @@ -65,20 +70,7 @@ public class DataRepository(IStorage? storage, DataRepositoryOptions? options = public async Task SaveAsync(IDataLocation location, T data) where T : class, IData { - var key = location.ToStorageKey(); - - // 自动备份 - if (_options.AutoBackup && await Storage.ExistsAsync(key)) - { - var backupKey = $"{key}.backup"; - var existing = await Storage.ReadAsync(key); - await Storage.WriteAsync(backupKey, existing); - } - - await Storage.WriteAsync(key, data); - - if (_options.EnableEvents) - this.SendEvent(new DataSavedEvent(data)); + await SaveCoreAsync(location, data, emitSavedEvent: true); } /// @@ -98,6 +90,12 @@ public class DataRepository(IStorage? storage, DataRepositoryOptions? options = public async Task DeleteAsync(IDataLocation location) { var key = location.ToStorageKey(); + + if (!await Storage.ExistsAsync(key)) + { + return; + } + await Storage.DeleteAsync(key); if (_options.EnableEvents) this.SendEvent(new DataDeletedEvent(location)); @@ -110,7 +108,13 @@ public class DataRepository(IStorage? storage, DataRepositoryOptions? options = public async Task SaveAllAsync(IEnumerable<(IDataLocation location, IData data)> dataList) { var valueTuples = dataList.ToList(); - foreach (var (location, data) in valueTuples) await SaveAsync(location, data); + + // 批量保存对订阅者而言应视为一次显式提交,因此这里复用底层保存逻辑, + // 但抑制逐项 DataSavedEvent,避免监听器对同一批次收到重复语义的事件。 + foreach (var (location, data) in valueTuples) + { + await SaveCoreUntypedAsync(location, data, emitSavedEvent: false); + } if (_options.EnableEvents) this.SendEvent(new DataBatchSavedEvent(valueTuples)); @@ -123,4 +127,56 @@ public class DataRepository(IStorage? storage, DataRepositoryOptions? options = { _storage ??= this.GetUtility()!; } -} \ No newline at end of file + + /// + /// 执行单项保存的共享流程,并根据调用入口决定是否发送单项保存事件。 + /// + /// 数据类型。 + /// 目标数据位置。 + /// 要保存的数据对象。 + /// 是否在成功写入后发送单项保存事件。 + private async Task SaveCoreAsync(IDataLocation location, T data, bool emitSavedEvent) + where T : class, IData + { + var key = location.ToStorageKey(); + + await BackupIfNeededAsync(key); + await Storage.WriteAsync(key, data); + + if (emitSavedEvent && _options.EnableEvents) + { + this.SendEvent(new DataSavedEvent(data)); + } + } + + /// + /// 在覆盖旧值前为当前存储键创建备份。 + /// + /// 即将被覆盖的存储键。 + private async Task BackupIfNeededAsync(string key) + where T : class, IData + { + if (!_options.AutoBackup || !await Storage.ExistsAsync(key)) + { + return; + } + + var backupKey = $"{key}.backup"; + var existing = await Storage.ReadAsync(key); + await Storage.WriteAsync(backupKey, existing); + } + + /// + /// 使用数据对象的运行时类型执行保存流程,避免批量保存时因为编译期类型退化为 而破坏备份反序列化。 + /// + /// 目标数据位置。 + /// 要保存的数据对象。 + /// 是否发送单项保存事件。 + private Task SaveCoreUntypedAsync(IDataLocation location, IData data, bool emitSavedEvent) + { + ArgumentNullException.ThrowIfNull(data); + + var closedMethod = SaveCoreGenericMethod.MakeGenericMethod(data.GetType()); + return (Task)closedMethod.Invoke(this, [location, data, emitSavedEvent])!; + } +} diff --git a/GFramework.Game/Data/UnifiedSettingsDataRepository.cs b/GFramework.Game/Data/UnifiedSettingsDataRepository.cs index 8fd693a7..4950ddf0 100644 --- a/GFramework.Game/Data/UnifiedSettingsDataRepository.cs +++ b/GFramework.Game/Data/UnifiedSettingsDataRepository.cs @@ -21,8 +21,13 @@ using GFramework.Game.Abstractions.Data.Events; namespace GFramework.Game.Data; /// -/// 使用单一文件存储所有设置数据的仓库实现 +/// 使用单一文件存储所有设置数据的仓库实现。 /// +/// +/// 该仓库通过内存缓存聚合所有设置 section,并在公开的保存或删除操作发生时整文件回写。 +/// 虽然底层不是“一项一个文件”,但它仍遵循 定义的统一契约: +/// 启用自动备份时,覆盖写入前会为整个统一文件创建单份备份;批量保存只发出批量事件,不重复发出单项保存事件。 +/// public class UnifiedSettingsDataRepository( IStorage? storage, IRuntimeTypeSerializer? serializer, @@ -66,7 +71,7 @@ public class UnifiedSettingsDataRepository( var key = location.Key; var result = _file!.Sections.TryGetValue(key, out var raw) ? Serializer.Deserialize(raw) : new T(); if (_options.EnableEvents) - this.SendEvent(new DataLoadedEvent(result)); + this.SendEvent(new DataLoadedEvent(result)); return result; } @@ -81,21 +86,11 @@ public class UnifiedSettingsDataRepository( where T : class, IData { await EnsureLoadedAsync(); - await _lock.WaitAsync(); - try - { - var key = location.Key; - var serialized = Serializer.Serialize(data); + await MutateAndPersistAsync(file => file.Sections[location.Key] = Serializer.Serialize(data)); - _file!.Sections[key] = serialized; - - await Storage.WriteAsync(UnifiedKey, _file); - if (_options.EnableEvents) - this.SendEvent(new DataSavedEvent(data)); - } - finally + if (_options.EnableEvents) { - _lock.Release(); + this.SendEvent(new DataSavedEvent(data)); } } @@ -118,13 +113,27 @@ public class UnifiedSettingsDataRepository( public async Task DeleteAsync(IDataLocation location) { await EnsureLoadedAsync(); + var removed = false; - if (File.Sections.Remove(location.Key)) + await _lock.WaitAsync(); + try { - await SaveUnifiedFileAsync(); + removed = File.Sections.Remove(location.Key); + if (!removed) + { + return; + } - if (_options.EnableEvents) - this.SendEvent(new DataDeletedEvent(location)); + await WriteUnifiedFileCoreAsync(); + } + finally + { + _lock.Release(); + } + + if (removed && _options.EnableEvents) + { + this.SendEvent(new DataDeletedEvent(location)); } } @@ -139,16 +148,17 @@ public class UnifiedSettingsDataRepository( await EnsureLoadedAsync(); var valueTuples = dataList.ToList(); - foreach (var (location, data) in valueTuples) - { - var serialized = Serializer.Serialize(data); - File.Sections[location.Key] = serialized; - } - await SaveUnifiedFileAsync(); + await MutateAndPersistAsync(file => + { + foreach (var (location, data) in valueTuples) + { + file.Sections[location.Key] = Serializer.Serialize(data); + } + }); if (_options.EnableEvents) - this.SendEvent(new DataBatchSavedEvent(valueTuples.ToList())); + this.SendEvent(new DataBatchSavedEvent(valueTuples)); } /// @@ -226,12 +236,13 @@ public class UnifiedSettingsDataRepository( /// /// 将缓存中的所有数据保存到统一文件 /// - private async Task SaveUnifiedFileAsync() + private async Task MutateAndPersistAsync(Action mutation) { await _lock.WaitAsync(); try { - await Storage.WriteAsync(UnifiedKey, _file); + mutation(File); + await WriteUnifiedFileCoreAsync(); } finally { @@ -240,11 +251,29 @@ public class UnifiedSettingsDataRepository( } /// - /// 获取统一文件的存储键名 + /// 将当前缓存中的统一文件写回底层存储,并在需要时创建整个文件的备份。 /// - /// 完整的存储键名 + /// + /// 该方法要求调用方已经持有 ,以保证“修改缓存 -> 备份旧文件 -> 写入新文件”观察到的是同一份一致状态。 + /// + private async Task WriteUnifiedFileCoreAsync() + { + if (_options.AutoBackup && await Storage.ExistsAsync(UnifiedKey)) + { + var backupKey = $"{UnifiedKey}.backup"; + var existing = await Storage.ReadAsync(UnifiedKey); + await Storage.WriteAsync(backupKey, existing); + } + + await Storage.WriteAsync(UnifiedKey, File); + } + + /// + /// 获取统一文件的存储键名。 + /// + /// 完整的存储键名。 protected virtual string GetUnifiedKey() { return string.IsNullOrEmpty(_options.BasePath) ? fileName : $"{_options.BasePath}/{fileName}"; } -} \ No newline at end of file +} diff --git a/docs/zh-CN/game/data.md b/docs/zh-CN/game/data.md index 100e30eb..71a5ed28 100644 --- a/docs/zh-CN/game/data.md +++ b/docs/zh-CN/game/data.md @@ -48,6 +48,20 @@ public interface IDataRepository : IUtility } ``` +`IDataRepository` 描述的是“仓库语义”,不是固定的落盘格式。实现可以选择每个数据项独立成文件,也可以把多个 section 聚合到同一个文件里。 + +当前内建实现里: + +- `DataRepository` 采用“每个 location 一份持久化对象”的模型 +- `UnifiedSettingsDataRepository` 采用“所有设置聚合到一个统一文件”的模型 + +两者对外遵守同一套约定: + +- `SaveAllAsync(...)` 视为一次批量提交,只发送 `DataBatchSavedEvent`,不会再为每个条目重复发送 `DataSavedEvent` +- `DeleteAsync(...)` 只有在目标数据真实存在并被删除时才会发送删除事件 +- 当 `DataRepositoryOptions.AutoBackup = true` 时,覆盖已有数据前会先保留上一份快照 +- 对 `UnifiedSettingsDataRepository` 来说,备份粒度是整个统一文件,而不是单个设置 section + ### 存档仓库 `ISaveRepository` 专门用于管理游戏存档: @@ -413,6 +427,29 @@ public async Task SaveAllGameData() } ``` +`SaveAllAsync(...)` 的事件语义和逐项调用 `SaveAsync(...)` 不同。它代表一次显式的批量提交,因此适合让监听器在收到 `DataBatchSavedEvent` 时统一刷新 UI、缓存或元数据,而不是对每个条目单独响应。 + +### 聚合设置仓库 + +如果你希望把设置统一保存到单个文件中,可以使用 `UnifiedSettingsDataRepository`: + +```csharp +var settingsRepo = new UnifiedSettingsDataRepository( + storage, + serializer, + new DataRepositoryOptions + { + AutoBackup = true, + EnableEvents = true + }, + "settings.json"); +``` + +这个实现依然满足 `IDataRepository` 的通用契约,但有两个实现层面的差异需要明确: + +- 它把所有设置 section 缓存在内存中,并在保存或删除时整文件回写 +- 开启自动备份时,备份的是整个 `settings.json` 文件,因此适合做“上一次完整设置快照”的恢复,而不是 section 级别回滚 + ### 存档备份 ```csharp From 60526a8a98936a2d39f55cadbfbed22f6b2f2bfd Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:52:06 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs(game):=20=E6=B7=BB=E5=8A=A0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=8E=E5=AD=98=E6=A1=A3=E7=B3=BB=E7=BB=9F=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=92=8C=E6=8C=81=E4=B9=85=E5=8C=96=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增数据与存档系统完整文档,包含核心概念、基本用法和高级功能 - 实现文件存储、槽位存档仓库和统一设置仓库的持久化行为测试 - 覆盖存档迁移、备份恢复、批量操作等关键功能的测试用例 - 添加数据版本控制和自动备份机制的详细说明 - 提供完整的 API 接口文档和使用示例代码 --- .../Data/PersistenceTestUtilities.cs | 11 + .../Data/PersistenceTests.cs | 310 ++++++++++++++++-- .../Setting/SettingsSystemTests.cs | 16 +- .../Data/UnifiedSettingsDataRepository.cs | 47 ++- docs/zh-CN/game/data.md | 61 +++- 5 files changed, 397 insertions(+), 48 deletions(-) diff --git a/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs b/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs index 00ab134a..08dfb4e1 100644 --- a/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs +++ b/GFramework.Game.Tests/Data/PersistenceTestUtilities.cs @@ -101,3 +101,14 @@ internal sealed class TestSimpleData : IData /// public int Value { get; set; } } + +/// +/// 为批量持久化测试提供的另一种数据模型,用于验证运行时类型不会在接口路径上退化。 +/// +internal sealed class TestNamedData : IData +{ + /// + /// 获取或设置测试数据中的名称值。 + /// + public string Name { get; set; } = string.Empty; +} diff --git a/GFramework.Game.Tests/Data/PersistenceTests.cs b/GFramework.Game.Tests/Data/PersistenceTests.cs index 74f1e2e2..31192a26 100644 --- a/GFramework.Game.Tests/Data/PersistenceTests.cs +++ b/GFramework.Game.Tests/Data/PersistenceTests.cs @@ -1,6 +1,7 @@ using System.IO; using GFramework.Core.Abstractions.Events; using GFramework.Core.Abstractions.Rule; +using GFramework.Core.Abstractions.Storage; using GFramework.Core.Architectures; using GFramework.Core.Events; using GFramework.Core.Ioc; @@ -200,8 +201,7 @@ public class PersistenceTests var repo = new UnifiedSettingsDataRepository( storage, serializer, - new DataRepositoryOptions { EnableEvents = false }, - "settings.json"); + new DataRepositoryOptions { EnableEvents = false }); var location = new TestDataLocation("settings/choice"); repo.RegisterDataType(location, typeof(TestSimpleData)); @@ -213,8 +213,7 @@ public class PersistenceTests var repo2 = new UnifiedSettingsDataRepository( storage2, serializer, - new DataRepositoryOptions { EnableEvents = false }, - "settings.json"); + new DataRepositoryOptions { EnableEvents = false }); repo2.RegisterDataType(location, typeof(TestSimpleData)); var loaded = await repo2.LoadAsync(location); @@ -285,8 +284,8 @@ public class PersistenceTests await repository.SaveAllAsync( [ - (location1, (IData)new TestSimpleData { Value = 10 }), - (location2, (IData)new TestSimpleData { Value = 20 }) + (location1, new TestSimpleData { Value = 10 }), + (location2, new TestSimpleData { Value = 20 }) ]); Assert.Multiple(() => @@ -296,6 +295,51 @@ public class PersistenceTests }); } + /// + /// 验证批量覆盖已有数据时仍会按每个条目的运行时类型执行备份与回写,而不会退化为 。 + /// + /// 表示异步测试完成的任务。 + [Test] + public async Task DataRepository_SaveAllAsync_Should_Preserve_Runtime_Types_When_Overwriting_Existing_Data() + { + var root = CreateTempRoot(); + using var storage = new FileStorage(root, new JsonSerializer(), ".json"); + var repository = new DataRepository( + storage, + new DataRepositoryOptions + { + AutoBackup = true, + EnableEvents = false + }); + var numberLocation = new TestDataLocation("graphics", namespaceValue: "settings"); + var textLocation = new TestDataLocation("profile", namespaceValue: "settings"); + + await repository.SaveAllAsync( + [ + (numberLocation, new TestSimpleData { Value = 1 }), + (textLocation, new TestNamedData { Name = "old-name" }) + ]); + + await repository.SaveAllAsync( + [ + (numberLocation, new TestSimpleData { Value = 2 }), + (textLocation, new TestNamedData { Name = "new-name" }) + ]); + + var currentNumber = await repository.LoadAsync(numberLocation); + var currentText = await repository.LoadAsync(textLocation); + var backupNumber = await storage.ReadAsync("settings/graphics.backup"); + var backupText = await storage.ReadAsync("settings/profile.backup"); + + Assert.Multiple(() => + { + Assert.That(currentNumber.Value, Is.EqualTo(2)); + Assert.That(currentText.Name, Is.EqualTo("new-name")); + Assert.That(backupNumber.Value, Is.EqualTo(1)); + Assert.That(backupText.Name, Is.EqualTo("old-name")); + }); + } + /// /// 验证统一设置仓库在批量覆盖时会为整个聚合文件创建备份,并只发送批量事件。 /// @@ -316,8 +360,7 @@ public class PersistenceTests { AutoBackup = true, EnableEvents = false - }, - "settings.json"); + }); seedRepository.RegisterDataType(location1, typeof(TestSimpleData)); seedRepository.RegisterDataType(location2, typeof(TestSimpleData)); @@ -332,8 +375,7 @@ public class PersistenceTests { AutoBackup = true, EnableEvents = true - }, - "settings.json"); + }); repository.RegisterDataType(location1, typeof(TestSimpleData)); repository.RegisterDataType(location2, typeof(TestSimpleData)); @@ -348,12 +390,12 @@ public class PersistenceTests await repository.SaveAllAsync( [ - (location1, (IData)new TestSimpleData { Value = 2 }), - (location2, (IData)new TestSimpleData { Value = 3 }) + (location1, new TestSimpleData { Value = 2 }), + (location2, new TestSimpleData { Value = 3 }) ]); var current = await repository.LoadAsync(location1); - var backupJson = File.ReadAllText(Path.Combine(root, "settings.json.backup.json")); + var backupJson = await File.ReadAllTextAsync(Path.Combine(root, "settings.json.backup.json")); Assert.Multiple(() => { @@ -385,15 +427,14 @@ public class PersistenceTests { AutoBackup = true, EnableEvents = false - }, - "settings.json"); + }); repository.RegisterDataType(location1, typeof(TestSimpleData)); repository.RegisterDataType(location2, typeof(TestSimpleData)); await repository.SaveAllAsync( [ - (location1, (IData)new TestSimpleData { Value = 7 }), - (location2, (IData)new TestSimpleData { Value = 11 }) + (location1, new TestSimpleData { Value = 7 }), + (location2, new TestSimpleData { Value = 11 }) ]); } @@ -405,8 +446,7 @@ public class PersistenceTests { AutoBackup = true, EnableEvents = false - }, - "settings.json"); + }); verifyRepository.RegisterDataType(location1, typeof(TestSimpleData)); verifyRepository.RegisterDataType(location2, typeof(TestSimpleData)); @@ -414,7 +454,7 @@ public class PersistenceTests var remaining = await verifyRepository.LoadAsync(location1); var removedExists = await verifyRepository.ExistsAsync(location2); - var backupJson = File.ReadAllText(Path.Combine(root, "settings.json.backup.json")); + var backupJson = await File.ReadAllTextAsync(Path.Combine(root, "settings.json.backup.json")); Assert.Multiple(() => { @@ -425,6 +465,126 @@ public class PersistenceTests }); } + /// + /// 验证统一设置仓库在保存提交失败时不会污染内存缓存,并且失败修改不会泄漏到后续无关保存。 + /// + /// 表示异步测试完成的任务。 + [Test] + public async Task UnifiedSettingsDataRepository_SaveAsync_When_Persist_Fails_Should_Keep_Cache_Consistent() + { + var root = CreateTempRoot(); + var primaryLocation = new TestDataLocation("settings/graphics"); + var secondaryLocation = new TestDataLocation("settings/audio"); + + using (var seedStorage = new FileStorage(root, new JsonSerializer(), ".json")) + { + var seedRepository = new UnifiedSettingsDataRepository( + seedStorage, + new JsonSerializer(), + new DataRepositoryOptions { EnableEvents = false }); + seedRepository.RegisterDataType(primaryLocation, typeof(TestSimpleData)); + seedRepository.RegisterDataType(secondaryLocation, typeof(TestSimpleData)); + await seedRepository.SaveAsync(primaryLocation, new TestSimpleData { Value = 1 }); + } + + using var innerStorage = new FileStorage(root, new JsonSerializer(), ".json"); + var throwingStorage = new ToggleWriteFailureStorage(innerStorage, "settings.json"); + var repository = new UnifiedSettingsDataRepository( + throwingStorage, + new JsonSerializer(), + new DataRepositoryOptions { EnableEvents = false }); + repository.RegisterDataType(primaryLocation, typeof(TestSimpleData)); + repository.RegisterDataType(secondaryLocation, typeof(TestSimpleData)); + + throwingStorage.ThrowOnWrite = true; + Assert.ThrowsAsync( + async () => await repository.SaveAsync(primaryLocation, new TestSimpleData { Value = 99 })); + + var cachedAfterFailure = await repository.LoadAsync(primaryLocation); + Assert.That(cachedAfterFailure.Value, Is.EqualTo(1)); + + throwingStorage.ThrowOnWrite = false; + await repository.SaveAsync(secondaryLocation, new TestSimpleData { Value = 7 }); + + using var verifyStorage = new FileStorage(root, new JsonSerializer(), ".json"); + var verifyRepository = new UnifiedSettingsDataRepository( + verifyStorage, + new JsonSerializer(), + new DataRepositoryOptions { EnableEvents = false }); + verifyRepository.RegisterDataType(primaryLocation, typeof(TestSimpleData)); + verifyRepository.RegisterDataType(secondaryLocation, typeof(TestSimpleData)); + + var persistedPrimary = await verifyRepository.LoadAsync(primaryLocation); + var persistedSecondary = await verifyRepository.LoadAsync(secondaryLocation); + + Assert.Multiple(() => + { + Assert.That(persistedPrimary.Value, Is.EqualTo(1)); + Assert.That(persistedSecondary.Value, Is.EqualTo(7)); + }); + } + + /// + /// 验证统一设置仓库在删除提交失败时不会把未提交删除留在缓存里,也不会泄漏到后续保存。 + /// + /// 表示异步测试完成的任务。 + [Test] + public async Task UnifiedSettingsDataRepository_DeleteAsync_When_Persist_Fails_Should_Keep_Cache_Consistent() + { + var root = CreateTempRoot(); + var primaryLocation = new TestDataLocation("settings/graphics"); + var secondaryLocation = new TestDataLocation("settings/audio"); + + using (var seedStorage = new FileStorage(root, new JsonSerializer(), ".json")) + { + var seedRepository = new UnifiedSettingsDataRepository( + seedStorage, + new JsonSerializer(), + new DataRepositoryOptions { EnableEvents = false }); + seedRepository.RegisterDataType(primaryLocation, typeof(TestSimpleData)); + seedRepository.RegisterDataType(secondaryLocation, typeof(TestSimpleData)); + await seedRepository.SaveAllAsync( + [ + (primaryLocation, new TestSimpleData { Value = 3 }), + (secondaryLocation, new TestSimpleData { Value = 5 }) + ]); + } + + using var innerStorage = new FileStorage(root, new JsonSerializer(), ".json"); + var throwingStorage = new ToggleWriteFailureStorage(innerStorage, "settings.json"); + var repository = new UnifiedSettingsDataRepository( + throwingStorage, + new JsonSerializer(), + new DataRepositoryOptions { EnableEvents = false }); + repository.RegisterDataType(primaryLocation, typeof(TestSimpleData)); + repository.RegisterDataType(secondaryLocation, typeof(TestSimpleData)); + + throwingStorage.ThrowOnWrite = true; + Assert.ThrowsAsync(async () => await repository.DeleteAsync(secondaryLocation)); + + Assert.That(await repository.ExistsAsync(secondaryLocation), Is.True); + + throwingStorage.ThrowOnWrite = false; + await repository.SaveAsync(primaryLocation, new TestSimpleData { Value = 9 }); + + using var verifyStorage = new FileStorage(root, new JsonSerializer(), ".json"); + var verifyRepository = new UnifiedSettingsDataRepository( + verifyStorage, + new JsonSerializer(), + new DataRepositoryOptions { EnableEvents = false }); + verifyRepository.RegisterDataType(primaryLocation, typeof(TestSimpleData)); + verifyRepository.RegisterDataType(secondaryLocation, typeof(TestSimpleData)); + + var persistedPrimary = await verifyRepository.LoadAsync(primaryLocation); + var persistedSecondary = await verifyRepository.LoadAsync(secondaryLocation); + + Assert.Multiple(() => + { + Assert.That(persistedPrimary.Value, Is.EqualTo(9)); + Assert.That(persistedSecondary.Value, Is.EqualTo(5)); + }); + } + /// /// 验证统一设置仓库在启用事件时,只为显式仓库操作发送加载、保存、批量保存和删除事件。 /// @@ -441,8 +601,7 @@ public class PersistenceTests { AutoBackup = true, EnableEvents = true - }, - "settings.json"); + }); var context = CreateEventContext(); ((IContextAware)repository).SetContext(context); @@ -465,8 +624,8 @@ public class PersistenceTests await repository.SaveAsync(location1, new TestSimpleData { Value = 5 }); await repository.SaveAllAsync( [ - (location1, (IData)new TestSimpleData { Value = 6 }), - (location2, (IData)new TestSimpleData { Value = 7 }) + (location1, new TestSimpleData { Value = 6 }), + (location2, new TestSimpleData { Value = 7 }) ]); await repository.DeleteAsync(location2); @@ -562,4 +721,107 @@ public class PersistenceTests }; } } + + /// + /// 为统一设置仓库失败场景测试提供可切换的写入失败包装器。 + /// + private sealed class ToggleWriteFailureStorage(IStorage innerStorage, string failingKey) : IStorage + { + /// + /// 获取或设置是否在目标键写入时主动抛出异常。 + /// + public bool ThrowOnWrite { get; set; } + + /// + public bool Exists(string key) + { + return innerStorage.Exists(key); + } + + /// + public Task ExistsAsync(string key) + { + return innerStorage.ExistsAsync(key); + } + + /// + public T Read(string key) + { + return innerStorage.Read(key); + } + + /// + public T Read(string key, T defaultValue) + { + return innerStorage.Read(key, defaultValue); + } + + /// + public Task ReadAsync(string key) + { + return innerStorage.ReadAsync(key); + } + + /// + public void Write(string key, T value) + { + ThrowIfNeeded(key); + innerStorage.Write(key, value); + } + + /// + public Task WriteAsync(string key, T value) + { + ThrowIfNeeded(key); + return innerStorage.WriteAsync(key, value); + } + + /// + public void Delete(string key) + { + innerStorage.Delete(key); + } + + /// + public Task DeleteAsync(string key) + { + return innerStorage.DeleteAsync(key); + } + + /// + public Task> ListDirectoriesAsync(string path = "") + { + return innerStorage.ListDirectoriesAsync(path); + } + + /// + public Task> ListFilesAsync(string path = "") + { + return innerStorage.ListFilesAsync(path); + } + + /// + public Task DirectoryExistsAsync(string path) + { + return innerStorage.DirectoryExistsAsync(path); + } + + /// + public Task CreateDirectoryAsync(string path) + { + return innerStorage.CreateDirectoryAsync(path); + } + + /// + /// 在启用失败开关且命中目标键时抛出一致的写入失败异常。 + /// + /// 当前正在写入的存储键。 + private void ThrowIfNeeded(string key) + { + if (ThrowOnWrite && string.Equals(key, failingKey, StringComparison.Ordinal)) + { + throw new InvalidOperationException("Simulated unified settings write failure."); + } + } + } } diff --git a/GFramework.Game.Tests/Setting/SettingsSystemTests.cs b/GFramework.Game.Tests/Setting/SettingsSystemTests.cs index 4a2786a5..76fe04c5 100644 --- a/GFramework.Game.Tests/Setting/SettingsSystemTests.cs +++ b/GFramework.Game.Tests/Setting/SettingsSystemTests.cs @@ -78,8 +78,9 @@ public sealed class SettingsSystemTests [Test] public async Task ResetAll_Should_Reset_Model_And_Reapply_All_Applicators() { - var applicator = new PrimaryTestSettings(); - var model = new FakeSettingsModel(applicator); + var primaryApplicator = new PrimaryTestSettings(); + var secondaryApplicator = new SecondaryTestSettings(); + var model = new FakeSettingsModel(primaryApplicator, secondaryApplicator); var system = CreateSystem(CreateContext(model)); await system.ResetAll(); @@ -87,7 +88,8 @@ public sealed class SettingsSystemTests Assert.Multiple(() => { Assert.That(model.ResetAllCallCount, Is.EqualTo(1)); - Assert.That(applicator.ApplyCount, Is.EqualTo(1)); + Assert.That(primaryApplicator.ApplyCount, Is.EqualTo(1)); + Assert.That(secondaryApplicator.ApplyCount, Is.EqualTo(1)); }); } @@ -98,8 +100,9 @@ public sealed class SettingsSystemTests [Test] public async Task Reset_Should_Reset_Target_Data_And_Reapply_Target_Applicator() { - var applicator = new PrimaryTestSettings(); - var model = new FakeSettingsModel(applicator); + var primaryApplicator = new PrimaryTestSettings(); + var secondaryApplicator = new SecondaryTestSettings(); + var model = new FakeSettingsModel(primaryApplicator, secondaryApplicator); var system = CreateSystem(CreateContext(model)); await system.Reset(); @@ -107,7 +110,8 @@ public sealed class SettingsSystemTests Assert.Multiple(() => { Assert.That(model.ResetTypes, Is.EquivalentTo(new[] { typeof(PrimaryTestSettings) })); - Assert.That(applicator.ApplyCount, Is.EqualTo(1)); + Assert.That(primaryApplicator.ApplyCount, Is.EqualTo(1)); + Assert.That(secondaryApplicator.ApplyCount, Is.Zero); }); } diff --git a/GFramework.Game/Data/UnifiedSettingsDataRepository.cs b/GFramework.Game/Data/UnifiedSettingsDataRepository.cs index 4950ddf0..1cd13eb1 100644 --- a/GFramework.Game/Data/UnifiedSettingsDataRepository.cs +++ b/GFramework.Game/Data/UnifiedSettingsDataRepository.cs @@ -118,13 +118,16 @@ public class UnifiedSettingsDataRepository( await _lock.WaitAsync(); try { - removed = File.Sections.Remove(location.Key); + var currentFile = File; + var nextFile = CloneFile(currentFile); + removed = nextFile.Sections.Remove(location.Key); if (!removed) { return; } - await WriteUnifiedFileCoreAsync(); + await WriteUnifiedFileCoreAsync(currentFile, nextFile); + _file = nextFile; } finally { @@ -241,8 +244,14 @@ public class UnifiedSettingsDataRepository( await _lock.WaitAsync(); try { - mutation(File); - await WriteUnifiedFileCoreAsync(); + var currentFile = File; + var nextFile = CloneFile(currentFile); + + // 先在副本上计算“下一份已提交状态”,只有底层持久化成功后才交换缓存, + // 这样即使备份或写入失败,也不会把未提交修改留在内存快照里。 + mutation(nextFile); + await WriteUnifiedFileCoreAsync(currentFile, nextFile); + _file = nextFile; } finally { @@ -251,21 +260,39 @@ public class UnifiedSettingsDataRepository( } /// - /// 将当前缓存中的统一文件写回底层存储,并在需要时创建整个文件的备份。 + /// 将当前缓存快照写回底层存储,并在需要时创建整个文件的备份。 /// /// - /// 该方法要求调用方已经持有 ,以保证“修改缓存 -> 备份旧文件 -> 写入新文件”观察到的是同一份一致状态。 + /// 该方法要求调用方已经持有 ,以保证“读取当前快照 -> 写入备份 -> 提交新快照”的原子提交顺序。 + /// 只有在该方法成功返回后,调用方才应交换内存中的 引用。 /// - private async Task WriteUnifiedFileCoreAsync() + /// 当前已提交的统一文件快照。 + /// 即将提交的新统一文件快照。 + private async Task WriteUnifiedFileCoreAsync(UnifiedSettingsFile currentFile, UnifiedSettingsFile nextFile) { if (_options.AutoBackup && await Storage.ExistsAsync(UnifiedKey)) { var backupKey = $"{UnifiedKey}.backup"; - var existing = await Storage.ReadAsync(UnifiedKey); - await Storage.WriteAsync(backupKey, existing); + await Storage.WriteAsync(backupKey, currentFile); } - await Storage.WriteAsync(UnifiedKey, File); + await Storage.WriteAsync(UnifiedKey, nextFile); + } + + /// + /// 复制当前统一文件快照,确保未提交修改不会污染内存中的已提交状态。 + /// + /// 要复制的统一文件快照。 + /// 包含独立 section 字典的新快照。 + private static UnifiedSettingsFile CloneFile(UnifiedSettingsFile source) + { + ArgumentNullException.ThrowIfNull(source); + + return new UnifiedSettingsFile + { + Version = source.Version, + Sections = new Dictionary(source.Sections, source.Sections.Comparer) + }; } /// diff --git a/docs/zh-CN/game/data.md b/docs/zh-CN/game/data.md index 71a5ed28..cd4b96e8 100644 --- a/docs/zh-CN/game/data.md +++ b/docs/zh-CN/game/data.md @@ -434,15 +434,32 @@ public async Task SaveAllGameData() 如果你希望把设置统一保存到单个文件中,可以使用 `UnifiedSettingsDataRepository`: ```csharp -var settingsRepo = new UnifiedSettingsDataRepository( - storage, - serializer, - new DataRepositoryOptions +using GFramework.Game.Data; +using GFramework.Game.Serializer; + +public sealed class GameArchitecture : Architecture +{ + protected override void Init() { - AutoBackup = true, - EnableEvents = true - }, - "settings.json"); + var storage = this.GetUtility(); + var serializer = new JsonSerializer(); + + 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` 的通用契约,但有两个实现层面的差异需要明确: @@ -450,6 +467,34 @@ var settingsRepo = new UnifiedSettingsDataRepository( - 它把所有设置 section 缓存在内存中,并在保存或删除时整文件回写 - 开启自动备份时,备份的是整个 `settings.json` 文件,因此适合做“上一次完整设置快照”的恢复,而不是 section 级别回滚 +如果你需要 `LoadAllAsync()`,或者希望在同一个统一文件里混合反序列化多个 section,必须先为每个 section 注册类型: + +```csharp +public async Task PrintSettingsSnapshot() +{ + 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}"); +} +``` + +最小采用要求: + +- 项目需要可用的 `IStorage` +- 项目需要一个可用的序列化器实例,例如 `GFramework.Game.Serializer.JsonSerializer` +- 在注册仓库时,把所有需要参与 `LoadAllAsync()` 或混合 section 反序列化的 location/type 对显式调用一次 `RegisterDataType(...)` + +兼容性说明: + +- 现在 `UnifiedSettingsDataRepository.LoadAsync()` 发送的是 `DataLoadedEvent`,而不是 `DataLoadedEvent` +- 如果你之前监听的是 `DataLoadedEvent`,需要改成订阅具体类型,例如 `DataLoadedEvent` 或 `DataLoadedEvent` + ### 存档备份 ```csharp