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