From 12ce31f82a4f0e2c8d5322f4e02412b42bc9a07f Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:00:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现基于 YAML 的配置文件加载功能 - 集成 JSON Schema 结构验证和类型检查 - 提供一对象一文件的目录组织方式 - 支持运行时只读查询和类型安全访问 - 实现 Source Generator 生成配置类型和表包装 - 添加 VS Code 插件提供配置浏览和编辑功能 - 支持跨表引用校验和依赖关系管理 - 实现开发期热重载功能,支持配置变更自动刷新 - 提供完整的配置加载、验证、注册和访问接口 --- .../Config/ConfigLoadDiagnostic.cs | 97 +++++ .../Config/ConfigLoadException.cs | 41 ++ .../Config/ConfigLoadFailureKind.cs | 109 +++++ .../Config/IConfigLoader.cs | 4 + .../Config/YamlConfigLoaderTests.cs | 61 ++- .../Config/ConfigLoadExceptionFactory.cs | 51 +++ GFramework.Game/Config/YamlConfigLoader.cs | 92 ++++- .../Config/YamlConfigSchemaValidator.cs | 373 ++++++++++++++---- docs/zh-CN/game/config-system.md | 36 +- 9 files changed, 745 insertions(+), 119 deletions(-) create mode 100644 GFramework.Game.Abstractions/Config/ConfigLoadDiagnostic.cs create mode 100644 GFramework.Game.Abstractions/Config/ConfigLoadException.cs create mode 100644 GFramework.Game.Abstractions/Config/ConfigLoadFailureKind.cs create mode 100644 GFramework.Game/Config/ConfigLoadExceptionFactory.cs diff --git a/GFramework.Game.Abstractions/Config/ConfigLoadDiagnostic.cs b/GFramework.Game.Abstractions/Config/ConfigLoadDiagnostic.cs new file mode 100644 index 00000000..2912597f --- /dev/null +++ b/GFramework.Game.Abstractions/Config/ConfigLoadDiagnostic.cs @@ -0,0 +1,97 @@ +namespace GFramework.Game.Abstractions.Config; + +/// +/// 表示一次配置加载失败的结构化诊断信息。 +/// 该模型旨在为日志、测试断言、编辑器联动和热重载失败回调提供稳定字段, +/// 避免调用方只能依赖异常消息文本做脆弱解析。 +/// +public sealed class ConfigLoadDiagnostic +{ + /// + /// 初始化一个配置加载诊断对象。 + /// + /// 失败类别。 + /// 所属配置表名称。 + /// 配置目录绝对路径;不适用时为空。 + /// 配置文件绝对路径;不适用时为空。 + /// schema 文件绝对路径;不适用时为空。 + /// 逻辑字段路径;无法定位到字段时为空。 + /// 跨表引用目标表名称;非引用失败时为空。 + /// 原始值或引用值;不适用时为空。 + /// 附加细节,用于补充无法结构化成独立字段的上下文。 + /// 为空时抛出。 + public ConfigLoadDiagnostic( + ConfigLoadFailureKind failureKind, + string tableName, + string? configDirectoryPath = null, + string? yamlPath = null, + string? schemaPath = null, + string? displayPath = null, + string? referencedTableName = null, + string? rawValue = null, + string? detail = null) + { + if (string.IsNullOrWhiteSpace(tableName)) + { + throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName)); + } + + FailureKind = failureKind; + TableName = tableName; + ConfigDirectoryPath = configDirectoryPath; + YamlPath = yamlPath; + SchemaPath = schemaPath; + DisplayPath = displayPath; + ReferencedTableName = referencedTableName; + RawValue = rawValue; + Detail = detail; + } + + /// + /// 获取失败类别。 + /// + public ConfigLoadFailureKind FailureKind { get; } + + /// + /// 获取所属配置表名称。 + /// + public string TableName { get; } + + /// + /// 获取配置目录绝对路径。 + /// + public string? ConfigDirectoryPath { get; } + + /// + /// 获取触发失败的 YAML 文件绝对路径。 + /// + public string? YamlPath { get; } + + /// + /// 获取触发失败的 schema 文件绝对路径。 + /// + public string? SchemaPath { get; } + + /// + /// 获取便于展示的字段路径。 + /// 对于根级失败或文件级失败,该值可能为空。 + /// + public string? DisplayPath { get; } + + /// + /// 获取跨表引用目标表名称。 + /// + public string? ReferencedTableName { get; } + + /// + /// 获取与失败相关的原始值。 + /// 该字段通常用于 enum 违规、跨表引用缺失或类型转换失败等场景。 + /// + public string? RawValue { get; } + + /// + /// 获取补充细节。 + /// 当失败上下文无法拆成更多稳定字段时,该值用于保留关键说明。 + /// + public string? Detail { get; } +} \ No newline at end of file diff --git a/GFramework.Game.Abstractions/Config/ConfigLoadException.cs b/GFramework.Game.Abstractions/Config/ConfigLoadException.cs new file mode 100644 index 00000000..bee1d31b --- /dev/null +++ b/GFramework.Game.Abstractions/Config/ConfigLoadException.cs @@ -0,0 +1,41 @@ +namespace GFramework.Game.Abstractions.Config; + +/// +/// 表示配置加载流程中的结构化失败。 +/// 该异常保留原有异常链,同时通过 暴露稳定字段, +/// 便于上层在不解析消息文本的情况下识别失败表、文件和字段位置。 +/// +public sealed class ConfigLoadException : InvalidOperationException +{ + /// + /// 初始化一个配置加载异常。 + /// + /// 结构化诊断信息。 + /// 面向人类阅读的错误消息。 + /// 底层异常;不存在时为空。 + /// 为空时抛出。 + /// 为空时抛出。 + public ConfigLoadException( + ConfigLoadDiagnostic diagnostic, + string message, + Exception? innerException = null) + : base(message, innerException) + { + if (diagnostic == null) + { + throw new ArgumentNullException(nameof(diagnostic)); + } + + if (string.IsNullOrWhiteSpace(message)) + { + throw new ArgumentException("Exception message cannot be null or whitespace.", nameof(message)); + } + + Diagnostic = diagnostic; + } + + /// + /// 获取结构化诊断信息。 + /// + public ConfigLoadDiagnostic Diagnostic { get; } +} \ No newline at end of file diff --git a/GFramework.Game.Abstractions/Config/ConfigLoadFailureKind.cs b/GFramework.Game.Abstractions/Config/ConfigLoadFailureKind.cs new file mode 100644 index 00000000..be0b3ef6 --- /dev/null +++ b/GFramework.Game.Abstractions/Config/ConfigLoadFailureKind.cs @@ -0,0 +1,109 @@ +namespace GFramework.Game.Abstractions.Config; + +/// +/// 表示配置加载过程中可稳定断言的失败类别。 +/// 该枚举用于把文件系统、schema 校验、反序列化和跨表引用错误从自由文本消息中抽离出来, +/// 便于日志、测试和上层工具以结构化方式处理失败原因。 +/// +public enum ConfigLoadFailureKind +{ + /// + /// 配置目录不存在。 + /// + ConfigDirectoryNotFound, + + /// + /// 绑定的 schema 文件不存在。 + /// + SchemaFileNotFound, + + /// + /// 读取 schema 文件失败。 + /// + SchemaReadFailed, + + /// + /// schema 文件不是合法 JSON。 + /// + SchemaInvalidJson, + + /// + /// schema 内容超出了当前运行时支持的子集或不满足最小约束。 + /// + SchemaUnsupported, + + /// + /// 读取配置文件失败。 + /// + ConfigFileReadFailed, + + /// + /// YAML 文本在进入 schema 校验阶段前无法被解析。 + /// + YamlParseFailed, + + /// + /// YAML 文档数量或结构不符合运行时约束。 + /// + InvalidYamlDocument, + + /// + /// 对象中出现了重复字段。 + /// + DuplicateProperty, + + /// + /// YAML 中出现了 schema 未声明的字段。 + /// + UnknownProperty, + + /// + /// YAML 缺失 schema 要求的字段。 + /// + MissingRequiredProperty, + + /// + /// YAML 值类型与 schema 声明不匹配。 + /// + PropertyTypeMismatch, + + /// + /// YAML 标量值为 null,但 schema 不允许。 + /// + NullScalarValue, + + /// + /// YAML 标量值不在 schema 声明的 enum 集合中。 + /// + EnumValueNotAllowed, + + /// + /// YAML 可被读取,但无法成功反序列化到目标 CLR 类型。 + /// + DeserializationFailed, + + /// + /// 已解析的配置项无法构造成运行时配置表。 + /// + TableBuildFailed, + + /// + /// 跨表引用声明的目标表不可用。 + /// + ReferencedTableNotFound, + + /// + /// 跨表引用值无法转换到目标表主键类型。 + /// + ReferenceKeyTypeMismatch, + + /// + /// 跨表引用值在目标表中不存在。 + /// + ReferencedKeyNotFound, + + /// + /// 兜底的未分类失败。 + /// + UnexpectedFailure +} \ No newline at end of file diff --git a/GFramework.Game.Abstractions/Config/IConfigLoader.cs b/GFramework.Game.Abstractions/Config/IConfigLoader.cs index 50abd080..4d3be28e 100644 --- a/GFramework.Game.Abstractions/Config/IConfigLoader.cs +++ b/GFramework.Game.Abstractions/Config/IConfigLoader.cs @@ -15,5 +15,9 @@ public interface IConfigLoader : IUtility /// 用于接收配置表的注册表。 /// 取消令牌。 /// 表示异步加载流程的任务。 + /// + /// 当配置文件、schema、反序列化或跨表引用校验失败时抛出。 + /// 调用方可以通过 读取稳定的结构化字段。 + /// Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index d5269b8f..d5008f44 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -9,6 +9,8 @@ namespace GFramework.Game.Tests.Config; [TestFixture] public class YamlConfigLoaderTests { + private string _rootPath = null!; + /// /// 为每个测试创建独立临时目录,避免文件系统状态互相污染。 /// @@ -31,8 +33,6 @@ public class YamlConfigLoaderTests } } - private string _rootPath = null!; - /// /// 验证加载器能够扫描 YAML 文件并将结果写入注册表。 /// @@ -80,12 +80,16 @@ public class YamlConfigLoaderTests .RegisterTable("monster", "monster", static config => config.Id); var registry = new ConfigRegistry(); - var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + 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(exception.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConfigDirectoryNotFound)); + Assert.That(exception.Diagnostic.TableName, Is.EqualTo("monster")); + Assert.That(exception.Diagnostic.ConfigDirectoryPath, + Is.EqualTo(Path.Combine(_rootPath, "monster"))); Assert.That(registry.Count, Is.EqualTo(0)); }); } @@ -118,10 +122,14 @@ public class YamlConfigLoaderTests .RegisterTable("monster", "monster", static config => config.Id) .RegisterTable("broken", "broken", static config => config.Id); - Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); Assert.Multiple(() => { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, + Is.EqualTo(ConfigLoadFailureKind.ConfigDirectoryNotFound)); + Assert.That(exception.Diagnostic.TableName, Is.EqualTo("broken")); 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")); @@ -145,7 +153,7 @@ public class YamlConfigLoaderTests .RegisterTable("monster", "monster", static config => config.Id); var registry = new ConfigRegistry(); - var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); Assert.Multiple(() => { @@ -186,12 +194,19 @@ public class YamlConfigLoaderTests static config => config.Id); var registry = new ConfigRegistry(); - var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); Assert.Multiple(() => { Assert.That(exception, Is.Not.Null); Assert.That(exception!.Message, Does.Contain("name")); + Assert.That(exception.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.MissingRequiredProperty)); + Assert.That(exception.Diagnostic.TableName, Is.EqualTo("monster")); + Assert.That(exception.Diagnostic.YamlPath, + Does.EndWith("monster/slime.yaml").Or.EndWith("monster\\slime.yaml")); + Assert.That(exception.Diagnostic.SchemaPath, + Does.EndWith("schemas/monster.schema.json").Or.EndWith("schemas\\monster.schema.json")); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("name")); Assert.That(registry.Count, Is.EqualTo(0)); }); } @@ -228,7 +243,7 @@ public class YamlConfigLoaderTests static config => config.Id); var registry = new ConfigRegistry(); - var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); Assert.Multiple(() => { @@ -274,7 +289,7 @@ public class YamlConfigLoaderTests static config => config.Id); var registry = new ConfigRegistry(); - var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); Assert.Multiple(() => { @@ -317,7 +332,7 @@ public class YamlConfigLoaderTests static config => config.Id); var registry = new ConfigRegistry(); - var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); Assert.Multiple(() => { @@ -367,7 +382,7 @@ public class YamlConfigLoaderTests static config => config.Id); var registry = new ConfigRegistry(); - var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); Assert.Multiple(() => { @@ -417,7 +432,7 @@ public class YamlConfigLoaderTests static config => config.Id); var registry = new ConfigRegistry(); - var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); Assert.Multiple(() => { @@ -468,7 +483,7 @@ public class YamlConfigLoaderTests static config => config.Id); var registry = new ConfigRegistry(); - var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); Assert.Multiple(() => { @@ -524,7 +539,7 @@ public class YamlConfigLoaderTests static config => config.Id); var registry = new ConfigRegistry(); - var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); Assert.Multiple(() => { @@ -605,7 +620,7 @@ public class YamlConfigLoaderTests static config => config.Id); var registry = new ConfigRegistry(); - var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); Assert.Multiple(() => { @@ -736,7 +751,7 @@ public class YamlConfigLoaderTests static config => config.Id); var registry = new ConfigRegistry(); - var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); Assert.Multiple(() => { @@ -811,7 +826,7 @@ public class YamlConfigLoaderTests static config => config.Id); var registry = new ConfigRegistry(); - var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); Assert.Multiple(() => { @@ -945,11 +960,17 @@ public class YamlConfigLoaderTests """); var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5)); + var diagnosticException = failure.Exception as ConfigLoadException; Assert.Multiple(() => { Assert.That(failure.TableName, Is.EqualTo("monster")); Assert.That(failure.Exception.Message, Does.Contain("rarity")); + Assert.That(diagnosticException, Is.Not.Null); + Assert.That(diagnosticException!.Diagnostic.FailureKind, + Is.EqualTo(ConfigLoadFailureKind.MissingRequiredProperty)); + Assert.That(diagnosticException.Diagnostic.TableName, Is.EqualTo("monster")); + Assert.That(diagnosticException.Diagnostic.DisplayPath, Is.EqualTo("rarity")); Assert.That(registry.GetTable("monster").Get(1).Hp, Is.EqualTo(10)); }); } @@ -1034,11 +1055,19 @@ public class YamlConfigLoaderTests """); var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5)); + var diagnosticException = failure.Exception as ConfigLoadException; Assert.Multiple(() => { Assert.That(failure.TableName, Is.EqualTo("item")); Assert.That(failure.Exception.Message, Does.Contain("dropItemId")); + Assert.That(diagnosticException, Is.Not.Null); + Assert.That(diagnosticException!.Diagnostic.FailureKind, + Is.EqualTo(ConfigLoadFailureKind.ReferencedKeyNotFound)); + Assert.That(diagnosticException.Diagnostic.TableName, Is.EqualTo("monster")); + Assert.That(diagnosticException.Diagnostic.ReferencedTableName, Is.EqualTo("item")); + Assert.That(diagnosticException.Diagnostic.DisplayPath, Is.EqualTo("dropItemId")); + Assert.That(diagnosticException.Diagnostic.RawValue, Is.EqualTo("potion")); Assert.That(registry.GetTable("item").ContainsKey("potion"), Is.True); Assert.That(registry.GetTable("monster").Get(1).DropItemId, Is.EqualTo("potion")); diff --git a/GFramework.Game/Config/ConfigLoadExceptionFactory.cs b/GFramework.Game/Config/ConfigLoadExceptionFactory.cs new file mode 100644 index 00000000..b7639aca --- /dev/null +++ b/GFramework.Game/Config/ConfigLoadExceptionFactory.cs @@ -0,0 +1,51 @@ +using GFramework.Game.Abstractions.Config; + +namespace GFramework.Game.Config; + +/// +/// 负责在运行时配置系统内部构造结构化加载异常。 +/// 该工厂集中封装诊断字段填充,避免不同失败路径对同一语义产生不一致的消息和字段约定。 +/// +internal static class ConfigLoadExceptionFactory +{ + /// + /// 创建一个包含结构化诊断信息的配置加载异常。 + /// + /// 失败类别。 + /// 配置表名称。 + /// 错误消息。 + /// 配置目录绝对路径;不适用时为空。 + /// YAML 文件绝对路径;不适用时为空。 + /// schema 文件绝对路径;不适用时为空。 + /// 逻辑字段路径;不适用时为空。 + /// 跨表引用目标表名称;不适用时为空。 + /// 原始值或引用值;不适用时为空。 + /// 附加细节;不适用时为空。 + /// 底层异常;不适用时为空。 + /// 构造完成的配置加载异常。 + internal static ConfigLoadException Create( + ConfigLoadFailureKind failureKind, + string tableName, + string message, + string? configDirectoryPath = null, + string? yamlPath = null, + string? schemaPath = null, + string? displayPath = null, + string? referencedTableName = null, + string? rawValue = null, + string? detail = null, + Exception? innerException = null) + { + var diagnostic = new ConfigLoadDiagnostic( + failureKind, + tableName, + configDirectoryPath, + yamlPath, + schemaPath, + displayPath, + referencedTableName, + rawValue, + detail); + return new ConfigLoadException(diagnostic, message, innerException); + } +} \ No newline at end of file diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index 3ea6cdbd..3a46b7f3 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -1,7 +1,4 @@ -using GFramework.Core.Abstractions.Events; using GFramework.Game.Abstractions.Config; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; namespace GFramework.Game.Config; @@ -87,7 +84,10 @@ public sealed class YamlConfigLoader : IConfigLoader /// /// 要被热重载更新的配置注册表。 /// 单个配置表重载成功后的可选回调。 - /// 单个配置表重载失败后的可选回调。 + /// + /// 单个配置表重载失败后的可选回调。 + /// 当失败来自加载器本身时,传入异常通常为 ,可从其诊断对象读取稳定字段。 + /// /// 防抖延迟;为空时默认使用 200 毫秒。 /// 用于停止热重载监听的注销句柄。 /// 为空时抛出。 @@ -327,8 +327,11 @@ public sealed class YamlConfigLoader : IConfigLoader var directoryPath = Path.Combine(rootPath, RelativePath); if (!Directory.Exists(directoryPath)) { - throw new DirectoryNotFoundException( - $"Config directory '{directoryPath}' was not found for table '{Name}'."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConfigDirectoryNotFound, + Name, + $"Config directory '{directoryPath}' was not found for table '{Name}'.", + configDirectoryPath: directoryPath); } YamlConfigSchema? schema = null; @@ -336,7 +339,7 @@ public sealed class YamlConfigLoader : IConfigLoader if (!string.IsNullOrEmpty(SchemaRelativePath)) { var schemaPath = Path.Combine(rootPath, SchemaRelativePath); - schema = await YamlConfigSchemaValidator.LoadAsync(schemaPath, cancellationToken); + schema = await YamlConfigSchemaValidator.LoadAsync(Name, schemaPath, cancellationToken); referencedTableNames = schema.ReferencedTableNames; } @@ -359,18 +362,27 @@ public sealed class YamlConfigLoader : IConfigLoader { yaml = await File.ReadAllTextAsync(file, cancellationToken); } + catch (ConfigLoadException) + { + throw; + } catch (Exception exception) { - throw new InvalidOperationException( + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConfigFileReadFailed, + Name, $"Failed to read config file '{file}' for table '{Name}'.", - exception); + configDirectoryPath: directoryPath, + yamlPath: file, + schemaPath: schema?.SchemaPath, + innerException: exception); } if (schema != null) { // 先按 schema 拒绝结构问题并提取跨表引用,避免被 IgnoreUnmatchedProperties 或默认值掩盖配置错误。 referenceUsages.AddRange( - YamlConfigSchemaValidator.ValidateAndCollectReferences(schema, file, yaml)); + YamlConfigSchemaValidator.ValidateAndCollectReferences(Name, schema, file, yaml)); } try @@ -384,11 +396,21 @@ public sealed class YamlConfigLoader : IConfigLoader values.Add(value); } + catch (ConfigLoadException) + { + throw; + } catch (Exception exception) { - throw new InvalidOperationException( + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.DeserializationFailed, + Name, $"Failed to deserialize config file '{file}' for table '{Name}' as '{typeof(TValue).Name}'.", - exception); + configDirectoryPath: directoryPath, + yamlPath: file, + schemaPath: schema?.SchemaPath, + detail: $"Target CLR type: {typeof(TValue).FullName}.", + innerException: exception); } } @@ -397,11 +419,19 @@ public sealed class YamlConfigLoader : IConfigLoader var table = new InMemoryConfigTable(values, _keySelector, _comparer); return new YamlTableLoadResult(Name, table, referencedTableNames, referenceUsages); } + catch (ConfigLoadException) + { + throw; + } catch (Exception exception) { - throw new InvalidOperationException( + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.TableBuildFailed, + Name, $"Failed to build config table '{Name}' from directory '{directoryPath}'.", - exception); + configDirectoryPath: directoryPath, + schemaPath: schema?.SchemaPath, + innerException: exception); } } } @@ -482,21 +512,43 @@ public sealed class YamlConfigLoader : IConfigLoader if (!TryResolveTargetTable(registry, loadedTableLookup, referenceUsage.ReferencedTableName, out var targetTable)) { - throw new InvalidOperationException( - $"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' references table '{referenceUsage.ReferencedTableName}', but that table is not available in the current loader batch or registry."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ReferencedTableNotFound, + loadedTable.Name, + $"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' references table '{referenceUsage.ReferencedTableName}', but that table is not available in the current loader batch or registry.", + yamlPath: referenceUsage.YamlPath, + schemaPath: referenceUsage.SchemaPath, + displayPath: referenceUsage.DisplayPath, + referencedTableName: referenceUsage.ReferencedTableName, + rawValue: referenceUsage.RawValue); } if (!TryConvertReferenceKey(referenceUsage, targetTable.KeyType, out var convertedKey, out var conversionError)) { - throw new InvalidOperationException( - $"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' cannot target table '{referenceUsage.ReferencedTableName}' with key type '{targetTable.KeyType.Name}'. {conversionError}"); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ReferenceKeyTypeMismatch, + loadedTable.Name, + $"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' cannot target table '{referenceUsage.ReferencedTableName}' with key type '{targetTable.KeyType.Name}'. {conversionError}", + yamlPath: referenceUsage.YamlPath, + schemaPath: referenceUsage.SchemaPath, + displayPath: referenceUsage.DisplayPath, + referencedTableName: referenceUsage.ReferencedTableName, + rawValue: referenceUsage.RawValue, + detail: conversionError); } if (!ContainsKey(targetTable, convertedKey!)) { - throw new InvalidOperationException( - $"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' references missing key '{referenceUsage.RawValue}' in table '{referenceUsage.ReferencedTableName}'."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ReferencedKeyNotFound, + loadedTable.Name, + $"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' references missing key '{referenceUsage.RawValue}' in table '{referenceUsage.ReferencedTableName}'.", + yamlPath: referenceUsage.YamlPath, + schemaPath: referenceUsage.SchemaPath, + displayPath: referenceUsage.DisplayPath, + referencedTableName: referenceUsage.ReferencedTableName, + rawValue: referenceUsage.RawValue); } } } diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index a5efcf42..c2eee56f 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -1,3 +1,5 @@ +using GFramework.Game.Abstractions.Config; + namespace GFramework.Game.Config; /// @@ -10,16 +12,23 @@ internal static class YamlConfigSchemaValidator /// /// 从磁盘加载并解析一个 JSON Schema 文件。 /// + /// 所属配置表名称。 /// Schema 文件路径。 /// 取消令牌。 /// 解析后的 schema 模型。 + /// 为空时抛出。 /// 为空时抛出。 - /// 当 schema 文件不存在时抛出。 - /// 当 schema 内容不符合当前运行时支持的子集时抛出。 + /// 当 schema 文件不存在或内容非法时抛出。 internal static async Task LoadAsync( + string tableName, string schemaPath, CancellationToken cancellationToken = default) { + if (string.IsNullOrWhiteSpace(tableName)) + { + throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName)); + } + if (string.IsNullOrWhiteSpace(schemaPath)) { throw new ArgumentException("Schema path cannot be null or whitespace.", nameof(schemaPath)); @@ -27,7 +36,11 @@ internal static class YamlConfigSchemaValidator if (!File.Exists(schemaPath)) { - throw new FileNotFoundException($"Schema file '{schemaPath}' was not found.", schemaPath); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaFileNotFound, + tableName, + $"Schema file '{schemaPath}' was not found.", + schemaPath: schemaPath); } string schemaText; @@ -37,18 +50,26 @@ internal static class YamlConfigSchemaValidator } catch (Exception exception) { - throw new InvalidOperationException($"Failed to read schema file '{schemaPath}'.", exception); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaReadFailed, + tableName, + $"Failed to read schema file '{schemaPath}'.", + schemaPath: schemaPath, + innerException: exception); } try { using var document = JsonDocument.Parse(schemaText); var root = document.RootElement; - var rootNode = ParseNode(schemaPath, "", root, isRoot: true); + var rootNode = ParseNode(tableName, schemaPath, "", root, isRoot: true); if (rootNode.NodeType != YamlConfigSchemaPropertyType.Object) { - throw new InvalidOperationException( - $"Schema file '{schemaPath}' must declare a root object schema."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Schema file '{schemaPath}' must declare a root object schema.", + schemaPath: schemaPath); } var referencedTableNames = new HashSet(StringComparer.Ordinal); @@ -56,43 +77,61 @@ internal static class YamlConfigSchemaValidator return new YamlConfigSchema(schemaPath, rootNode, referencedTableNames.ToArray()); } + catch (ConfigLoadException) + { + throw; + } catch (JsonException exception) { - throw new InvalidOperationException($"Schema file '{schemaPath}' contains invalid JSON.", exception); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaInvalidJson, + tableName, + $"Schema file '{schemaPath}' contains invalid JSON.", + schemaPath: schemaPath, + innerException: exception); } } /// /// 使用已解析的 schema 校验 YAML 文本。 /// + /// 所属配置表名称。 /// 已解析的 schema 模型。 /// YAML 文件路径,仅用于诊断信息。 /// YAML 文本内容。 /// 当参数为空时抛出。 - /// 当 YAML 内容与 schema 不匹配时抛出。 + /// 当 YAML 内容与 schema 不匹配时抛出。 internal static void Validate( + string tableName, YamlConfigSchema schema, string yamlPath, string yamlText) { - ValidateAndCollectReferences(schema, yamlPath, yamlText); + ValidateAndCollectReferences(tableName, schema, yamlPath, yamlText); } /// /// 使用已解析的 schema 校验 YAML 文本,并提取声明过的跨表引用。 /// 该方法让结构校验与引用采集共享同一份 YAML 解析结果,避免加载器重复解析同一文件。 /// + /// 所属配置表名称。 /// 已解析的 schema 模型。 /// YAML 文件路径,仅用于诊断信息。 /// YAML 文本内容。 /// 当前 YAML 文件中声明的跨表引用集合。 /// 当参数为空时抛出。 - /// 当 YAML 内容与 schema 不匹配时抛出。 + /// 当 YAML 内容与 schema 不匹配时抛出。 internal static IReadOnlyList ValidateAndCollectReferences( + string tableName, YamlConfigSchema schema, string yamlPath, string yamlText) { + if (string.IsNullOrWhiteSpace(tableName)) + { + throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName)); + } + ArgumentNullException.ThrowIfNull(schema); ArgumentNullException.ThrowIfNull(yamlPath); ArgumentNullException.ThrowIfNull(yamlText); @@ -105,31 +144,41 @@ internal static class YamlConfigSchemaValidator } catch (Exception exception) { - throw new InvalidOperationException( + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.YamlParseFailed, + tableName, $"Config file '{yamlPath}' could not be parsed as YAML before schema validation.", - exception); + yamlPath: yamlPath, + schemaPath: schema.SchemaPath, + innerException: exception); } if (yamlStream.Documents.Count != 1) { - throw new InvalidOperationException( - $"Config file '{yamlPath}' must contain exactly one YAML document."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.InvalidYamlDocument, + tableName, + $"Config file '{yamlPath}' must contain exactly one YAML document.", + yamlPath: yamlPath, + schemaPath: schema.SchemaPath); } var references = new List(); - ValidateNode(yamlPath, string.Empty, yamlStream.Documents[0].RootNode, schema.RootNode, references); + ValidateNode(tableName, yamlPath, string.Empty, yamlStream.Documents[0].RootNode, schema.RootNode, references); return references; } /// /// 递归解析 schema 节点,使运行时只保留校验真正需要的最小结构信息。 /// + /// 所属配置表名称。 /// Schema 文件路径。 /// 当前节点的逻辑属性路径。 /// Schema JSON 节点。 /// 是否为根节点。 /// 可用于运行时校验的节点模型。 private static YamlConfigSchemaNode ParseNode( + string tableName, string schemaPath, string propertyPath, JsonElement element, @@ -138,54 +187,66 @@ internal static class YamlConfigSchemaValidator if (!element.TryGetProperty("type", out var typeElement) || typeElement.ValueKind != JsonValueKind.String) { - throw new InvalidOperationException( - $"Property '{propertyPath}' in schema file '{schemaPath}' must declare a string 'type'."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare a string 'type'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); } var typeName = typeElement.GetString() ?? string.Empty; - var referenceTableName = TryGetReferenceTableName(schemaPath, propertyPath, element); + var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element); switch (typeName) { case "object": - EnsureReferenceKeywordIsSupported(schemaPath, propertyPath, YamlConfigSchemaPropertyType.Object, + EnsureReferenceKeywordIsSupported(tableName, schemaPath, propertyPath, + YamlConfigSchemaPropertyType.Object, referenceTableName); - return ParseObjectNode(schemaPath, propertyPath, element, isRoot); + return ParseObjectNode(tableName, schemaPath, propertyPath, element, isRoot); case "array": - return ParseArrayNode(schemaPath, propertyPath, element, referenceTableName); + return ParseArrayNode(tableName, schemaPath, propertyPath, element, referenceTableName); case "integer": - return CreateScalarNode(schemaPath, propertyPath, YamlConfigSchemaPropertyType.Integer, element, - referenceTableName); + return CreateScalarNode(tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.Integer, + element, referenceTableName); case "number": - return CreateScalarNode(schemaPath, propertyPath, YamlConfigSchemaPropertyType.Number, element, - referenceTableName); + return CreateScalarNode(tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.Number, + element, referenceTableName); case "boolean": - return CreateScalarNode(schemaPath, propertyPath, YamlConfigSchemaPropertyType.Boolean, element, - referenceTableName); + return CreateScalarNode(tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.Boolean, + element, referenceTableName); case "string": - return CreateScalarNode(schemaPath, propertyPath, YamlConfigSchemaPropertyType.String, element, - referenceTableName); + return CreateScalarNode(tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.String, + element, referenceTableName); default: - throw new InvalidOperationException( - $"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported type '{typeName}'."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported type '{typeName}'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath), + rawValue: typeName); } } /// /// 解析对象节点,保留属性字典与必填集合,以便后续递归校验时逐层定位错误。 /// + /// 所属配置表名称。 /// Schema 文件路径。 /// 对象属性路径。 /// 对象 schema 节点。 /// 是否为根节点。 /// 对象节点模型。 private static YamlConfigSchemaNode ParseObjectNode( + string tableName, string schemaPath, string propertyPath, JsonElement element, @@ -195,8 +256,12 @@ internal static class YamlConfigSchemaValidator propertiesElement.ValueKind != JsonValueKind.Object) { var subject = isRoot ? "root schema" : $"object property '{propertyPath}'"; - throw new InvalidOperationException( - $"The {subject} in schema file '{schemaPath}' must declare an object-valued 'properties' section."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"The {subject} in schema file '{schemaPath}' must declare an object-valued 'properties' section.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); } var requiredProperties = new HashSet(StringComparer.Ordinal); @@ -222,6 +287,7 @@ internal static class YamlConfigSchemaValidator foreach (var property in propertiesElement.EnumerateObject()) { properties[property.Name] = ParseNode( + tableName, schemaPath, CombineSchemaPath(propertyPath, property.Name), property.Value); @@ -242,12 +308,14 @@ internal static class YamlConfigSchemaValidator /// 当前子集支持标量数组和对象数组,不支持数组嵌套数组。 /// 当数组声明跨表引用时,会把引用语义挂到元素节点上,便于后续逐项校验。 /// + /// 所属配置表名称。 /// Schema 文件路径。 /// 数组属性路径。 /// 数组 schema 节点。 /// 声明在数组节点上的目标引用表。 /// 数组节点模型。 private static YamlConfigSchemaNode ParseArrayNode( + string tableName, string schemaPath, string propertyPath, JsonElement element, @@ -256,18 +324,27 @@ internal static class YamlConfigSchemaValidator if (!element.TryGetProperty("items", out var itemsElement) || itemsElement.ValueKind != JsonValueKind.Object) { - throw new InvalidOperationException( - $"Array property '{propertyPath}' in schema file '{schemaPath}' must declare an object-valued 'items' schema."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Array property '{propertyPath}' in schema file '{schemaPath}' must declare an object-valued 'items' schema.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); } - var itemNode = ParseNode(schemaPath, $"{propertyPath}[]", itemsElement); + var itemNode = ParseNode(tableName, schemaPath, $"{propertyPath}[]", itemsElement); if (!string.IsNullOrWhiteSpace(referenceTableName)) { if (itemNode.NodeType != YamlConfigSchemaPropertyType.String && itemNode.NodeType != YamlConfigSchemaPropertyType.Integer) { - throw new InvalidOperationException( - $"Property '{propertyPath}' in schema file '{schemaPath}' uses 'x-gframework-ref-table', but only string, integer, or arrays of those scalar types can declare cross-table references."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' uses 'x-gframework-ref-table', but only string, integer, or arrays of those scalar types can declare cross-table references.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath), + referencedTableName: referenceTableName); } itemNode = itemNode.WithReferenceTable(referenceTableName); @@ -275,8 +352,12 @@ internal static class YamlConfigSchemaValidator if (itemNode.NodeType == YamlConfigSchemaPropertyType.Array) { - throw new InvalidOperationException( - $"Array property '{propertyPath}' in schema file '{schemaPath}' uses unsupported nested array items."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Array property '{propertyPath}' in schema file '{schemaPath}' uses unsupported nested array items.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); } return new YamlConfigSchemaNode( @@ -292,6 +373,7 @@ internal static class YamlConfigSchemaValidator /// /// 创建标量节点,并在解析阶段就完成 enum 与引用约束的兼容性检查。 /// + /// 所属配置表名称。 /// Schema 文件路径。 /// 标量属性路径。 /// 标量类型。 @@ -299,20 +381,21 @@ internal static class YamlConfigSchemaValidator /// 目标引用表名称。 /// 标量节点模型。 private static YamlConfigSchemaNode CreateScalarNode( + string tableName, string schemaPath, string propertyPath, YamlConfigSchemaPropertyType nodeType, JsonElement element, string? referenceTableName) { - EnsureReferenceKeywordIsSupported(schemaPath, propertyPath, nodeType, referenceTableName); + EnsureReferenceKeywordIsSupported(tableName, schemaPath, propertyPath, nodeType, referenceTableName); return new YamlConfigSchemaNode( nodeType, properties: null, requiredProperties: null, itemNode: null, referenceTableName, - ParseEnumValues(schemaPath, propertyPath, element, nodeType, "enum"), + ParseEnumValues(tableName, schemaPath, propertyPath, element, nodeType, "enum"), schemaPath); } @@ -320,12 +403,14 @@ internal static class YamlConfigSchemaValidator /// 递归校验 YAML 节点。 /// 每层都带上逻辑字段路径,这样深层对象与数组元素的错误也能直接定位。 /// + /// 所属配置表名称。 /// YAML 文件路径。 /// 当前字段路径;根节点时为空。 /// 实际 YAML 节点。 /// 对应的 schema 节点。 /// 已收集的跨表引用。 private static void ValidateNode( + string tableName, string yamlPath, string displayPath, YamlNode node, @@ -335,35 +420,43 @@ internal static class YamlConfigSchemaValidator switch (schemaNode.NodeType) { case YamlConfigSchemaPropertyType.Object: - ValidateObjectNode(yamlPath, displayPath, node, schemaNode, references); + ValidateObjectNode(tableName, yamlPath, displayPath, node, schemaNode, references); return; case YamlConfigSchemaPropertyType.Array: - ValidateArrayNode(yamlPath, displayPath, node, schemaNode, references); + ValidateArrayNode(tableName, yamlPath, displayPath, node, schemaNode, references); return; case YamlConfigSchemaPropertyType.Integer: case YamlConfigSchemaPropertyType.Number: case YamlConfigSchemaPropertyType.Boolean: case YamlConfigSchemaPropertyType.String: - ValidateScalarNode(yamlPath, displayPath, node, schemaNode, references); + ValidateScalarNode(tableName, yamlPath, displayPath, node, schemaNode, references); return; default: - throw new InvalidOperationException( - $"Schema node '{displayPath}' uses unsupported runtime node type '{schemaNode.NodeType}'."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.UnexpectedFailure, + tableName, + $"Schema node '{displayPath}' uses unsupported runtime node type '{schemaNode.NodeType}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: schemaNode.NodeType.ToString()); } } /// /// 校验对象节点,同时处理重复字段、未知字段和深层必填字段。 /// + /// 所属配置表名称。 /// YAML 文件路径。 /// 当前对象的逻辑字段路径。 /// 实际 YAML 节点。 /// 对象 schema 节点。 /// 已收集的跨表引用。 private static void ValidateObjectNode( + string tableName, string yamlPath, string displayPath, YamlNode node, @@ -373,8 +466,13 @@ internal static class YamlConfigSchemaValidator if (node is not YamlMappingNode mappingNode) { var subject = displayPath.Length == 0 ? "Root object" : $"Property '{displayPath}'"; - throw new InvalidOperationException( - $"{subject} in config file '{yamlPath}' must be an object."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.PropertyTypeMismatch, + tableName, + $"{subject} in config file '{yamlPath}' must be an object.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath)); } var seenProperties = new HashSet(StringComparer.Ordinal); @@ -384,26 +482,41 @@ internal static class YamlConfigSchemaValidator string.IsNullOrWhiteSpace(keyNode.Value)) { var subject = displayPath.Length == 0 ? "root object" : $"object property '{displayPath}'"; - throw new InvalidOperationException( - $"Config file '{yamlPath}' contains a non-scalar or empty property name inside {subject}."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.InvalidYamlDocument, + tableName, + $"Config file '{yamlPath}' contains a non-scalar or empty property name inside {subject}.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath)); } var propertyName = keyNode.Value; var propertyPath = CombineDisplayPath(displayPath, propertyName); if (!seenProperties.Add(propertyName)) { - throw new InvalidOperationException( - $"Config file '{yamlPath}' contains duplicate property '{propertyPath}'."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.DuplicateProperty, + tableName, + $"Config file '{yamlPath}' contains duplicate property '{propertyPath}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: propertyPath); } if (schemaNode.Properties is null || !schemaNode.Properties.TryGetValue(propertyName, out var propertySchema)) { - throw new InvalidOperationException( - $"Config file '{yamlPath}' contains unknown property '{propertyPath}' that is not declared in schema '{schemaNode.SchemaPathHint}'."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.UnknownProperty, + tableName, + $"Config file '{yamlPath}' contains unknown property '{propertyPath}' that is not declared in schema '{schemaNode.SchemaPathHint}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: propertyPath); } - ValidateNode(yamlPath, propertyPath, entry.Value, propertySchema, references); + ValidateNode(tableName, yamlPath, propertyPath, entry.Value, propertySchema, references); } if (schemaNode.RequiredProperties is null) @@ -418,20 +531,28 @@ internal static class YamlConfigSchemaValidator continue; } - throw new InvalidOperationException( - $"Config file '{yamlPath}' is missing required property '{CombineDisplayPath(displayPath, requiredProperty)}' defined by schema '{schemaNode.SchemaPathHint}'."); + var requiredPath = CombineDisplayPath(displayPath, requiredProperty); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.MissingRequiredProperty, + tableName, + $"Config file '{yamlPath}' is missing required property '{requiredPath}' defined by schema '{schemaNode.SchemaPathHint}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: requiredPath); } } /// /// 校验数组节点,并递归验证每个元素。 /// + /// 所属配置表名称。 /// YAML 文件路径。 /// 数组字段路径。 /// 实际 YAML 节点。 /// 数组 schema 节点。 /// 已收集的跨表引用。 private static void ValidateArrayNode( + string tableName, string yamlPath, string displayPath, YamlNode node, @@ -440,19 +561,30 @@ internal static class YamlConfigSchemaValidator { if (node is not YamlSequenceNode sequenceNode) { - throw new InvalidOperationException( - $"Property '{displayPath}' in config file '{yamlPath}' must be an array."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.PropertyTypeMismatch, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be an array.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath)); } if (schemaNode.ItemNode is null) { - throw new InvalidOperationException( - $"Schema node '{displayPath}' is missing array item information."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.UnexpectedFailure, + tableName, + $"Schema node '{displayPath}' is missing array item information.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath)); } for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) { ValidateNode( + tableName, yamlPath, $"{displayPath}[{itemIndex}]", sequenceNode.Children[itemIndex], @@ -464,12 +596,14 @@ internal static class YamlConfigSchemaValidator /// /// 校验标量节点,并在值有效时收集跨表引用。 /// + /// 所属配置表名称。 /// YAML 文件路径。 /// 标量字段路径。 /// 实际 YAML 节点。 /// 标量 schema 节点。 /// 已收集的跨表引用。 private static void ValidateScalarNode( + string tableName, string yamlPath, string displayPath, YamlNode node, @@ -478,15 +612,25 @@ internal static class YamlConfigSchemaValidator { if (node is not YamlScalarNode scalarNode) { - throw new InvalidOperationException( - $"Property '{displayPath}' in config file '{yamlPath}' must be a scalar value of type '{GetTypeName(schemaNode.NodeType)}'."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.PropertyTypeMismatch, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be a scalar value of type '{GetTypeName(schemaNode.NodeType)}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath)); } var value = scalarNode.Value; if (value is null) { - throw new InvalidOperationException( - $"Property '{displayPath}' in config file '{yamlPath}' cannot be null when schema type is '{GetTypeName(schemaNode.NodeType)}'."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.NullScalarValue, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' cannot be null when schema type is '{GetTypeName(schemaNode.NodeType)}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath)); } var tag = scalarNode.Tag.ToString(); @@ -509,16 +653,29 @@ internal static class YamlConfigSchemaValidator if (!isValid) { - throw new InvalidOperationException( - $"Property '{displayPath}' in config file '{yamlPath}' must be of type '{GetTypeName(schemaNode.NodeType)}', but the current YAML scalar value is '{value}'."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.PropertyTypeMismatch, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be of type '{GetTypeName(schemaNode.NodeType)}', but the current YAML scalar value is '{value}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: value); } var normalizedValue = NormalizeScalarValue(schemaNode.NodeType, value); if (schemaNode.AllowedValues is { Count: > 0 } && !schemaNode.AllowedValues.Contains(normalizedValue, StringComparer.Ordinal)) { - throw new InvalidOperationException( - $"Property '{displayPath}' in config file '{yamlPath}' must be one of [{string.Join(", ", schemaNode.AllowedValues)}], but the current YAML scalar value is '{value}'."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.EnumValueNotAllowed, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be one of [{string.Join(", ", schemaNode.AllowedValues)}], but the current YAML scalar value is '{value}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: value, + detail: $"Allowed values: {string.Join(", ", schemaNode.AllowedValues)}."); } if (schemaNode.ReferenceTableName != null) @@ -526,6 +683,7 @@ internal static class YamlConfigSchemaValidator references.Add( new YamlConfigReferenceUsage( yamlPath, + schemaNode.SchemaPathHint, displayPath, normalizedValue, schemaNode.ReferenceTableName, @@ -536,6 +694,7 @@ internal static class YamlConfigSchemaValidator /// /// 解析 enum,并在读取阶段验证枚举值与字段类型的兼容性。 /// + /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// Schema 节点。 @@ -543,6 +702,7 @@ internal static class YamlConfigSchemaValidator /// 当前读取的关键字名称。 /// 归一化后的枚举值集合;未声明时返回空。 private static IReadOnlyCollection? ParseEnumValues( + string tableName, string schemaPath, string propertyPath, JsonElement element, @@ -556,14 +716,19 @@ internal static class YamlConfigSchemaValidator if (enumElement.ValueKind != JsonValueKind.Array) { - throw new InvalidOperationException( - $"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as an array."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as an array.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); } var allowedValues = new List(); foreach (var item in enumElement.EnumerateArray()) { - allowedValues.Add(NormalizeEnumValue(schemaPath, propertyPath, keywordName, expectedType, item)); + allowedValues.Add( + NormalizeEnumValue(tableName, schemaPath, propertyPath, keywordName, expectedType, item)); } return allowedValues; @@ -572,11 +737,13 @@ internal static class YamlConfigSchemaValidator /// /// 解析跨表引用目标表名称。 /// + /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// Schema 节点。 /// 目标表名称;未声明时返回空。 private static string? TryGetReferenceTableName( + string tableName, string schemaPath, string propertyPath, JsonElement element) @@ -588,15 +755,23 @@ internal static class YamlConfigSchemaValidator if (referenceTableElement.ValueKind != JsonValueKind.String) { - throw new InvalidOperationException( - $"Property '{propertyPath}' in schema file '{schemaPath}' must declare a string 'x-gframework-ref-table' value."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare a string 'x-gframework-ref-table' value.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); } var referenceTableName = referenceTableElement.GetString(); if (string.IsNullOrWhiteSpace(referenceTableName)) { - throw new InvalidOperationException( - $"Property '{propertyPath}' in schema file '{schemaPath}' must declare a non-empty 'x-gframework-ref-table' value."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare a non-empty 'x-gframework-ref-table' value.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); } return referenceTableName; @@ -605,11 +780,13 @@ internal static class YamlConfigSchemaValidator /// /// 验证哪些 schema 类型允许声明跨表引用。 /// + /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// 字段类型。 /// 目标表名称。 private static void EnsureReferenceKeywordIsSupported( + string tableName, string schemaPath, string propertyPath, YamlConfigSchemaPropertyType propertyType, @@ -626,8 +803,13 @@ internal static class YamlConfigSchemaValidator return; } - throw new InvalidOperationException( - $"Property '{propertyPath}' in schema file '{schemaPath}' uses 'x-gframework-ref-table', but only string, integer, or arrays of those scalar types can declare cross-table references."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' uses 'x-gframework-ref-table', but only string, integer, or arrays of those scalar types can declare cross-table references.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath), + referencedTableName: referenceTableName); } /// @@ -661,6 +843,7 @@ internal static class YamlConfigSchemaValidator /// /// 将 schema 中的 enum 单值归一化到运行时比较字符串。 /// + /// 所属配置表名称。 /// Schema 文件路径。 /// 字段路径。 /// 关键字名称。 @@ -668,6 +851,7 @@ internal static class YamlConfigSchemaValidator /// 当前枚举值节点。 /// 归一化后的字符串值。 private static string NormalizeEnumValue( + string tableName, string schemaPath, string propertyPath, string keywordName, @@ -693,11 +877,27 @@ internal static class YamlConfigSchemaValidator } catch { - throw new InvalidOperationException( - $"Property '{propertyPath}' in schema file '{schemaPath}' contains a '{keywordName}' value that is incompatible with schema type '{GetTypeName(expectedType)}'."); + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' contains a '{keywordName}' value that is incompatible with schema type '{GetTypeName(expectedType)}'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); } } + /// + /// 将内部路径转换为适合放入诊断对象的可选字段路径。 + /// + /// 内部使用的属性路径。 + /// 可用于诊断的路径;根节点时返回空。 + private static string? GetDiagnosticPath(string path) + { + return string.IsNullOrWhiteSpace(path) || string.Equals(path, "", StringComparison.Ordinal) + ? null + : path; + } + /// /// 将 YAML 标量值规范化成运行时比较格式。 /// @@ -925,23 +1125,27 @@ internal sealed class YamlConfigReferenceUsage /// 初始化一个跨表引用使用记录。 /// /// 源 YAML 文件路径。 + /// 定义该引用的 schema 文件路径。 /// 声明引用的字段路径。 /// YAML 中的原始标量值。 /// 目标配置表名称。 /// 引用值的 schema 标量类型。 public YamlConfigReferenceUsage( string yamlPath, + string schemaPath, string propertyPath, string rawValue, string referencedTableName, YamlConfigSchemaPropertyType valueType) { ArgumentNullException.ThrowIfNull(yamlPath); + ArgumentNullException.ThrowIfNull(schemaPath); ArgumentNullException.ThrowIfNull(propertyPath); ArgumentNullException.ThrowIfNull(rawValue); ArgumentNullException.ThrowIfNull(referencedTableName); YamlPath = yamlPath; + SchemaPath = schemaPath; PropertyPath = propertyPath; RawValue = rawValue; ReferencedTableName = referencedTableName; @@ -953,6 +1157,11 @@ internal sealed class YamlConfigReferenceUsage /// public string YamlPath { get; } + /// + /// 获取定义该引用的 schema 文件路径。 + /// + public string SchemaPath { get; } + /// /// 获取声明引用的字段路径。 /// diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index c2f1138c..3e72c48a 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -154,11 +154,41 @@ var slime = monsterTable.Get(1); 这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 +加载失败时,`YamlConfigLoader` 会抛出 `ConfigLoadException`。你可以通过 `exception.Diagnostic` 读取稳定字段,而不必解析消息文本: + +```csharp +using GFramework.Game.Abstractions.Config; + +try +{ + await loader.LoadAsync(registry); +} +catch (ConfigLoadException exception) +{ + Console.WriteLine(exception.Diagnostic.FailureKind); + Console.WriteLine(exception.Diagnostic.TableName); + Console.WriteLine(exception.Diagnostic.YamlPath); + Console.WriteLine(exception.Diagnostic.SchemaPath); + Console.WriteLine(exception.Diagnostic.DisplayPath); +} +``` + +当前诊断对象会优先暴露这些字段: + +- `FailureKind` +- `TableName` +- `YamlPath` +- `SchemaPath` +- `DisplayPath` +- `ReferencedTableName` +- `RawValue` + ## 开发期热重载 如果你希望在开发期修改配置文件后自动刷新运行时表,可以在初次加载完成后启用热重载: ```csharp +using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; var registry = new ConfigRegistry(); @@ -175,7 +205,11 @@ var hotReload = loader.EnableHotReload( registry, onTableReloaded: tableName => Console.WriteLine($"Reloaded: {tableName}"), onTableReloadFailed: (tableName, exception) => - Console.WriteLine($"Reload failed: {tableName}, {exception.Message}")); + { + var diagnostic = (exception as ConfigLoadException)?.Diagnostic; + Console.WriteLine($"Reload failed: {tableName}, {exception.Message}"); + Console.WriteLine($"Failure kind: {diagnostic?.FailureKind}"); + }); ``` 当前热重载行为如下: