feat(config): 添加配置管理系统核心组件

- 实现了 ConfigRegistry 配置注册表,支持按名称注册和类型安全查询
- 创建了 InMemoryConfigTable 内存配置表,提供基于字典的只读配置存储
- 定义了 IConfigLoader、IConfigRegistry 和 IConfigTable 接口契约
- 添加了完整的单元测试验证配置表的注册、查询和类型检查功能
- 在项目文件中添加了新的代码文件夹结构
- 实现了配置表的覆盖策略以支持开发期热重载需求
This commit is contained in:
GeWuYou 2026-03-30 13:18:54 +08:00
parent a628ade28e
commit c0aa8ba70e
8 changed files with 642 additions and 0 deletions

View File

@ -0,0 +1,19 @@
using GFramework.Core.Abstractions.Utility;
namespace GFramework.Game.Abstractions.Config;
/// <summary>
/// 定义配置加载器契约。
/// 具体实现负责从文件系统、资源包或其他配置源加载文本配置,并将解析结果注册到配置注册表。
/// </summary>
public interface IConfigLoader : IUtility
{
/// <summary>
/// 执行配置加载并将结果写入注册表。
/// 实现应在同一次加载过程中保证注册结果的一致性,避免只加载部分配置后就暴露给运行时消费。
/// </summary>
/// <param name="registry">用于接收配置表的注册表。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示异步加载流程的任务。</returns>
Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,75 @@
using GFramework.Core.Abstractions.Utility;
namespace GFramework.Game.Abstractions.Config;
/// <summary>
/// 定义配置注册表契约,用于统一保存和解析按名称注册的配置表。
/// 注册表是运行时配置系统的入口,负责在加载阶段收集配置表,并在消费阶段提供类型安全查询。
/// </summary>
public interface IConfigRegistry : IUtility
{
/// <summary>
/// 获取当前已注册配置表数量。
/// </summary>
int Count { get; }
/// <summary>
/// 获取所有已注册配置表名称。
/// </summary>
/// <returns>配置表名称集合。</returns>
IReadOnlyCollection<string> GetTableNames();
/// <summary>
/// 注册指定名称的配置表。
/// 若名称已存在,则替换旧表,以便开发期热重载使用同一入口刷新配置。
/// </summary>
/// <typeparam name="TKey">配置表主键类型。</typeparam>
/// <typeparam name="TValue">配置项值类型。</typeparam>
/// <param name="name">配置表名称。</param>
/// <param name="table">要注册的配置表实例。</param>
void RegisterTable<TKey, TValue>(string name, IConfigTable<TKey, TValue> table)
where TKey : notnull;
/// <summary>
/// 获取指定名称的配置表。
/// </summary>
/// <typeparam name="TKey">配置表主键类型。</typeparam>
/// <typeparam name="TValue">配置项值类型。</typeparam>
/// <param name="name">配置表名称。</param>
/// <returns>匹配的强类型配置表实例。</returns>
/// <exception cref="KeyNotFoundException">当配置表名称不存在时抛出。</exception>
/// <exception cref="InvalidOperationException">当请求类型与已注册配置表类型不匹配时抛出。</exception>
IConfigTable<TKey, TValue> GetTable<TKey, TValue>(string name)
where TKey : notnull;
/// <summary>
/// 尝试获取指定名称的配置表。
/// 当名称存在但类型不匹配时返回 <c>false</c>,避免消费端将类型错误误判为加载成功。
/// </summary>
/// <typeparam name="TKey">配置表主键类型。</typeparam>
/// <typeparam name="TValue">配置项值类型。</typeparam>
/// <param name="name">配置表名称。</param>
/// <param name="table">匹配的强类型配置表;未找到或类型不匹配时返回空。</param>
/// <returns>找到且类型匹配时返回 <c>true</c>,否则返回 <c>false</c>。</returns>
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
where TKey : notnull;
/// <summary>
/// 检查指定名称的配置表是否存在。
/// </summary>
/// <param name="name">配置表名称。</param>
/// <returns>存在时返回 <c>true</c>,否则返回 <c>false</c>。</returns>
bool HasTable(string name);
/// <summary>
/// 移除指定名称的配置表。
/// </summary>
/// <param name="name">配置表名称。</param>
/// <returns>移除成功时返回 <c>true</c>,否则返回 <c>false</c>。</returns>
bool RemoveTable(string name);
/// <summary>
/// 清空所有已注册配置表。
/// </summary>
void Clear();
}

View File

@ -0,0 +1,65 @@
using GFramework.Core.Abstractions.Utility;
namespace GFramework.Game.Abstractions.Config;
/// <summary>
/// 定义配置表的非泛型公共契约,用于在注册表中保存异构配置表实例。
/// 该接口只暴露运行时发现和诊断所需的元数据,不提供具体类型访问能力。
/// </summary>
public interface IConfigTable : IUtility
{
/// <summary>
/// 获取配置表主键类型。
/// </summary>
Type KeyType { get; }
/// <summary>
/// 获取配置项值类型。
/// </summary>
Type ValueType { get; }
/// <summary>
/// 获取当前配置表中的条目数量。
/// </summary>
int Count { get; }
}
/// <summary>
/// 定义强类型只读配置表契约。
/// 运行时配置表应通过主键执行只读查询,而不是暴露可变集合接口,
/// 以保持配置数据在加载完成后的稳定性和可预测性。
/// </summary>
/// <typeparam name="TKey">配置表主键类型。</typeparam>
/// <typeparam name="TValue">配置项值类型。</typeparam>
public interface IConfigTable<TKey, TValue> : IConfigTable
where TKey : notnull
{
/// <summary>
/// 获取指定主键的配置项。
/// </summary>
/// <param name="key">配置项主键。</param>
/// <returns>找到的配置项。</returns>
/// <exception cref="KeyNotFoundException">当主键不存在时抛出。</exception>
TValue Get(TKey key);
/// <summary>
/// 尝试获取指定主键的配置项。
/// </summary>
/// <param name="key">配置项主键。</param>
/// <param name="value">找到的配置项;未找到时返回默认值。</param>
/// <returns>找到配置项时返回 <c>true</c>,否则返回 <c>false</c>。</returns>
bool TryGet(TKey key, out TValue? value);
/// <summary>
/// 检查指定主键是否存在。
/// </summary>
/// <param name="key">配置项主键。</param>
/// <returns>主键存在时返回 <c>true</c>,否则返回 <c>false</c>。</returns>
bool ContainsKey(TKey key);
/// <summary>
/// 获取配置表中的所有配置项快照。
/// </summary>
/// <returns>只读配置项集合。</returns>
IReadOnlyCollection<TValue> All();
}

View File

@ -0,0 +1,130 @@
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
namespace GFramework.Game.Tests.Config;
/// <summary>
/// 验证配置注册表的注册、覆盖和类型检查行为。
/// </summary>
[TestFixture]
public class ConfigRegistryTests
{
/// <summary>
/// 验证注册后的配置表可以按名称和类型成功解析。
/// </summary>
[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<int, MonsterConfigStub>("monster");
Assert.That(resolved, Is.SameAs(table));
}
/// <summary>
/// 验证同名注册会覆盖旧表,用于后续热重载场景。
/// </summary>
[Test]
public void RegisterTable_Should_Replace_Previous_Table_With_Same_Name()
{
var registry = new ConfigRegistry();
var oldTable = CreateMonsterTable();
var newTable = new InMemoryConfigTable<int, MonsterConfigStub>(
new[]
{
new MonsterConfigStub(3, "Orc")
},
static config => config.Id);
registry.RegisterTable("monster", oldTable);
registry.RegisterTable("monster", newTable);
var resolved = registry.GetTable<int, MonsterConfigStub>("monster");
Assert.That(resolved, Is.SameAs(newTable));
Assert.That(resolved.Count, Is.EqualTo(1));
}
/// <summary>
/// 验证请求类型与实际注册类型不匹配时会抛出异常,避免消费端默默读取错误表。
/// </summary>
[Test]
public void GetTable_Should_Throw_When_Requested_Type_Does_Not_Match_Registered_Table()
{
var registry = new ConfigRegistry();
registry.RegisterTable("monster", CreateMonsterTable());
Assert.Throws<InvalidOperationException>(() => registry.GetTable<string, MonsterConfigStub>("monster"));
}
/// <summary>
/// 验证移除和清空操作会更新注册表状态。
/// </summary>
[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));
}
/// <summary>
/// 创建怪物配置表测试实例。
/// </summary>
/// <returns>怪物配置表。</returns>
private static IConfigTable<int, MonsterConfigStub> CreateMonsterTable()
{
return new InMemoryConfigTable<int, MonsterConfigStub>(
new[]
{
new MonsterConfigStub(1, "Slime"),
new MonsterConfigStub(2, "Goblin")
},
static config => config.Id);
}
/// <summary>
/// 创建 NPC 配置表测试实例。
/// </summary>
/// <returns>NPC 配置表。</returns>
private static IConfigTable<Guid, NpcConfigStub> CreateNpcTable()
{
return new InMemoryConfigTable<Guid, NpcConfigStub>(
new[]
{
new NpcConfigStub(Guid.NewGuid(), "Guide")
},
static config => config.Id);
}
/// <summary>
/// 用于怪物配置表测试的最小配置类型。
/// </summary>
/// <param name="Id">配置主键。</param>
/// <param name="Name">配置名称。</param>
private sealed record MonsterConfigStub(int Id, string Name);
/// <summary>
/// 用于 NPC 配置表测试的最小配置类型。
/// </summary>
/// <param name="Id">配置主键。</param>
/// <param name="Name">配置名称。</param>
private sealed record NpcConfigStub(Guid Id, string Name);
}

