docs(data): 添加数据与存档系统文档并实现数据仓库功能

- 新增数据与存档系统详细文档,涵盖核心概念和使用方法
- 实现 DataRepository 类提供统一数据持久化接口
- 添加 DataRepositoryOptions 配置选项支持备份和事件功能
- 实现完整的数据仓库测试用例验证持久化行为
- 支持多槽位存档管理和版本化数据迁移功能
- 提供批量数据操作和事件通知机制
- 实现自动备份功能防止数据丢失
- 支持聚合设置仓库统一管理多个配置项
This commit is contained in:
GeWuYou 2026-04-06 12:21:28 +08:00
parent fb3bf49a12
commit 7114a76377
6 changed files with 819 additions and 53 deletions

View File

@ -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;
}
}

View File

@ -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;

View 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;
}
}
}

View File

@ -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])!;
}
}

View File

@ -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,11 +251,29 @@ 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}";
}
}
}

View File

@ -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