feat(game): 添加游戏内容配置系统

- 实现基于 YAML 的配置文件加载功能
- 集成 JSON Schema 结构验证和类型检查
- 提供一对象一文件的目录组织方式
- 支持运行时只读查询和类型安全访问
- 实现 Source Generator 生成配置类型和表包装
- 添加 VS Code 插件提供配置浏览和编辑功能
- 支持跨表引用校验和依赖关系管理
- 实现开发期热重载功能,支持配置变更自动刷新
- 提供完整的配置加载、验证、注册和访问接口
This commit is contained in:
GeWuYou 2026-04-03 12:00:32 +08:00
parent a92e514ffe
commit 12ce31f82a
9 changed files with 745 additions and 119 deletions

View 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; }
}

View 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; }
}

View 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
}

View File

@ -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);
}

View File

@ -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"));

View 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);
}
}

View File

@ -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);
}
}
}

View File

@ -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>

View File

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