mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-06 16:16:44 +08:00
docs(data): 添加数据与存档系统文档并实现数据仓库功能
- 新增数据与存档系统详细文档,涵盖核心概念和使用方法 - 实现 DataRepository 类提供统一数据持久化接口 - 添加 DataRepositoryOptions 配置选项支持备份和事件功能 - 实现完整的数据仓库测试用例验证持久化行为 - 支持多槽位存档管理和版本化数据迁移功能 - 提供批量数据操作和事件通知机制 - 实现自动备份功能防止数据丢失 - 支持聚合设置仓库统一管理多个配置项
This commit is contained in:
parent
fb3bf49a12
commit
7114a76377
@ -14,22 +14,47 @@
|
||||
namespace GFramework.Game.Abstractions.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 数据仓库配置选项
|
||||
/// 数据仓库配置选项。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该选项描述的是仓库层的公开行为契约,而不是某一种固定的落盘格式。
|
||||
/// 因此不同实现可以分别使用“每项单文件”或“统一聚合文件”存储,只要对外遵守同一套备份与事件语义:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <see cref="AutoBackup" /> 在覆盖已有持久化数据前保留上一份可恢复快照。对于聚合型仓库,备份粒度是整个统一文件。
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <see cref="EnableEvents" /> 仅控制公开仓库操作产生的事件;内部缓存预热、迁移回写或批量保存中的子步骤不会额外发出单项事件。
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public class DataRepositoryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 存储基础路径(如 "user://data/")
|
||||
/// 获取或设置仓库使用的基础存储路径。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 具体实现会在该路径下组织自己的键空间。调用方应将其视为仓库级根目录,而不是具体文件名。
|
||||
/// </remarks>
|
||||
public string BasePath { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 是否在保存时自动备份
|
||||
/// 获取或设置是否在覆盖已有持久化数据前自动创建备份。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该选项只影响覆盖写入;首次写入不会生成备份。聚合型仓库会为统一文件创建单份备份,而不是为内部 section 分别备份。
|
||||
/// </remarks>
|
||||
public bool AutoBackup { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用加载/保存事件
|
||||
/// 获取或设置是否启用仓库层加载、保存、删除与批量保存事件。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 当该值为 <see langword="true" /> 时,<c>SaveAllAsync</c> 只会发出批量事件,不会重复发出每个条目的单项保存事件。
|
||||
/// </remarks>
|
||||
public bool EnableEvents { get; set; } = true;
|
||||
}
|
||||
@ -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<TestSimpleData>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证通用数据仓库在覆盖已有数据时会创建备份文件,并保留覆盖前的旧值。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[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<TestSimpleData>(location);
|
||||
var backup = await storage.ReadAsync<TestSimpleData>("profile/options.backup");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(current.Value, Is.EqualTo(2));
|
||||
Assert.That(backup.Value, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证通用数据仓库的批量保存只发送批量事件,不重复发送单项保存事件。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[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<DataSavedEvent<TestSimpleData>>(_ => savedEventCount++);
|
||||
context.RegisterEvent<DataBatchSavedEvent>(_ => 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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证统一设置仓库在批量覆盖时会为整个聚合文件创建备份,并只发送批量事件。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[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<DataSavedEvent<TestSimpleData>>(_ => savedEventCount++);
|
||||
context.RegisterEvent<DataBatchSavedEvent>(_ => batchEventCount++);
|
||||
|
||||
await repository.SaveAllAsync(
|
||||
[
|
||||
(location1, (IData)new TestSimpleData { Value = 2 }),
|
||||
(location2, (IData)new TestSimpleData { Value = 3 })
|
||||
]);
|
||||
|
||||
var current = await repository.LoadAsync<TestSimpleData>(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"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证统一设置仓库在删除某个 section 时会回写聚合文件,并保留删除前的统一文件备份。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[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<TestSimpleData>(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"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证统一设置仓库在启用事件时,只为显式仓库操作发送加载、保存、批量保存和删除事件。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[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<DataLoadedEvent<TestSimpleData>>(_ => loadedEventCount++);
|
||||
context.RegisterEvent<DataSavedEvent<TestSimpleData>>(_ => savedEventCount++);
|
||||
context.RegisterEvent<DataBatchSavedEvent>(_ => batchEventCount++);
|
||||
context.RegisterEvent<DataDeletedEvent>(_ => deletedEventCount++);
|
||||
|
||||
_ = await repository.LoadAsync<TestSimpleData>(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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建带事件总线的真实架构上下文,供上下文感知仓库测试使用。
|
||||
/// </summary>
|
||||
/// <returns>可用于发送和监听事件的架构上下文。</returns>
|
||||
private static ArchitectureContext CreateEventContext()
|
||||
{
|
||||
var container = new MicrosoftDiContainer();
|
||||
container.Register<IEventBus>(new EventBus());
|
||||
container.Freeze();
|
||||
return new ArchitectureContext(container);
|
||||
}
|
||||
|
||||
private sealed class TestSaveMigrationV1ToV2 : ISaveMigration<TestVersionedSaveData>
|
||||
{
|
||||
public int FromVersion => 1;
|
||||
|
||||
347
GFramework.Game.Tests/Setting/SettingsSystemTests.cs
Normal file
347
GFramework.Game.Tests/Setting/SettingsSystemTests.cs
Normal file
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 覆盖 <see cref="SettingsSystem" /> 的系统层语义,确保系统对模型编排、事件发送和重置流程保持稳定。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class SettingsSystemTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证 <see cref="SettingsSystem.ApplyAll" /> 会尝试应用全部 applicator,并为成功与失败结果分别发送事件。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[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<SettingsApplyingEvent<ISettingsSection>>(_ => applyingEventCount++);
|
||||
context.RegisterEvent<SettingsAppliedEvent<ISettingsSection>>(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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 <see cref="SettingsSystem.SaveAll" /> 会直接委托给模型层统一保存。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 <see cref="SettingsSystem.ResetAll" /> 会先委托模型统一重置,再重新应用全部 applicator。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 <see cref="SettingsSystem.Reset{T}" /> 会重置目标数据类型,并只重新应用对应的 applicator。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
[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<PrimaryTestSettings>();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(model.ResetTypes, Is.EquivalentTo(new[] { typeof(PrimaryTestSettings) }));
|
||||
Assert.That(applicator.ApplyCount, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建带事件总线和设置模型的真实架构上下文。
|
||||
/// </summary>
|
||||
/// <param name="model">测试使用的设置模型。</param>
|
||||
/// <returns>可供系统解析依赖与发送事件的上下文。</returns>
|
||||
private static ArchitectureContext CreateContext(ISettingsModel model)
|
||||
{
|
||||
var container = new MicrosoftDiContainer();
|
||||
container.Register<IEventBus>(new EventBus());
|
||||
container.Register<ISettingsModel>(model);
|
||||
container.Freeze();
|
||||
return new ArchitectureContext(container);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建并初始化绑定到指定上下文的设置系统。
|
||||
/// </summary>
|
||||
/// <param name="context">系统运行所需的架构上下文。</param>
|
||||
/// <returns>已完成初始化的设置系统实例。</returns>
|
||||
private static SettingsSystem CreateSystem(IArchitectureContext context)
|
||||
{
|
||||
var system = new SettingsSystem();
|
||||
((IContextAware)system).SetContext(context);
|
||||
system.Initialize();
|
||||
return system;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于系统层测试的简化设置模型,记录系统对模型的调用行为。
|
||||
/// </summary>
|
||||
private sealed class FakeSettingsModel : ISettingsModel
|
||||
{
|
||||
private readonly IReadOnlyDictionary<Type, IResetApplyAbleSettings> _applicators;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化测试模型,并注册参与测试的 applicator 集合。
|
||||
/// </summary>
|
||||
/// <param name="applicators">测试使用的 applicator。</param>
|
||||
public FakeSettingsModel(params IResetApplyAbleSettings[] applicators)
|
||||
{
|
||||
_applicators = applicators.ToDictionary(applicator => applicator.GetType());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取保存全量设置的调用次数。
|
||||
/// </summary>
|
||||
public int SaveAllCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取重置全部设置的调用次数。
|
||||
/// </summary>
|
||||
public int ResetAllCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取被请求重置的设置数据类型列表。
|
||||
/// </summary>
|
||||
public List<Type> ResetTypes { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsInitialized => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetContext(IArchitectureContext context)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IArchitectureContext GetContext()
|
||||
{
|
||||
throw new NotSupportedException("Fake settings model does not expose a context.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnArchitecturePhase(ArchitecturePhase phase)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T GetData<T>() where T : class, ISettingsData, new()
|
||||
{
|
||||
return new T();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ISettingsData> AllData()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISettingsModel RegisterApplicator<T>(T applicator) where T : class, IResetApplyAbleSettings
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T? GetApplicator<T>() where T : class, IResetApplyAbleSettings
|
||||
{
|
||||
return _applicators.TryGetValue(typeof(T), out var applicator) ? (T)applicator : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<IResetApplyAbleSettings> AllApplicators()
|
||||
{
|
||||
return _applicators.Values;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISettingsModel RegisterMigration(ISettingsMigration migration)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveAllAsync()
|
||||
{
|
||||
SaveAllCallCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ApplyAllAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Reset<T>() where T : class, ISettingsData, new()
|
||||
{
|
||||
ResetTypes.Add(typeof(T));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetAll()
|
||||
{
|
||||
ResetAllCallCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为系统层测试提供的最小设置数据实现。
|
||||
/// </summary>
|
||||
private abstract class TestSettingsBase : ISettingsData, IResetApplyAbleSettings
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public int Version { get; set; } = 1;
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTime LastModified { get; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// 获取测试用的数值字段,用于确认重置与加载行为。
|
||||
/// </summary>
|
||||
public int Value { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取应用操作被调用的次数。
|
||||
/// </summary>
|
||||
public int ApplyCount { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISettingsData Data => this;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Type DataType => GetType();
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置是否在应用时抛出异常。
|
||||
/// </summary>
|
||||
protected bool ThrowOnApply { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Reset()
|
||||
{
|
||||
Value = 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void LoadFrom(ISettingsData source)
|
||||
{
|
||||
if (source is TestSettingsBase data)
|
||||
{
|
||||
Value = data.Value;
|
||||
Version = data.Version;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task Apply()
|
||||
{
|
||||
ApplyCount++;
|
||||
|
||||
await Task.Yield();
|
||||
|
||||
if (ThrowOnApply)
|
||||
{
|
||||
throw new InvalidOperationException("Test applicator failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 代表主设置分支的测试设置对象。
|
||||
/// </summary>
|
||||
private sealed class PrimaryTestSettings : TestSettingsBase
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 代表第二个设置分支的测试设置对象,可选择在应用时失败。
|
||||
/// </summary>
|
||||
private sealed class SecondaryTestSettings : TestSettingsBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化第二个测试设置对象。
|
||||
/// </summary>
|
||||
/// <param name="throwOnApply">是否在应用时抛出异常。</param>
|
||||
public SecondaryTestSettings(bool throwOnApply = false)
|
||||
{
|
||||
ThrowOnApply = throwOnApply;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 数据仓库类,用于管理游戏数据的存储和读取
|
||||
/// 数据仓库类,用于管理游戏数据的存储和读取。
|
||||
/// </summary>
|
||||
/// <param name="storage">存储接口实例</param>
|
||||
/// <param name="options">数据仓库配置选项</param>
|
||||
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<T>(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<T>(key);
|
||||
await Storage.WriteAsync(backupKey, existing);
|
||||
}
|
||||
|
||||
await Storage.WriteAsync(key, data);
|
||||
|
||||
if (_options.EnableEvents)
|
||||
this.SendEvent(new DataSavedEvent<T>(data));
|
||||
await SaveCoreAsync(location, data, emitSavedEvent: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -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<IStorage>()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行单项保存的共享流程,并根据调用入口决定是否发送单项保存事件。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">数据类型。</typeparam>
|
||||
/// <param name="location">目标数据位置。</param>
|
||||
/// <param name="data">要保存的数据对象。</param>
|
||||
/// <param name="emitSavedEvent">是否在成功写入后发送单项保存事件。</param>
|
||||
private async Task SaveCoreAsync<T>(IDataLocation location, T data, bool emitSavedEvent)
|
||||
where T : class, IData
|
||||
{
|
||||
var key = location.ToStorageKey();
|
||||
|
||||
await BackupIfNeededAsync<T>(key);
|
||||
await Storage.WriteAsync(key, data);
|
||||
|
||||
if (emitSavedEvent && _options.EnableEvents)
|
||||
{
|
||||
this.SendEvent(new DataSavedEvent<T>(data));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在覆盖旧值前为当前存储键创建备份。
|
||||
/// </summary>
|
||||
/// <param name="key">即将被覆盖的存储键。</param>
|
||||
private async Task BackupIfNeededAsync<T>(string key)
|
||||
where T : class, IData
|
||||
{
|
||||
if (!_options.AutoBackup || !await Storage.ExistsAsync(key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var backupKey = $"{key}.backup";
|
||||
var existing = await Storage.ReadAsync<T>(key);
|
||||
await Storage.WriteAsync(backupKey, existing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用数据对象的运行时类型执行保存流程,避免批量保存时因为编译期类型退化为 <see cref="IData" /> 而破坏备份反序列化。
|
||||
/// </summary>
|
||||
/// <param name="location">目标数据位置。</param>
|
||||
/// <param name="data">要保存的数据对象。</param>
|
||||
/// <param name="emitSavedEvent">是否发送单项保存事件。</param>
|
||||
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])!;
|
||||
}
|
||||
}
|
||||
@ -21,8 +21,13 @@ using GFramework.Game.Abstractions.Data.Events;
|
||||
namespace GFramework.Game.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 使用单一文件存储所有设置数据的仓库实现
|
||||
/// 使用单一文件存储所有设置数据的仓库实现。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该仓库通过内存缓存聚合所有设置 section,并在公开的保存或删除操作发生时整文件回写。
|
||||
/// 虽然底层不是“一项一个文件”,但它仍遵循 <see cref="DataRepositoryOptions" /> 定义的统一契约:
|
||||
/// 启用自动备份时,覆盖写入前会为整个统一文件创建单份备份;批量保存只发出批量事件,不重复发出单项保存事件。
|
||||
/// </remarks>
|
||||
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<T>(raw) : new T();
|
||||
if (_options.EnableEvents)
|
||||
this.SendEvent(new DataLoadedEvent<IData>(result));
|
||||
this.SendEvent(new DataLoadedEvent<T>(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<T>(data));
|
||||
}
|
||||
finally
|
||||
if (_options.EnableEvents)
|
||||
{
|
||||
_lock.Release();
|
||||
this.SendEvent(new DataSavedEvent<T>(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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -226,12 +236,13 @@ public class UnifiedSettingsDataRepository(
|
||||
/// <summary>
|
||||
/// 将缓存中的所有数据保存到统一文件
|
||||
/// </summary>
|
||||
private async Task SaveUnifiedFileAsync()
|
||||
private async Task MutateAndPersistAsync(Action<UnifiedSettingsFile> mutation)
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await Storage.WriteAsync(UnifiedKey, _file);
|
||||
mutation(File);
|
||||
await WriteUnifiedFileCoreAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@ -240,9 +251,27 @@ public class UnifiedSettingsDataRepository(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取统一文件的存储键名
|
||||
/// 将当前缓存中的统一文件写回底层存储,并在需要时创建整个文件的备份。
|
||||
/// </summary>
|
||||
/// <returns>完整的存储键名</returns>
|
||||
/// <remarks>
|
||||
/// 该方法要求调用方已经持有 <see cref="_lock" />,以保证“修改缓存 -> 备份旧文件 -> 写入新文件”观察到的是同一份一致状态。
|
||||
/// </remarks>
|
||||
private async Task WriteUnifiedFileCoreAsync()
|
||||
{
|
||||
if (_options.AutoBackup && await Storage.ExistsAsync(UnifiedKey))
|
||||
{
|
||||
var backupKey = $"{UnifiedKey}.backup";
|
||||
var existing = await Storage.ReadAsync<UnifiedSettingsFile>(UnifiedKey);
|
||||
await Storage.WriteAsync(backupKey, existing);
|
||||
}
|
||||
|
||||
await Storage.WriteAsync(UnifiedKey, File);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取统一文件的存储键名。
|
||||
/// </summary>
|
||||
/// <returns>完整的存储键名。</returns>
|
||||
protected virtual string GetUnifiedKey()
|
||||
{
|
||||
return string.IsNullOrEmpty(_options.BasePath) ? fileName : $"{_options.BasePath}/{fileName}";
|
||||
|
||||
@ -48,6 +48,20 @@ public interface IDataRepository : IUtility
|
||||
}
|
||||
```
|
||||
|
||||
`IDataRepository` 描述的是“仓库语义”,不是固定的落盘格式。实现可以选择每个数据项独立成文件,也可以把多个 section 聚合到同一个文件里。
|
||||
|
||||
当前内建实现里:
|
||||
|
||||
- `DataRepository` 采用“每个 location 一份持久化对象”的模型
|
||||
- `UnifiedSettingsDataRepository` 采用“所有设置聚合到一个统一文件”的模型
|
||||
|
||||
两者对外遵守同一套约定:
|
||||
|
||||
- `SaveAllAsync(...)` 视为一次批量提交,只发送 `DataBatchSavedEvent`,不会再为每个条目重复发送 `DataSavedEvent<T>`
|
||||
- `DeleteAsync(...)` 只有在目标数据真实存在并被删除时才会发送删除事件
|
||||
- 当 `DataRepositoryOptions.AutoBackup = true` 时,覆盖已有数据前会先保留上一份快照
|
||||
- 对 `UnifiedSettingsDataRepository` 来说,备份粒度是整个统一文件,而不是单个设置 section
|
||||
|
||||
### 存档仓库
|
||||
|
||||
`ISaveRepository<T>` 专门用于管理游戏存档:
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user