mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
feat(game): 添加游戏内容配置系统
- 实现基于 YAML 的配置文件加载功能 - 集成 JSON Schema 结构验证和类型检查 - 提供一对象一文件的目录组织方式 - 支持运行时只读查询和类型安全访问 - 实现 Source Generator 生成配置类型和表包装 - 添加 VS Code 插件提供配置浏览和编辑功能 - 支持跨表引用校验和依赖关系管理 - 实现开发期热重载功能,支持配置变更自动刷新 - 提供完整的配置加载、验证、注册和访问接口
This commit is contained in:
parent
a92e514ffe
commit
12ce31f82a
97
GFramework.Game.Abstractions/Config/ConfigLoadDiagnostic.cs
Normal file
97
GFramework.Game.Abstractions/Config/ConfigLoadDiagnostic.cs
Normal file
@ -0,0 +1,97 @@
|
||||
namespace GFramework.Game.Abstractions.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 表示一次配置加载失败的结构化诊断信息。
|
||||
/// 该模型旨在为日志、测试断言、编辑器联动和热重载失败回调提供稳定字段,
|
||||
/// 避免调用方只能依赖异常消息文本做脆弱解析。
|
||||
/// </summary>
|
||||
public sealed class ConfigLoadDiagnostic
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一个配置加载诊断对象。
|
||||
/// </summary>
|
||||
/// <param name="failureKind">失败类别。</param>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="configDirectoryPath">配置目录绝对路径;不适用时为空。</param>
|
||||
/// <param name="yamlPath">配置文件绝对路径;不适用时为空。</param>
|
||||
/// <param name="schemaPath">schema 文件绝对路径;不适用时为空。</param>
|
||||
/// <param name="displayPath">逻辑字段路径;无法定位到字段时为空。</param>
|
||||
/// <param name="referencedTableName">跨表引用目标表名称;非引用失败时为空。</param>
|
||||
/// <param name="rawValue">原始值或引用值;不适用时为空。</param>
|
||||
/// <param name="detail">附加细节,用于补充无法结构化成独立字段的上下文。</param>
|
||||
/// <exception cref="ArgumentException">当 <paramref name="tableName" /> 为空时抛出。</exception>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取失败类别。
|
||||
/// </summary>
|
||||
public ConfigLoadFailureKind FailureKind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取所属配置表名称。
|
||||
/// </summary>
|
||||
public string TableName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取配置目录绝对路径。
|
||||
/// </summary>
|
||||
public string? ConfigDirectoryPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取触发失败的 YAML 文件绝对路径。
|
||||
/// </summary>
|
||||
public string? YamlPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取触发失败的 schema 文件绝对路径。
|
||||
/// </summary>
|
||||
public string? SchemaPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取便于展示的字段路径。
|
||||
/// 对于根级失败或文件级失败,该值可能为空。
|
||||
/// </summary>
|
||||
public string? DisplayPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取跨表引用目标表名称。
|
||||
/// </summary>
|
||||
public string? ReferencedTableName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取与失败相关的原始值。
|
||||
/// 该字段通常用于 enum 违规、跨表引用缺失或类型转换失败等场景。
|
||||
/// </summary>
|
||||
public string? RawValue { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取补充细节。
|
||||
/// 当失败上下文无法拆成更多稳定字段时,该值用于保留关键说明。
|
||||
/// </summary>
|
||||
public string? Detail { get; }
|
||||
}
|
||||
41
GFramework.Game.Abstractions/Config/ConfigLoadException.cs
Normal file
41
GFramework.Game.Abstractions/Config/ConfigLoadException.cs
Normal file
@ -0,0 +1,41 @@
|
||||
namespace GFramework.Game.Abstractions.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 表示配置加载流程中的结构化失败。
|
||||
/// 该异常保留原有异常链,同时通过 <see cref="Diagnostic" /> 暴露稳定字段,
|
||||
/// 便于上层在不解析消息文本的情况下识别失败表、文件和字段位置。
|
||||
/// </summary>
|
||||
public sealed class ConfigLoadException : InvalidOperationException
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一个配置加载异常。
|
||||
/// </summary>
|
||||
/// <param name="diagnostic">结构化诊断信息。</param>
|
||||
/// <param name="message">面向人类阅读的错误消息。</param>
|
||||
/// <param name="innerException">底层异常;不存在时为空。</param>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="diagnostic" /> 为空时抛出。</exception>
|
||||
/// <exception cref="ArgumentException">当 <paramref name="message" /> 为空时抛出。</exception>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取结构化诊断信息。
|
||||
/// </summary>
|
||||
public ConfigLoadDiagnostic Diagnostic { get; }
|
||||
}
|
||||
109
GFramework.Game.Abstractions/Config/ConfigLoadFailureKind.cs
Normal file
109
GFramework.Game.Abstractions/Config/ConfigLoadFailureKind.cs
Normal file
@ -0,0 +1,109 @@
|
||||
namespace GFramework.Game.Abstractions.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 表示配置加载过程中可稳定断言的失败类别。
|
||||
/// 该枚举用于把文件系统、schema 校验、反序列化和跨表引用错误从自由文本消息中抽离出来,
|
||||
/// 便于日志、测试和上层工具以结构化方式处理失败原因。
|
||||
/// </summary>
|
||||
public enum ConfigLoadFailureKind
|
||||
{
|
||||
/// <summary>
|
||||
/// 配置目录不存在。
|
||||
/// </summary>
|
||||
ConfigDirectoryNotFound,
|
||||
|
||||
/// <summary>
|
||||
/// 绑定的 schema 文件不存在。
|
||||
/// </summary>
|
||||
SchemaFileNotFound,
|
||||
|
||||
/// <summary>
|
||||
/// 读取 schema 文件失败。
|
||||
/// </summary>
|
||||
SchemaReadFailed,
|
||||
|
||||
/// <summary>
|
||||
/// schema 文件不是合法 JSON。
|
||||
/// </summary>
|
||||
SchemaInvalidJson,
|
||||
|
||||
/// <summary>
|
||||
/// schema 内容超出了当前运行时支持的子集或不满足最小约束。
|
||||
/// </summary>
|
||||
SchemaUnsupported,
|
||||
|
||||
/// <summary>
|
||||
/// 读取配置文件失败。
|
||||
/// </summary>
|
||||
ConfigFileReadFailed,
|
||||
|
||||
/// <summary>
|
||||
/// YAML 文本在进入 schema 校验阶段前无法被解析。
|
||||
/// </summary>
|
||||
YamlParseFailed,
|
||||
|
||||
/// <summary>
|
||||
/// YAML 文档数量或结构不符合运行时约束。
|
||||
/// </summary>
|
||||
InvalidYamlDocument,
|
||||
|
||||
/// <summary>
|
||||
/// 对象中出现了重复字段。
|
||||
/// </summary>
|
||||
DuplicateProperty,
|
||||
|
||||
/// <summary>
|
||||
/// YAML 中出现了 schema 未声明的字段。
|
||||
/// </summary>
|
||||
UnknownProperty,
|
||||
|
||||
/// <summary>
|
||||
/// YAML 缺失 schema 要求的字段。
|
||||
/// </summary>
|
||||
MissingRequiredProperty,
|
||||
|
||||
/// <summary>
|
||||
/// YAML 值类型与 schema 声明不匹配。
|
||||
/// </summary>
|
||||
PropertyTypeMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// YAML 标量值为 null,但 schema 不允许。
|
||||
/// </summary>
|
||||
NullScalarValue,
|
||||
|
||||
/// <summary>
|
||||
/// YAML 标量值不在 schema 声明的 enum 集合中。
|
||||
/// </summary>
|
||||
EnumValueNotAllowed,
|
||||
|
||||
/// <summary>
|
||||
/// YAML 可被读取,但无法成功反序列化到目标 CLR 类型。
|
||||
/// </summary>
|
||||
DeserializationFailed,
|
||||
|
||||
/// <summary>
|
||||
/// 已解析的配置项无法构造成运行时配置表。
|
||||
/// </summary>
|
||||
TableBuildFailed,
|
||||
|
||||
/// <summary>
|
||||
/// 跨表引用声明的目标表不可用。
|
||||
/// </summary>
|
||||
ReferencedTableNotFound,
|
||||
|
||||
/// <summary>
|
||||
/// 跨表引用值无法转换到目标表主键类型。
|
||||
/// </summary>
|
||||
ReferenceKeyTypeMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// 跨表引用值在目标表中不存在。
|
||||
/// </summary>
|
||||
ReferencedKeyNotFound,
|
||||
|
||||
/// <summary>
|
||||
/// 兜底的未分类失败。
|
||||
/// </summary>
|
||||
UnexpectedFailure
|
||||
}
|
||||
@ -15,5 +15,9 @@ public interface IConfigLoader : IUtility
|
||||
/// <param name="registry">用于接收配置表的注册表。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>表示异步加载流程的任务。</returns>
|
||||
/// <exception cref="ConfigLoadException">
|
||||
/// 当配置文件、schema、反序列化或跨表引用校验失败时抛出。
|
||||
/// 调用方可以通过 <see cref="ConfigLoadException.Diagnostic" /> 读取稳定的结构化字段。
|
||||
/// </exception>
|
||||
Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -9,6 +9,8 @@ namespace GFramework.Game.Tests.Config;
|
||||
[TestFixture]
|
||||
public class YamlConfigLoaderTests
|
||||
{
|
||||
private string _rootPath = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 为每个测试创建独立临时目录,避免文件系统状态互相污染。
|
||||
/// </summary>
|
||||
@ -31,8 +33,6 @@ public class YamlConfigLoaderTests
|
||||
}
|
||||
}
|
||||
|
||||
private string _rootPath = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 验证加载器能够扫描 YAML 文件并将结果写入注册表。
|
||||
/// </summary>
|
||||
@ -80,12 +80,16 @@ public class YamlConfigLoaderTests
|
||||
.RegisterTable<int, MonsterConfigStub>("monster", "monster", static config => config.Id);
|
||||
var registry = new ConfigRegistry();
|
||||
|
||||
var exception = Assert.ThrowsAsync<DirectoryNotFoundException>(async () => await loader.LoadAsync(registry));
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(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<int, MonsterConfigStub>("monster", "monster", static config => config.Id)
|
||||
.RegisterTable<int, MonsterConfigStub>("broken", "broken", static config => config.Id);
|
||||
|
||||
Assert.ThrowsAsync<DirectoryNotFoundException>(async () => await loader.LoadAsync(registry));
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(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<int, ExistingConfigStub>("existing").Get(100).Name, Is.EqualTo("Original"));
|
||||
@ -145,7 +153,7 @@ public class YamlConfigLoaderTests
|
||||
.RegisterTable<int, MonsterConfigStub>("monster", "monster", static config => config.Id);
|
||||
var registry = new ConfigRegistry();
|
||||
|
||||
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(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<InvalidOperationException>(async () => await loader.LoadAsync(registry));
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(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<InvalidOperationException>(async () => await loader.LoadAsync(registry));
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(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<InvalidOperationException>(async () => await loader.LoadAsync(registry));
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(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<InvalidOperationException>(async () => await loader.LoadAsync(registry));
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(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<InvalidOperationException>(async () => await loader.LoadAsync(registry));
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(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<InvalidOperationException>(async () => await loader.LoadAsync(registry));
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(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<InvalidOperationException>(async () => await loader.LoadAsync(registry));
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(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<InvalidOperationException>(async () => await loader.LoadAsync(registry));
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(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<InvalidOperationException>(async () => await loader.LoadAsync(registry));
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(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<InvalidOperationException>(async () => await loader.LoadAsync(registry));
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(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<InvalidOperationException>(async () => await loader.LoadAsync(registry));
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(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<int, MonsterConfigStub>("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<string, ItemConfigStub>("item").ContainsKey("potion"), Is.True);
|
||||
Assert.That(registry.GetTable<int, MonsterDropConfigStub>("monster").Get(1).DropItemId,
|
||||
Is.EqualTo("potion"));
|
||||
|
||||
51
GFramework.Game/Config/ConfigLoadExceptionFactory.cs
Normal file
51
GFramework.Game/Config/ConfigLoadExceptionFactory.cs
Normal file
@ -0,0 +1,51 @@
|
||||
using GFramework.Game.Abstractions.Config;
|
||||
|
||||
namespace GFramework.Game.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 负责在运行时配置系统内部构造结构化加载异常。
|
||||
/// 该工厂集中封装诊断字段填充,避免不同失败路径对同一语义产生不一致的消息和字段约定。
|
||||
/// </summary>
|
||||
internal static class ConfigLoadExceptionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建一个包含结构化诊断信息的配置加载异常。
|
||||
/// </summary>
|
||||
/// <param name="failureKind">失败类别。</param>
|
||||
/// <param name="tableName">配置表名称。</param>
|
||||
/// <param name="message">错误消息。</param>
|
||||
/// <param name="configDirectoryPath">配置目录绝对路径;不适用时为空。</param>
|
||||
/// <param name="yamlPath">YAML 文件绝对路径;不适用时为空。</param>
|
||||
/// <param name="schemaPath">schema 文件绝对路径;不适用时为空。</param>
|
||||
/// <param name="displayPath">逻辑字段路径;不适用时为空。</param>
|
||||
/// <param name="referencedTableName">跨表引用目标表名称;不适用时为空。</param>
|
||||
/// <param name="rawValue">原始值或引用值;不适用时为空。</param>
|
||||
/// <param name="detail">附加细节;不适用时为空。</param>
|
||||
/// <param name="innerException">底层异常;不适用时为空。</param>
|
||||
/// <returns>构造完成的配置加载异常。</returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
/// </summary>
|
||||
/// <param name="registry">要被热重载更新的配置注册表。</param>
|
||||
/// <param name="onTableReloaded">单个配置表重载成功后的可选回调。</param>
|
||||
/// <param name="onTableReloadFailed">单个配置表重载失败后的可选回调。</param>
|
||||
/// <param name="onTableReloadFailed">
|
||||
/// 单个配置表重载失败后的可选回调。
|
||||
/// 当失败来自加载器本身时,传入异常通常为 <see cref="ConfigLoadException" />,可从其诊断对象读取稳定字段。
|
||||
/// </param>
|
||||
/// <param name="debounceDelay">防抖延迟;为空时默认使用 200 毫秒。</param>
|
||||
/// <returns>用于停止热重载监听的注销句柄。</returns>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为空时抛出。</exception>
|
||||
@ -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<TKey, TValue>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
using GFramework.Game.Abstractions.Config;
|
||||
|
||||
namespace GFramework.Game.Config;
|
||||
|
||||
/// <summary>
|
||||
@ -10,16 +12,23 @@ internal static class YamlConfigSchemaValidator
|
||||
/// <summary>
|
||||
/// 从磁盘加载并解析一个 JSON Schema 文件。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>解析后的 schema 模型。</returns>
|
||||
/// <exception cref="ArgumentException">当 <paramref name="tableName" /> 为空时抛出。</exception>
|
||||
/// <exception cref="ArgumentException">当 <paramref name="schemaPath" /> 为空时抛出。</exception>
|
||||
/// <exception cref="FileNotFoundException">当 schema 文件不存在时抛出。</exception>
|
||||
/// <exception cref="InvalidOperationException">当 schema 内容不符合当前运行时支持的子集时抛出。</exception>
|
||||
/// <exception cref="ConfigLoadException">当 schema 文件不存在或内容非法时抛出。</exception>
|
||||
internal static async Task<YamlConfigSchema> 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>", root, isRoot: true);
|
||||
var rootNode = ParseNode(tableName, schemaPath, "<root>", 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<string>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用已解析的 schema 校验 YAML 文本。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schema">已解析的 schema 模型。</param>
|
||||
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
|
||||
/// <param name="yamlText">YAML 文本内容。</param>
|
||||
/// <exception cref="ArgumentNullException">当参数为空时抛出。</exception>
|
||||
/// <exception cref="InvalidOperationException">当 YAML 内容与 schema 不匹配时抛出。</exception>
|
||||
/// <exception cref="ConfigLoadException">当 YAML 内容与 schema 不匹配时抛出。</exception>
|
||||
internal static void Validate(
|
||||
string tableName,
|
||||
YamlConfigSchema schema,
|
||||
string yamlPath,
|
||||
string yamlText)
|
||||
{
|
||||
ValidateAndCollectReferences(schema, yamlPath, yamlText);
|
||||
ValidateAndCollectReferences(tableName, schema, yamlPath, yamlText);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用已解析的 schema 校验 YAML 文本,并提取声明过的跨表引用。
|
||||
/// 该方法让结构校验与引用采集共享同一份 YAML 解析结果,避免加载器重复解析同一文件。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schema">已解析的 schema 模型。</param>
|
||||
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
|
||||
/// <param name="yamlText">YAML 文本内容。</param>
|
||||
/// <returns>当前 YAML 文件中声明的跨表引用集合。</returns>
|
||||
/// <exception cref="ArgumentNullException">当参数为空时抛出。</exception>
|
||||
/// <exception cref="InvalidOperationException">当 YAML 内容与 schema 不匹配时抛出。</exception>
|
||||
/// <exception cref="ConfigLoadException">当 YAML 内容与 schema 不匹配时抛出。</exception>
|
||||
internal static IReadOnlyList<YamlConfigReferenceUsage> 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<YamlConfigReferenceUsage>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 递归解析 schema 节点,使运行时只保留校验真正需要的最小结构信息。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">当前节点的逻辑属性路径。</param>
|
||||
/// <param name="element">Schema JSON 节点。</param>
|
||||
/// <param name="isRoot">是否为根节点。</param>
|
||||
/// <returns>可用于运行时校验的节点模型。</returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析对象节点,保留属性字典与必填集合,以便后续递归校验时逐层定位错误。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">对象属性路径。</param>
|
||||
/// <param name="element">对象 schema 节点。</param>
|
||||
/// <param name="isRoot">是否为根节点。</param>
|
||||
/// <returns>对象节点模型。</returns>
|
||||
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<string>(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
|
||||
/// 当前子集支持标量数组和对象数组,不支持数组嵌套数组。
|
||||
/// 当数组声明跨表引用时,会把引用语义挂到元素节点上,便于后续逐项校验。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">数组属性路径。</param>
|
||||
/// <param name="element">数组 schema 节点。</param>
|
||||
/// <param name="referenceTableName">声明在数组节点上的目标引用表。</param>
|
||||
/// <returns>数组节点模型。</returns>
|
||||
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
|
||||
/// <summary>
|
||||
/// 创建标量节点,并在解析阶段就完成 enum 与引用约束的兼容性检查。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">标量属性路径。</param>
|
||||
/// <param name="nodeType">标量类型。</param>
|
||||
@ -299,20 +381,21 @@ internal static class YamlConfigSchemaValidator
|
||||
/// <param name="referenceTableName">目标引用表名称。</param>
|
||||
/// <returns>标量节点模型。</returns>
|
||||
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 节点。
|
||||
/// 每层都带上逻辑字段路径,这样深层对象与数组元素的错误也能直接定位。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="yamlPath">YAML 文件路径。</param>
|
||||
/// <param name="displayPath">当前字段路径;根节点时为空。</param>
|
||||
/// <param name="node">实际 YAML 节点。</param>
|
||||
/// <param name="schemaNode">对应的 schema 节点。</param>
|
||||
/// <param name="references">已收集的跨表引用。</param>
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验对象节点,同时处理重复字段、未知字段和深层必填字段。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="yamlPath">YAML 文件路径。</param>
|
||||
/// <param name="displayPath">当前对象的逻辑字段路径。</param>
|
||||
/// <param name="node">实际 YAML 节点。</param>
|
||||
/// <param name="schemaNode">对象 schema 节点。</param>
|
||||
/// <param name="references">已收集的跨表引用。</param>
|
||||
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<string>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验数组节点,并递归验证每个元素。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="yamlPath">YAML 文件路径。</param>
|
||||
/// <param name="displayPath">数组字段路径。</param>
|
||||
/// <param name="node">实际 YAML 节点。</param>
|
||||
/// <param name="schemaNode">数组 schema 节点。</param>
|
||||
/// <param name="references">已收集的跨表引用。</param>
|
||||
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
|
||||
/// <summary>
|
||||
/// 校验标量节点,并在值有效时收集跨表引用。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="yamlPath">YAML 文件路径。</param>
|
||||
/// <param name="displayPath">标量字段路径。</param>
|
||||
/// <param name="node">实际 YAML 节点。</param>
|
||||
/// <param name="schemaNode">标量 schema 节点。</param>
|
||||
/// <param name="references">已收集的跨表引用。</param>
|
||||
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
|
||||
/// <summary>
|
||||
/// 解析 enum,并在读取阶段验证枚举值与字段类型的兼容性。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">字段路径。</param>
|
||||
/// <param name="element">Schema 节点。</param>
|
||||
@ -543,6 +702,7 @@ internal static class YamlConfigSchemaValidator
|
||||
/// <param name="keywordName">当前读取的关键字名称。</param>
|
||||
/// <returns>归一化后的枚举值集合;未声明时返回空。</returns>
|
||||
private static IReadOnlyCollection<string>? 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<string>();
|
||||
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
|
||||
/// <summary>
|
||||
/// 解析跨表引用目标表名称。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">字段路径。</param>
|
||||
/// <param name="element">Schema 节点。</param>
|
||||
/// <returns>目标表名称;未声明时返回空。</returns>
|
||||
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
|
||||
/// <summary>
|
||||
/// 验证哪些 schema 类型允许声明跨表引用。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">字段路径。</param>
|
||||
/// <param name="propertyType">字段类型。</param>
|
||||
/// <param name="referenceTableName">目标表名称。</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -661,6 +843,7 @@ internal static class YamlConfigSchemaValidator
|
||||
/// <summary>
|
||||
/// 将 schema 中的 enum 单值归一化到运行时比较字符串。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">字段路径。</param>
|
||||
/// <param name="keywordName">关键字名称。</param>
|
||||
@ -668,6 +851,7 @@ internal static class YamlConfigSchemaValidator
|
||||
/// <param name="item">当前枚举值节点。</param>
|
||||
/// <returns>归一化后的字符串值。</returns>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将内部路径转换为适合放入诊断对象的可选字段路径。
|
||||
/// </summary>
|
||||
/// <param name="path">内部使用的属性路径。</param>
|
||||
/// <returns>可用于诊断的路径;根节点时返回空。</returns>
|
||||
private static string? GetDiagnosticPath(string path)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(path) || string.Equals(path, "<root>", StringComparison.Ordinal)
|
||||
? null
|
||||
: path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 YAML 标量值规范化成运行时比较格式。
|
||||
/// </summary>
|
||||
@ -925,23 +1125,27 @@ internal sealed class YamlConfigReferenceUsage
|
||||
/// 初始化一个跨表引用使用记录。
|
||||
/// </summary>
|
||||
/// <param name="yamlPath">源 YAML 文件路径。</param>
|
||||
/// <param name="schemaPath">定义该引用的 schema 文件路径。</param>
|
||||
/// <param name="propertyPath">声明引用的字段路径。</param>
|
||||
/// <param name="rawValue">YAML 中的原始标量值。</param>
|
||||
/// <param name="referencedTableName">目标配置表名称。</param>
|
||||
/// <param name="valueType">引用值的 schema 标量类型。</param>
|
||||
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
|
||||
/// </summary>
|
||||
public string YamlPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取定义该引用的 schema 文件路径。
|
||||
/// </summary>
|
||||
public string SchemaPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取声明引用的字段路径。
|
||||
/// </summary>
|
||||
|
||||
@ -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}");
|
||||
});
|
||||
```
|
||||
|
||||
当前热重载行为如下:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user