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}");
+ });
```
当前热重载行为如下: