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] =?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