View File

@ -0,0 +1,72 @@
using GFramework.Game.Config;
namespace GFramework.Game.Tests.Config;
/// <summary>
/// 验证内存配置表的基础只读查询行为。
/// </summary>
[TestFixture]
public class InMemoryConfigTableTests
{
/// <summary>
/// 验证已存在主键可以被正确查询。
/// </summary>
[Test]
public void Get_Should_Return_Config_When_Key_Exists()
{
var table = new InMemoryConfigTable<int, MonsterConfigStub>(
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"));
}
/// <summary>
/// 验证重复主键会在加载期被拒绝,避免运行期覆盖旧值。
/// </summary>
[Test]
public void Constructor_Should_Throw_When_Duplicate_Key_Is_Detected()
{
Assert.Throws<InvalidOperationException>(() =>
new InMemoryConfigTable<int, MonsterConfigStub>(
new[]
{
new MonsterConfigStub(1, "Slime"),
new MonsterConfigStub(1, "Goblin")
},
static config => config.Id));
}
/// <summary>
/// 验证 All 返回的集合包含完整快照。
/// </summary>
[Test]
public void All_Should_Return_All_Configs()
{
var table = new InMemoryConfigTable<int, MonsterConfigStub>(
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" }));
}
/// <summary>
/// 用于配置表测试的最小配置类型。
/// </summary>
/// <param name="Id">配置主键。</param>
/// <param name="Name">配置名称。</param>
private sealed record MonsterConfigStub(int Id, string Name);
}

View File

@ -0,0 +1,162 @@
using GFramework.Game.Abstractions.Config;
namespace GFramework.Game.Config;
/// <summary>
/// 默认配置注册表实现。
/// 该类型负责统一管理按名称注册的配置表,并在消费端提供类型安全的解析入口。
/// 为了支持开发期热重载,注册行为采用覆盖策略而不是拒绝重复名称。
/// </summary>
public sealed class ConfigRegistry : IConfigRegistry
{
private const string NameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace.";
private readonly ConcurrentDictionary<string, IConfigTable> _tables = new(StringComparer.Ordinal);
/// <summary>
/// 获取已注册的配置表数量。
/// </summary>
public int Count => _tables.Count;
/// <summary>
/// 获取所有已注册配置表的名称集合,按字典序排序。
/// </summary>
/// <returns>返回只读的配置表名称集合。</returns>
public IReadOnlyCollection<string> GetTableNames()
{
return _tables.Keys.OrderBy(static key => key, StringComparer.Ordinal).ToArray();
}
/// <summary>
/// 注册一个配置表到注册表中。
/// 如果同名的配置表已存在,则会覆盖原有注册以支持热重载。
/// </summary>
/// <typeparam name="TKey">配置表主键的类型,必须为非空类型。</typeparam>
/// <typeparam name="TValue">配置表值的类型。</typeparam>
/// <param name="name">配置表的注册名称,用于后续查找。</param>
/// <param name="table">要注册的配置表实例。</param>
/// <exception cref="ArgumentException">当 <paramref name="name" /> 为 null、空或仅包含空白字符时抛出。</exception>
/// <exception cref="ArgumentNullException">当 <paramref name="table" /> 为 null 时抛出。</exception>
public void RegisterTable<TKey, TValue>(string name, IConfigTable<TKey, TValue> table)
where TKey : notnull
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name));
}
ArgumentNullException.ThrowIfNull(table);
_tables[name] = table;
}
/// <summary>
/// 根据名称获取已注册的配置表,并进行类型验证。
/// </summary>
/// <typeparam name="TKey">期望的主键类型,必须为非空类型。</typeparam>
/// <typeparam name="TValue">期望的值类型。</typeparam>
/// <param name="name">要查找的配置表名称。</param>
/// <returns>返回类型匹配的配置表实例。</returns>
/// <exception cref="ArgumentException">当 <paramref name="name" /> 为 null、空或仅包含空白字符时抛出。</exception>
/// <exception cref="KeyNotFoundException">当指定名称的配置表不存在时抛出。</exception>
/// <exception cref="InvalidOperationException">
/// 当找到的配置表类型与请求的类型不匹配时抛出。
/// </exception>
public IConfigTable<TKey, TValue> GetTable<TKey, TValue>(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<TKey, TValue> 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}'.");
}
/// <summary>
/// 尝试根据名称获取配置表,操作失败时不会抛出异常。
/// </summary>
/// <typeparam name="TKey">期望的主键类型,必须为非空类型。</typeparam>
/// <typeparam name="TValue">期望的值类型。</typeparam>
/// <param name="name">要查找的配置表名称。</param>
/// <param name="table">
/// 输出参数,如果查找成功则返回类型匹配的配置表实例,否则为 null。
/// </param>
/// <returns>如果找到指定名称且类型匹配的配置表则返回 true否则返回 false。</returns>
/// <exception cref="ArgumentException">当 <paramref name="name" /> 为 null、空或仅包含空白字符时抛出。</exception>
public bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? 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<TKey, TValue> typedTable)
{
return false;
}
table = typedTable;
return true;
}
/// <summary>
/// 检查指定名称的配置表是否已注册。
/// </summary>
/// <param name="name">要检查的配置表名称。</param>
/// <returns>如果配置表已注册则返回 true否则返回 false。</returns>
/// <exception cref="ArgumentException">当 <paramref name="name" /> 为 null、空或仅包含空白字符时抛出。</exception>
public bool HasTable(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name));
}
return _tables.ContainsKey(name);
}
/// <summary>
/// 从注册表中移除指定名称的配置表。
/// </summary>
/// <param name="name">要移除的配置表名称。</param>
/// <returns>如果配置表存在并被成功移除则返回 true否则返回 false。</returns>
/// <exception cref="ArgumentException">当 <paramref name="name" /> 为 null、空或仅包含空白字符时抛出。</exception>
public bool RemoveTable(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name));
}
return _tables.TryRemove(name, out _);
}
/// <summary>
/// 清空注册表中的所有配置表。
/// </summary>
public void Clear()
{
_tables.Clear();
}
}

