From c0aa8ba70e7cf121f28a0c9b2ba7fd7d2cec4938 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:18:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了 ConfigRegistry 配置注册表,支持按名称注册和类型安全查询 - 创建了 InMemoryConfigTable 内存配置表,提供基于字典的只读配置存储 - 定义了 IConfigLoader、IConfigRegistry 和 IConfigTable 接口契约 - 添加了完整的单元测试验证配置表的注册、查询和类型检查功能 - 在项目文件中添加了新的代码文件夹结构 - 实现了配置表的覆盖策略以支持开发期热重载需求 --- .../Config/IConfigLoader.cs | 19 ++ .../Config/IConfigRegistry.cs | 75 ++++++++ .../Config/IConfigTable.cs | 65 +++++++ .../Config/ConfigRegistryTests.cs | 130 ++++++++++++++ .../Config/InMemoryConfigTableTests.cs | 72 ++++++++ GFramework.Game/Config/ConfigRegistry.cs | 162 ++++++++++++++++++ GFramework.Game/Config/InMemoryConfigTable.cs | 118 +++++++++++++ GFramework.csproj | 1 + 8 files changed, 642 insertions(+) create mode 100644 GFramework.Game.Abstractions/Config/IConfigLoader.cs create mode 100644 GFramework.Game.Abstractions/Config/IConfigRegistry.cs create mode 100644 GFramework.Game.Abstractions/Config/IConfigTable.cs create mode 100644 GFramework.Game.Tests/Config/ConfigRegistryTests.cs create mode 100644 GFramework.Game.Tests/Config/InMemoryConfigTableTests.cs create mode 100644 GFramework.Game/Config/ConfigRegistry.cs create mode 100644 GFramework.Game/Config/InMemoryConfigTable.cs diff --git a/GFramework.Game.Abstractions/Config/IConfigLoader.cs b/GFramework.Game.Abstractions/Config/IConfigLoader.cs new file mode 100644 index 0000000..50abd08 --- /dev/null +++ b/GFramework.Game.Abstractions/Config/IConfigLoader.cs @@ -0,0 +1,19 @@ +using GFramework.Core.Abstractions.Utility; + +namespace GFramework.Game.Abstractions.Config; + +/// +/// 定义配置加载器契约。 +/// 具体实现负责从文件系统、资源包或其他配置源加载文本配置,并将解析结果注册到配置注册表。 +/// +public interface IConfigLoader : IUtility +{ + /// + /// 执行配置加载并将结果写入注册表。 + /// 实现应在同一次加载过程中保证注册结果的一致性,避免只加载部分配置后就暴露给运行时消费。 + /// + /// 用于接收配置表的注册表。 + /// 取消令牌。 + /// 表示异步加载流程的任务。 + Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/GFramework.Game.Abstractions/Config/IConfigRegistry.cs b/GFramework.Game.Abstractions/Config/IConfigRegistry.cs new file mode 100644 index 0000000..d40c918 --- /dev/null +++ b/GFramework.Game.Abstractions/Config/IConfigRegistry.cs @@ -0,0 +1,75 @@ +using GFramework.Core.Abstractions.Utility; + +namespace GFramework.Game.Abstractions.Config; + +/// +/// 定义配置注册表契约,用于统一保存和解析按名称注册的配置表。 +/// 注册表是运行时配置系统的入口,负责在加载阶段收集配置表,并在消费阶段提供类型安全查询。 +/// +public interface IConfigRegistry : IUtility +{ + /// + /// 获取当前已注册配置表数量。 + /// + int Count { get; } + + /// + /// 获取所有已注册配置表名称。 + /// + /// 配置表名称集合。 + IReadOnlyCollection GetTableNames(); + + /// + /// 注册指定名称的配置表。 + /// 若名称已存在,则替换旧表,以便开发期热重载使用同一入口刷新配置。 + /// + /// 配置表主键类型。 + /// 配置项值类型。 + /// 配置表名称。 + /// 要注册的配置表实例。 + void RegisterTable(string name, IConfigTable table) + where TKey : notnull; + + /// + /// 获取指定名称的配置表。 + /// + /// 配置表主键类型。 + /// 配置项值类型。 + /// 配置表名称。 + /// 匹配的强类型配置表实例。 + /// 当配置表名称不存在时抛出。 + /// 当请求类型与已注册配置表类型不匹配时抛出。 + IConfigTable GetTable(string name) + where TKey : notnull; + + /// + /// 尝试获取指定名称的配置表。 + /// 当名称存在但类型不匹配时返回 false,避免消费端将类型错误误判为加载成功。 + /// + /// 配置表主键类型。 + /// 配置项值类型。 + /// 配置表名称。 + /// 匹配的强类型配置表;未找到或类型不匹配时返回空。 + /// 找到且类型匹配时返回 true,否则返回 false + bool TryGetTable(string name, out IConfigTable? table) + where TKey : notnull; + + /// + /// 检查指定名称的配置表是否存在。 + /// + /// 配置表名称。 + /// 存在时返回 true,否则返回 false + bool HasTable(string name); + + /// + /// 移除指定名称的配置表。 + /// + /// 配置表名称。 + /// 移除成功时返回 true,否则返回 false + bool RemoveTable(string name); + + /// + /// 清空所有已注册配置表。 + /// + void Clear(); +} \ No newline at end of file diff --git a/GFramework.Game.Abstractions/Config/IConfigTable.cs b/GFramework.Game.Abstractions/Config/IConfigTable.cs new file mode 100644 index 0000000..1cd781a --- /dev/null +++ b/GFramework.Game.Abstractions/Config/IConfigTable.cs @@ -0,0 +1,65 @@ +using GFramework.Core.Abstractions.Utility; + +namespace GFramework.Game.Abstractions.Config; + +/// +/// 定义配置表的非泛型公共契约,用于在注册表中保存异构配置表实例。 +/// 该接口只暴露运行时发现和诊断所需的元数据,不提供具体类型访问能力。 +/// +public interface IConfigTable : IUtility +{ + /// + /// 获取配置表主键类型。 + /// + Type KeyType { get; } + + /// + /// 获取配置项值类型。 + /// + Type ValueType { get; } + + /// + /// 获取当前配置表中的条目数量。 + /// + int Count { get; } +} + +/// +/// 定义强类型只读配置表契约。 +/// 运行时配置表应通过主键执行只读查询,而不是暴露可变集合接口, +/// 以保持配置数据在加载完成后的稳定性和可预测性。 +/// +/// 配置表主键类型。 +/// 配置项值类型。 +public interface IConfigTable : IConfigTable + where TKey : notnull +{ + /// + /// 获取指定主键的配置项。 + /// + /// 配置项主键。 + /// 找到的配置项。 + /// 当主键不存在时抛出。 + TValue Get(TKey key); + + /// + /// 尝试获取指定主键的配置项。 + /// + /// 配置项主键。 + /// 找到的配置项;未找到时返回默认值。 + /// 找到配置项时返回 true,否则返回 false + bool TryGet(TKey key, out TValue? value); + + /// + /// 检查指定主键是否存在。 + /// + /// 配置项主键。 + /// 主键存在时返回 true,否则返回 false + bool ContainsKey(TKey key); + + /// + /// 获取配置表中的所有配置项快照。 + /// + /// 只读配置项集合。 + IReadOnlyCollection All(); +} \ No newline at end of file diff --git a/GFramework.Game.Tests/Config/ConfigRegistryTests.cs b/GFramework.Game.Tests/Config/ConfigRegistryTests.cs new file mode 100644 index 0000000..437e968 --- /dev/null +++ b/GFramework.Game.Tests/Config/ConfigRegistryTests.cs @@ -0,0 +1,130 @@ +using GFramework.Game.Abstractions.Config; +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证配置注册表的注册、覆盖和类型检查行为。 +/// +[TestFixture] +public class ConfigRegistryTests +{ + /// + /// 验证注册后的配置表可以按名称和类型成功解析。 + /// + [Test] + public void RegisterTable_Then_GetTable_Should_Return_Registered_Instance() + { + var registry = new ConfigRegistry(); + var table = CreateMonsterTable(); + + registry.RegisterTable("monster", table); + + var resolved = registry.GetTable("monster"); + + Assert.That(resolved, Is.SameAs(table)); + } + + /// + /// 验证同名注册会覆盖旧表,用于后续热重载场景。 + /// + [Test] + public void RegisterTable_Should_Replace_Previous_Table_With_Same_Name() + { + var registry = new ConfigRegistry(); + var oldTable = CreateMonsterTable(); + var newTable = new InMemoryConfigTable( + new[] + { + new MonsterConfigStub(3, "Orc") + }, + static config => config.Id); + + registry.RegisterTable("monster", oldTable); + registry.RegisterTable("monster", newTable); + + var resolved = registry.GetTable("monster"); + + Assert.That(resolved, Is.SameAs(newTable)); + Assert.That(resolved.Count, Is.EqualTo(1)); + } + + /// + /// 验证请求类型与实际注册类型不匹配时会抛出异常,避免消费端默默读取错误表。 + /// + [Test] + public void GetTable_Should_Throw_When_Requested_Type_Does_Not_Match_Registered_Table() + { + var registry = new ConfigRegistry(); + registry.RegisterTable("monster", CreateMonsterTable()); + + Assert.Throws(() => registry.GetTable("monster")); + } + + /// + /// 验证移除和清空操作会更新注册表状态。 + /// + [Test] + public void RemoveTable_And_Clear_Should_Update_Registry_State() + { + var registry = new ConfigRegistry(); + registry.RegisterTable("monster", CreateMonsterTable()); + registry.RegisterTable("npc", CreateNpcTable()); + + var removed = registry.RemoveTable("monster"); + + Assert.Multiple(() => + { + Assert.That(removed, Is.True); + Assert.That(registry.HasTable("monster"), Is.False); + Assert.That(registry.Count, Is.EqualTo(1)); + }); + + registry.Clear(); + + Assert.That(registry.Count, Is.EqualTo(0)); + } + + /// + /// 创建怪物配置表测试实例。 + /// + /// 怪物配置表。 + private static IConfigTable CreateMonsterTable() + { + return new InMemoryConfigTable( + new[] + { + new MonsterConfigStub(1, "Slime"), + new MonsterConfigStub(2, "Goblin") + }, + static config => config.Id); + } + + /// + /// 创建 NPC 配置表测试实例。 + /// + /// NPC 配置表。 + private static IConfigTable CreateNpcTable() + { + return new InMemoryConfigTable( + new[] + { + new NpcConfigStub(Guid.NewGuid(), "Guide") + }, + static config => config.Id); + } + + /// + /// 用于怪物配置表测试的最小配置类型。 + /// + /// 配置主键。 + /// 配置名称。 + private sealed record MonsterConfigStub(int Id, string Name); + + /// + /// 用于 NPC 配置表测试的最小配置类型。 + /// + /// 配置主键。 + /// 配置名称。 + private sealed record NpcConfigStub(Guid Id, string Name); +} \ No newline at end of file diff --git a/GFramework.Game.Tests/Config/InMemoryConfigTableTests.cs b/GFramework.Game.Tests/Config/InMemoryConfigTableTests.cs new file mode 100644 index 0000000..0d5a7f4 --- /dev/null +++ b/GFramework.Game.Tests/Config/InMemoryConfigTableTests.cs @@ -0,0 +1,72 @@ +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证内存配置表的基础只读查询行为。 +/// +[TestFixture] +public class InMemoryConfigTableTests +{ + /// + /// 验证已存在主键可以被正确查询。 + /// + [Test] + public void Get_Should_Return_Config_When_Key_Exists() + { + var table = new InMemoryConfigTable( + new[] + { + new MonsterConfigStub(1, "Slime"), + new MonsterConfigStub(2, "Goblin") + }, + static config => config.Id); + + var result = table.Get(2); + + Assert.That(result.Name, Is.EqualTo("Goblin")); + } + + /// + /// 验证重复主键会在加载期被拒绝,避免运行期覆盖旧值。 + /// + [Test] + public void Constructor_Should_Throw_When_Duplicate_Key_Is_Detected() + { + Assert.Throws(() => + new InMemoryConfigTable( + new[] + { + new MonsterConfigStub(1, "Slime"), + new MonsterConfigStub(1, "Goblin") + }, + static config => config.Id)); + } + + /// + /// 验证 All 返回的集合包含完整快照。 + /// + [Test] + public void All_Should_Return_All_Configs() + { + var table = new InMemoryConfigTable( + new[] + { + new MonsterConfigStub(1, "Slime"), + new MonsterConfigStub(2, "Goblin") + }, + static config => config.Id); + + var all = table.All(); + + Assert.That(all, Has.Count.EqualTo(2)); + Assert.That(all.Select(static config => config.Name), Is.EquivalentTo(new[] { "Slime", "Goblin" })); + } + + /// + /// 用于配置表测试的最小配置类型。 + /// + /// 配置主键。 + /// 配置名称。 + private sealed record MonsterConfigStub(int Id, string Name); +} \ No newline at end of file diff --git a/GFramework.Game/Config/ConfigRegistry.cs b/GFramework.Game/Config/ConfigRegistry.cs new file mode 100644 index 0000000..db80be1 --- /dev/null +++ b/GFramework.Game/Config/ConfigRegistry.cs @@ -0,0 +1,162 @@ +using GFramework.Game.Abstractions.Config; + +namespace GFramework.Game.Config; + +/// +/// 默认配置注册表实现。 +/// 该类型负责统一管理按名称注册的配置表,并在消费端提供类型安全的解析入口。 +/// 为了支持开发期热重载,注册行为采用覆盖策略而不是拒绝重复名称。 +/// +public sealed class ConfigRegistry : IConfigRegistry +{ + private const string NameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace."; + + private readonly ConcurrentDictionary _tables = new(StringComparer.Ordinal); + + /// + /// 获取已注册的配置表数量。 + /// + public int Count => _tables.Count; + + /// + /// 获取所有已注册配置表的名称集合,按字典序排序。 + /// + /// 返回只读的配置表名称集合。 + public IReadOnlyCollection GetTableNames() + { + return _tables.Keys.OrderBy(static key => key, StringComparer.Ordinal).ToArray(); + } + + /// + /// 注册一个配置表到注册表中。 + /// 如果同名的配置表已存在,则会覆盖原有注册以支持热重载。 + /// + /// 配置表主键的类型,必须为非空类型。 + /// 配置表值的类型。 + /// 配置表的注册名称,用于后续查找。 + /// 要注册的配置表实例。 + /// 为 null、空或仅包含空白字符时抛出。 + /// 为 null 时抛出。 + public void RegisterTable(string name, IConfigTable table) + where TKey : notnull + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name)); + } + + ArgumentNullException.ThrowIfNull(table); + + _tables[name] = table; + } + + /// + /// 根据名称获取已注册的配置表,并进行类型验证。 + /// + /// 期望的主键类型,必须为非空类型。 + /// 期望的值类型。 + /// 要查找的配置表名称。 + /// 返回类型匹配的配置表实例。 + /// 为 null、空或仅包含空白字符时抛出。 + /// 当指定名称的配置表不存在时抛出。 + /// + /// 当找到的配置表类型与请求的类型不匹配时抛出。 + /// + public IConfigTable GetTable(string name) + where TKey : notnull + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name)); + } + + if (!_tables.TryGetValue(name, out var table)) + { + throw new KeyNotFoundException($"Config table '{name}' was not found."); + } + + if (table is IConfigTable typedTable) + { + return typedTable; + } + + throw new InvalidOperationException( + $"Config table '{name}' was registered as '{table.KeyType.Name} -> {table.ValueType.Name}', " + + $"but the caller requested '{typeof(TKey).Name} -> {typeof(TValue).Name}'."); + } + + /// + /// 尝试根据名称获取配置表,操作失败时不会抛出异常。 + /// + /// 期望的主键类型,必须为非空类型。 + /// 期望的值类型。 + /// 要查找的配置表名称。 + /// + /// 输出参数,如果查找成功则返回类型匹配的配置表实例,否则为 null。 + /// + /// 如果找到指定名称且类型匹配的配置表则返回 true,否则返回 false。 + /// 为 null、空或仅包含空白字符时抛出。 + public bool TryGetTable(string name, out IConfigTable? table) + where TKey : notnull + { + table = default; + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name)); + } + + if (!_tables.TryGetValue(name, out var rawTable)) + { + return false; + } + + if (rawTable is not IConfigTable typedTable) + { + return false; + } + + table = typedTable; + return true; + } + + /// + /// 检查指定名称的配置表是否已注册。 + /// + /// 要检查的配置表名称。 + /// 如果配置表已注册则返回 true,否则返回 false。 + /// 为 null、空或仅包含空白字符时抛出。 + public bool HasTable(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name)); + } + + return _tables.ContainsKey(name); + } + + /// + /// 从注册表中移除指定名称的配置表。 + /// + /// 要移除的配置表名称。 + /// 如果配置表存在并被成功移除则返回 true,否则返回 false。 + /// 为 null、空或仅包含空白字符时抛出。 + public bool RemoveTable(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name)); + } + + return _tables.TryRemove(name, out _); + } + + /// + /// 清空注册表中的所有配置表。 + /// + public void Clear() + { + _tables.Clear(); + } +} \ No newline at end of file diff --git a/GFramework.Game/Config/InMemoryConfigTable.cs b/GFramework.Game/Config/InMemoryConfigTable.cs new file mode 100644 index 0000000..bf6bd28 --- /dev/null +++ b/GFramework.Game/Config/InMemoryConfigTable.cs @@ -0,0 +1,118 @@ +using System.Collections.ObjectModel; +using GFramework.Game.Abstractions.Config; + +namespace GFramework.Game.Config; + +/// +/// 基于内存字典的只读配置表实现。 +/// 该实现用于 Runtime MVP 阶段,为加载器和注册表提供稳定的只读查询对象。 +/// +/// 配置表主键类型。 +/// 配置项值类型。 +public sealed class InMemoryConfigTable : IConfigTable + where TKey : notnull +{ + private readonly IReadOnlyCollection _allValues; + private readonly IReadOnlyDictionary _entries; + + /// + /// 使用配置项序列和主键选择器创建内存配置表。 + /// + /// 配置项序列。 + /// 用于提取主键的委托。 + /// 可选的主键比较器。 + /// 为空时抛出。 + /// 当配置项主键重复时抛出。 + public InMemoryConfigTable( + IEnumerable values, + Func keySelector, + IEqualityComparer? comparer = null) + { + ArgumentNullException.ThrowIfNull(values); + ArgumentNullException.ThrowIfNull(keySelector); + + var dictionary = new Dictionary(comparer); + var allValues = new List(); + + foreach (var value in values) + { + var key = keySelector(value); + + // 配置表必须在加载期拒绝重复主键,否则运行期查询结果将不可预测。 + if (!dictionary.TryAdd(key, value)) + { + throw new InvalidOperationException( + $"Duplicate config key '{key}' was detected for table value type '{typeof(TValue).Name}'."); + } + + allValues.Add(value); + } + + _entries = new ReadOnlyDictionary(dictionary); + _allValues = new ReadOnlyCollection(allValues); + } + + /// + /// 获取配置表的主键类型。 + /// + public Type KeyType => typeof(TKey); + + /// + /// 获取配置表的值类型。 + /// + public Type ValueType => typeof(TValue); + + /// + /// 获取配置表中配置项的数量。 + /// + public int Count => _entries.Count; + + /// + /// 根据主键获取配置项的值。 + /// + /// 要查找的配置项主键。 + /// 返回对应主键的配置项值。 + /// 当指定主键的配置项不存在时抛出。 + public TValue Get(TKey key) + { + if (!_entries.TryGetValue(key, out var value)) + { + throw new KeyNotFoundException( + $"Config key '{key}' was not found in table '{typeof(TValue).Name}'."); + } + + return value; + } + + /// + /// 尝试根据主键获取配置项的值,操作失败时不会抛出异常。 + /// + /// 要查找的配置项主键。 + /// + /// 输出参数,如果查找成功则返回对应的配置项值,否则为默认值。 + /// + /// 如果找到指定主键的配置项则返回 true,否则返回 false。 + public bool TryGet(TKey key, out TValue? value) + { + return _entries.TryGetValue(key, out value); + } + + /// + /// 检查指定主键的配置项是否存在于配置表中。 + /// + /// 要检查的配置项主键。 + /// 如果配置项已存在则返回 true,否则返回 false。 + public bool ContainsKey(TKey key) + { + return _entries.ContainsKey(key); + } + + /// + /// 获取配置表中所有配置项的集合。 + /// + /// 返回所有配置项值的只读集合。 + public IReadOnlyCollection All() + { + return _allValues; + } +} \ No newline at end of file diff --git a/GFramework.csproj b/GFramework.csproj index 1594e9a..e1d069a 100644 --- a/GFramework.csproj +++ b/GFramework.csproj @@ -134,6 +134,7 @@ +