From 5fa12dcd37f3d19e219bb8a4b3454f6d1cd3747a Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:13:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0YAML=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增YamlConfigLoader类支持基于目录的YAML配置加载 - 添加对.yaml和.yml文件格式的自动识别和解析 - 实现异步加载任务支持取消令牌 - 集成YamlDotNet库进行YAML反序列化处理 - 支持驼峰命名约定和忽略未匹配属性 - 实现配置表注册的链式API设计 - 添加详细的加载过程异常处理和错误信息 - 提供完整的单元测试覆盖各种加载场景 - 更新项目依赖添加YamlDotNet包引用16.3.0版本 --- .../Config/YamlConfigLoaderTests.cs | 202 +++++++++++++ GFramework.Game/Config/YamlConfigLoader.cs | 269 ++++++++++++++++++ GFramework.Game/GFramework.Game.csproj | 1 + 3 files changed, 472 insertions(+) create mode 100644 GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs create mode 100644 GFramework.Game/Config/YamlConfigLoader.cs diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs new file mode 100644 index 0000000..f551eb6 --- /dev/null +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -0,0 +1,202 @@ +using System.IO; +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证 YAML 配置加载器的目录扫描与注册行为。 +/// +[TestFixture] +public class YamlConfigLoaderTests +{ + /// + /// 为每个测试创建独立临时目录,避免文件系统状态互相污染。 + /// + [SetUp] + public void SetUp() + { + _rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_rootPath); + } + + /// + /// 清理测试期间创建的临时目录。 + /// + [TearDown] + public void TearDown() + { + if (Directory.Exists(_rootPath)) + { + Directory.Delete(_rootPath, true); + } + } + + private string _rootPath = null!; + + /// + /// 验证加载器能够扫描 YAML 文件并将结果写入注册表。 + /// + [Test] + public async Task LoadAsync_Should_Register_Table_From_Yaml_Files() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + CreateConfigFile( + "monster/goblin.yml", + """ + id: 2 + name: Goblin + hp: 30 + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", static config => config.Id); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(2)); + Assert.That(table.Get(1).Name, Is.EqualTo("Slime")); + Assert.That(table.Get(2).Hp, Is.EqualTo(30)); + }); + } + + /// + /// 验证注册的配置目录不存在时会抛出清晰错误。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Config_Directory_Does_Not_Exist() + { + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain("monster")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证某个配置表加载失败时,注册表不会留下部分成功的中间状态。 + /// + [Test] + public void LoadAsync_Should_Not_Mutate_Registry_When_A_Later_Table_Fails() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + + var registry = new ConfigRegistry(); + registry.RegisterTable( + "existing", + new InMemoryConfigTable( + new[] + { + new ExistingConfigStub(100, "Original") + }, + static config => config.Id)); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", static config => config.Id) + .RegisterTable("broken", "broken", static config => config.Id); + + Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(registry.Count, Is.EqualTo(1)); + Assert.That(registry.HasTable("monster"), Is.False); + Assert.That(registry.GetTable("existing").Get(100).Name, Is.EqualTo("Original")); + }); + } + + /// + /// 验证非法 YAML 会被包装成带文件路径的反序列化错误。 + /// + [Test] + public void LoadAsync_Should_Throw_With_File_Path_When_Yaml_Is_Invalid() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: [1 + name: Slime + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain("slime.yaml")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 创建测试用配置文件。 + /// + /// 相对根目录的文件路径。 + /// 文件内容。 + private void CreateConfigFile(string relativePath, string content) + { + var fullPath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(fullPath, content); + } + + /// + /// 用于 YAML 加载测试的最小怪物配置类型。 + /// + private sealed class MonsterConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 获取或设置生命值。 + /// + public int Hp { get; set; } + } + + /// + /// 用于验证注册表一致性的现有配置类型。 + /// + /// 配置主键。 + /// 配置名称。 + private sealed record ExistingConfigStub(int Id, string Name); +} \ No newline at end of file diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs new file mode 100644 index 0000000..f26308d --- /dev/null +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -0,0 +1,269 @@ +using System.IO; +using GFramework.Game.Abstractions.Config; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace GFramework.Game.Config; + +/// +/// 基于文件目录的 YAML 配置加载器。 +/// 该实现用于 Runtime MVP 的文本配置接入阶段,通过显式注册表定义描述要加载的配置域, +/// 再在一次加载流程中统一解析并写入配置注册表。 +/// +public sealed class YamlConfigLoader : IConfigLoader +{ + private const string RootPathCannotBeNullOrWhiteSpaceMessage = "Root path cannot be null or whitespace."; + private const string TableNameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace."; + private const string RelativePathCannotBeNullOrWhiteSpaceMessage = "Relative path cannot be null or whitespace."; + + private readonly IDeserializer _deserializer; + private readonly List _registrations = new(); + private readonly string _rootPath; + + /// + /// 使用指定配置根目录创建 YAML 配置加载器。 + /// + /// 配置根目录。 + /// 为空时抛出。 + public YamlConfigLoader(string rootPath) + { + if (string.IsNullOrWhiteSpace(rootPath)) + { + throw new ArgumentException(RootPathCannotBeNullOrWhiteSpaceMessage, nameof(rootPath)); + } + + _rootPath = rootPath; + _deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + } + + /// + /// 获取配置根目录。 + /// + public string RootPath => _rootPath; + + /// + /// 获取当前已注册的配置表定义数量。 + /// + public int RegistrationCount => _registrations.Count; + + /// + public async Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(registry); + + var loadedTables = new List<(string name, IConfigTable table)>(_registrations.Count); + + foreach (var registration in _registrations) + { + cancellationToken.ThrowIfCancellationRequested(); + loadedTables.Add(await registration.LoadAsync(_rootPath, _deserializer, cancellationToken)); + } + + // 仅当本轮所有配置表都成功加载后才写入注册表,避免暴露部分成功的中间状态。 + foreach (var (name, table) in loadedTables) + { + RegistrationDispatcher.Register(registry, name, table); + } + } + + /// + /// 注册一个 YAML 配置表定义。 + /// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。 + /// + /// 配置主键类型。 + /// 配置值类型。 + /// 配置表名称。 + /// 相对配置根目录的子目录。 + /// 配置项主键提取器。 + /// 可选主键比较器。 + /// 当前加载器实例,以便链式注册。 + public YamlConfigLoader RegisterTable( + string tableName, + string relativePath, + Func keySelector, + IEqualityComparer? comparer = null) + where TKey : notnull + { + if (string.IsNullOrWhiteSpace(tableName)) + { + throw new ArgumentException(TableNameCannotBeNullOrWhiteSpaceMessage, nameof(tableName)); + } + + if (string.IsNullOrWhiteSpace(relativePath)) + { + throw new ArgumentException(RelativePathCannotBeNullOrWhiteSpaceMessage, nameof(relativePath)); + } + + ArgumentNullException.ThrowIfNull(keySelector); + + _registrations.Add(new YamlTableRegistration(tableName, relativePath, keySelector, comparer)); + return this; + } + + /// + /// 负责在非泛型配置表与泛型注册表方法之间做分派。 + /// 该静态助手将运行时反射局部封装在加载器内部,避免向外暴露弱类型注册 API。 + /// + private static class RegistrationDispatcher + { + /// + /// 将强类型配置表写入注册表。 + /// + /// 目标配置注册表。 + /// 配置表名称。 + /// 已加载的配置表实例。 + /// 当传入表未实现强类型配置表契约时抛出。 + public static void Register(IConfigRegistry registry, string name, IConfigTable table) + { + var tableInterface = table.GetType() + .GetInterfaces() + .FirstOrDefault(static type => + type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IConfigTable<,>)); + + if (tableInterface == null) + { + throw new InvalidOperationException( + $"Loaded config table '{name}' does not implement '{typeof(IConfigTable<,>).Name}'."); + } + + var genericArguments = tableInterface.GetGenericArguments(); + var method = typeof(IConfigRegistry) + .GetMethod(nameof(IConfigRegistry.RegisterTable))! + .MakeGenericMethod(genericArguments[0], genericArguments[1]); + + method.Invoke(registry, new object[] { name, table }); + } + } + + /// + /// 定义 YAML 配置表注册项的统一内部契约。 + /// + private interface IYamlTableRegistration + { + /// + /// 从指定根目录加载配置表。 + /// + /// 配置根目录。 + /// YAML 反序列化器。 + /// 取消令牌。 + /// 已加载的配置表名称与配置表实例。 + Task<(string name, IConfigTable table)> LoadAsync( + string rootPath, + IDeserializer deserializer, + CancellationToken cancellationToken); + } + + /// + /// YAML 配置表注册项。 + /// + /// 配置主键类型。 + /// 配置项值类型。 + private sealed class YamlTableRegistration : IYamlTableRegistration + where TKey : notnull + { + private readonly IEqualityComparer? _comparer; + private readonly Func _keySelector; + + /// + /// 初始化 YAML 配置表注册项。 + /// + /// 配置表名称。 + /// 相对配置根目录的子目录。 + /// 配置项主键提取器。 + /// 可选主键比较器。 + public YamlTableRegistration( + string name, + string relativePath, + Func keySelector, + IEqualityComparer? comparer) + { + Name = name; + RelativePath = relativePath; + _keySelector = keySelector; + _comparer = comparer; + } + + /// + /// 获取配置表名称。 + /// + public string Name { get; } + + /// + /// 获取相对配置根目录的子目录。 + /// + public string RelativePath { get; } + + /// + public async Task<(string name, IConfigTable table)> LoadAsync( + string rootPath, + IDeserializer deserializer, + CancellationToken cancellationToken) + { + var directoryPath = Path.Combine(rootPath, RelativePath); + if (!Directory.Exists(directoryPath)) + { + throw new DirectoryNotFoundException( + $"Config directory '{directoryPath}' was not found for table '{Name}'."); + } + + var values = new List(); + var files = Directory + .EnumerateFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly) + .Where(static path => + path.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".yml", StringComparison.OrdinalIgnoreCase)) + .OrderBy(static path => path, StringComparer.Ordinal) + .ToArray(); + + foreach (var file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + + string yaml; + try + { + yaml = await File.ReadAllTextAsync(file, cancellationToken); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Failed to read config file '{file}' for table '{Name}'.", + exception); + } + + try + { + var value = deserializer.Deserialize(yaml); + + if (value == null) + { + throw new InvalidOperationException("YAML content was deserialized to null."); + } + + values.Add(value); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Failed to deserialize config file '{file}' for table '{Name}' as '{typeof(TValue).Name}'.", + exception); + } + } + + try + { + var table = new InMemoryConfigTable(values, _keySelector, _comparer); + return (Name, table); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Failed to build config table '{Name}' from directory '{directoryPath}'.", + exception); + } + } + } +} \ No newline at end of file diff --git a/GFramework.Game/GFramework.Game.csproj b/GFramework.Game/GFramework.Game.csproj index 5bb94d9..6a81f01 100644 --- a/GFramework.Game/GFramework.Game.csproj +++ b/GFramework.Game/GFramework.Game.csproj @@ -14,5 +14,6 @@ +