View File

@ -0,0 +1,118 @@
using System.Collections.ObjectModel;
using GFramework.Game.Abstractions.Config;
namespace GFramework.Game.Config;
/// <summary>
/// 基于内存字典的只读配置表实现。
/// 该实现用于 Runtime MVP 阶段,为加载器和注册表提供稳定的只读查询对象。
/// </summary>
/// <typeparam name="TKey">配置表主键类型。</typeparam>
/// <typeparam name="TValue">配置项值类型。</typeparam>
public sealed class InMemoryConfigTable<TKey, TValue> : IConfigTable<TKey, TValue>
where TKey : notnull
{
private readonly IReadOnlyCollection<TValue> _allValues;
private readonly IReadOnlyDictionary<TKey, TValue> _entries;
/// <summary>
/// 使用配置项序列和主键选择器创建内存配置表。
/// </summary>
/// <param name="values">配置项序列。</param>
/// <param name="keySelector">用于提取主键的委托。</param>
/// <param name="comparer">可选的主键比较器。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="values" /> 或 <paramref name="keySelector" /> 为空时抛出。</exception>
/// <exception cref="InvalidOperationException">当配置项主键重复时抛出。</exception>
public InMemoryConfigTable(
IEnumerable<TValue> values,
Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? comparer = null)
{
ArgumentNullException.ThrowIfNull(values);
ArgumentNullException.ThrowIfNull(keySelector);
var dictionary = new Dictionary<TKey, TValue>(comparer);
var allValues = new List<TValue>();
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<TKey, TValue>(dictionary);
_allValues = new ReadOnlyCollection<TValue>(allValues);
}
/// <summary>
/// 获取配置表的主键类型。
/// </summary>
public Type KeyType => typeof(TKey);
/// <summary>
/// 获取配置表的值类型。
/// </summary>
public Type ValueType => typeof(TValue);
/// <summary>
/// 获取配置表中配置项的数量。
/// </summary>
public int Count => _entries.Count;
/// <summary>
/// 根据主键获取配置项的值。
/// </summary>
/// <param name="key">要查找的配置项主键。</param>
/// <returns>返回对应主键的配置项值。</returns>
/// <exception cref="KeyNotFoundException">当指定主键的配置项不存在时抛出。</exception>
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;
}
/// <summary>
/// 尝试根据主键获取配置项的值,操作失败时不会抛出异常。
/// </summary>
/// <param name="key">要查找的配置项主键。</param>
/// <param name="value">
/// 输出参数,如果查找成功则返回对应的配置项值,否则为默认值。
/// </param>
/// <returns>如果找到指定主键的配置项则返回 true否则返回 false。</returns>
public bool TryGet(TKey key, out TValue? value)
{
return _entries.TryGetValue(key, out value);
}
/// <summary>
/// 检查指定主键的配置项是否存在于配置表中。
/// </summary>
/// <param name="key">要检查的配置项主键。</param>
/// <returns>如果配置项已存在则返回 true否则返回 false。</returns>
public bool ContainsKey(TKey key)
{
return _entries.ContainsKey(key);
}
/// <summary>
/// 获取配置表中所有配置项的集合。
/// </summary>
/// <returns>返回所有配置项值的只读集合。</returns>
public IReadOnlyCollection<TValue> All()
{
return _allValues;
}
}

View File

@ -134,6 +134,7 @@
<AdditionalFiles Remove="AnalyzerReleases.Unshipped.md"/>
</ItemGroup>
<ItemGroup>
<Folder Include="local-plan\todos\"/>
<Folder Include="local-plan\评估\"/>
</ItemGroup>
</Project>