Compare commits

..

No commits in common. "14ed42cabe8f0ed5ff58df45568aac890f470596" and "eaa1e5dff482904cf40ec5a4735a2ec9f0903def" have entirely different histories.

13 changed files with 130 additions and 1055 deletions

View File

@ -1,97 +0,0 @@
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

@ -1,41 +0,0 @@
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

@ -1,109 +0,0 @@
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,9 +15,5 @@ public interface IConfigLoader : IUtility
/// <param name="registry">用于接收配置表的注册表。</param> /// <param name="registry">用于接收配置表的注册表。</param>
/// <param name="cancellationToken">取消令牌。</param> /// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示异步加载流程的任务。</returns> /// <returns>表示异步加载流程的任务。</returns>
/// <exception cref="ConfigLoadException">
/// 当配置文件、schema、反序列化或跨表引用校验失败时抛出。
/// 调用方可以通过 <see cref="ConfigLoadException.Diagnostic" /> 读取稳定的结构化字段。
/// </exception>
Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default); Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default);
} }

View File

@ -1,5 +1,4 @@
using System.IO; using System.IO;
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config; using GFramework.Game.Config;
namespace GFramework.Game.Tests.Config; namespace GFramework.Game.Tests.Config;
@ -81,16 +80,12 @@ public class YamlConfigLoaderTests
.RegisterTable<int, MonsterConfigStub>("monster", "monster", static config => config.Id); .RegisterTable<int, MonsterConfigStub>("monster", "monster", static config => config.Id);
var registry = new ConfigRegistry(); var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry)); var exception = Assert.ThrowsAsync<DirectoryNotFoundException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(exception, Is.Not.Null); Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("monster")); 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)); Assert.That(registry.Count, Is.EqualTo(0));
}); });
} }
@ -123,14 +118,10 @@ public class YamlConfigLoaderTests
.RegisterTable<int, MonsterConfigStub>("monster", "monster", static config => config.Id) .RegisterTable<int, MonsterConfigStub>("monster", "monster", static config => config.Id)
.RegisterTable<int, MonsterConfigStub>("broken", "broken", static config => config.Id); .RegisterTable<int, MonsterConfigStub>("broken", "broken", static config => config.Id);
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry)); Assert.ThrowsAsync<DirectoryNotFoundException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() => 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.Count, Is.EqualTo(1));
Assert.That(registry.HasTable("monster"), Is.False); Assert.That(registry.HasTable("monster"), Is.False);
Assert.That(registry.GetTable<int, ExistingConfigStub>("existing").Get(100).Name, Is.EqualTo("Original")); Assert.That(registry.GetTable<int, ExistingConfigStub>("existing").Get(100).Name, Is.EqualTo("Original"));
@ -154,7 +145,7 @@ public class YamlConfigLoaderTests
.RegisterTable<int, MonsterConfigStub>("monster", "monster", static config => config.Id); .RegisterTable<int, MonsterConfigStub>("monster", "monster", static config => config.Id);
var registry = new ConfigRegistry(); var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry)); var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -195,19 +186,12 @@ public class YamlConfigLoaderTests
static config => config.Id); static config => config.Id);
var registry = new ConfigRegistry(); var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry)); var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(exception, Is.Not.Null); Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("name")); 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)); Assert.That(registry.Count, Is.EqualTo(0));
}); });
} }
@ -244,7 +228,7 @@ public class YamlConfigLoaderTests
static config => config.Id); static config => config.Id);
var registry = new ConfigRegistry(); var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry)); var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -290,7 +274,7 @@ public class YamlConfigLoaderTests
static config => config.Id); static config => config.Id);
var registry = new ConfigRegistry(); var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry)); var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -333,7 +317,7 @@ public class YamlConfigLoaderTests
static config => config.Id); static config => config.Id);
var registry = new ConfigRegistry(); var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry)); var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -383,7 +367,7 @@ public class YamlConfigLoaderTests
static config => config.Id); static config => config.Id);
var registry = new ConfigRegistry(); var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry)); var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -433,7 +417,7 @@ public class YamlConfigLoaderTests
static config => config.Id); static config => config.Id);
var registry = new ConfigRegistry(); var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry)); var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -484,7 +468,7 @@ public class YamlConfigLoaderTests
static config => config.Id); static config => config.Id);
var registry = new ConfigRegistry(); var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry)); var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -540,7 +524,7 @@ public class YamlConfigLoaderTests
static config => config.Id); static config => config.Id);
var registry = new ConfigRegistry(); var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry)); var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -621,7 +605,7 @@ public class YamlConfigLoaderTests
static config => config.Id); static config => config.Id);
var registry = new ConfigRegistry(); var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry)); var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -752,7 +736,7 @@ public class YamlConfigLoaderTests
static config => config.Id); static config => config.Id);
var registry = new ConfigRegistry(); var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry)); var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -827,7 +811,7 @@ public class YamlConfigLoaderTests
static config => config.Id); static config => config.Id);
var registry = new ConfigRegistry(); var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () => await loader.LoadAsync(registry)); var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@ -961,17 +945,11 @@ public class YamlConfigLoaderTests
"""); """);
var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5)); var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5));
var diagnosticException = failure.Exception as ConfigLoadException;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(failure.TableName, Is.EqualTo("monster")); Assert.That(failure.TableName, Is.EqualTo("monster"));
Assert.That(failure.Exception.Message, Does.Contain("rarity")); 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)); Assert.That(registry.GetTable<int, MonsterConfigStub>("monster").Get(1).Hp, Is.EqualTo(10));
}); });
} }
@ -1056,19 +1034,11 @@ public class YamlConfigLoaderTests
"""); """);
var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5)); var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5));
var diagnosticException = failure.Exception as ConfigLoadException;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(failure.TableName, Is.EqualTo("item")); Assert.That(failure.TableName, Is.EqualTo("item"));
Assert.That(failure.Exception.Message, Does.Contain("dropItemId")); 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<string, ItemConfigStub>("item").ContainsKey("potion"), Is.True);
Assert.That(registry.GetTable<int, MonsterDropConfigStub>("monster").Get(1).DropItemId, Assert.That(registry.GetTable<int, MonsterDropConfigStub>("monster").Get(1).DropItemId,
Is.EqualTo("potion")); Is.EqualTo("potion"));

View File

@ -1,51 +0,0 @@
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,4 +1,3 @@
using System.Diagnostics;
using GFramework.Core.Abstractions.Events; using GFramework.Core.Abstractions.Events;
using GFramework.Game.Abstractions.Config; using GFramework.Game.Abstractions.Config;
using YamlDotNet.Serialization; using YamlDotNet.Serialization;
@ -88,10 +87,7 @@ public sealed class YamlConfigLoader : IConfigLoader
/// </summary> /// </summary>
/// <param name="registry">要被热重载更新的配置注册表。</param> /// <param name="registry">要被热重载更新的配置注册表。</param>
/// <param name="onTableReloaded">单个配置表重载成功后的可选回调。</param> /// <param name="onTableReloaded">单个配置表重载成功后的可选回调。</param>
/// <param name="onTableReloadFailed"> /// <param name="onTableReloadFailed">单个配置表重载失败后的可选回调。</param>
/// 单个配置表重载失败后的可选回调。
/// 当失败来自加载器本身时,传入异常通常为 <see cref="ConfigLoadException" />,可从其诊断对象读取稳定字段。
/// </param>
/// <param name="debounceDelay">防抖延迟;为空时默认使用 200 毫秒。</param> /// <param name="debounceDelay">防抖延迟;为空时默认使用 200 毫秒。</param>
/// <returns>用于停止热重载监听的注销句柄。</returns> /// <returns>用于停止热重载监听的注销句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为空时抛出。</exception> /// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为空时抛出。</exception>
@ -300,20 +296,6 @@ public sealed class YamlConfigLoader : IConfigLoader
Func<TValue, TKey> keySelector, Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? comparer) IEqualityComparer<TKey>? comparer)
{ {
Debug.Assert(!string.IsNullOrWhiteSpace(name), "Table registrations should always have a non-empty name.");
Debug.Assert(!string.IsNullOrWhiteSpace(relativePath),
"Table registrations should always have a non-empty relative path.");
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException(TableNameCannotBeNullOrWhiteSpaceMessage, nameof(name));
}
if (string.IsNullOrWhiteSpace(relativePath))
{
throw new ArgumentException(RelativePathCannotBeNullOrWhiteSpaceMessage, nameof(relativePath));
}
Name = name; Name = name;
RelativePath = relativePath; RelativePath = relativePath;
SchemaRelativePath = schemaRelativePath; SchemaRelativePath = schemaRelativePath;
@ -345,11 +327,8 @@ public sealed class YamlConfigLoader : IConfigLoader
var directoryPath = Path.Combine(rootPath, RelativePath); var directoryPath = Path.Combine(rootPath, RelativePath);
if (!Directory.Exists(directoryPath)) if (!Directory.Exists(directoryPath))
{ {
throw ConfigLoadExceptionFactory.Create( throw new DirectoryNotFoundException(
ConfigLoadFailureKind.ConfigDirectoryNotFound, $"Config directory '{directoryPath}' was not found for table '{Name}'.");
Name,
$"Config directory '{directoryPath}' was not found for table '{Name}'.",
configDirectoryPath: directoryPath);
} }
YamlConfigSchema? schema = null; YamlConfigSchema? schema = null;
@ -357,7 +336,7 @@ public sealed class YamlConfigLoader : IConfigLoader
if (!string.IsNullOrEmpty(SchemaRelativePath)) if (!string.IsNullOrEmpty(SchemaRelativePath))
{ {
var schemaPath = Path.Combine(rootPath, SchemaRelativePath); var schemaPath = Path.Combine(rootPath, SchemaRelativePath);
schema = await YamlConfigSchemaValidator.LoadAsync(Name, schemaPath, cancellationToken); schema = await YamlConfigSchemaValidator.LoadAsync(schemaPath, cancellationToken);
referencedTableNames = schema.ReferencedTableNames; referencedTableNames = schema.ReferencedTableNames;
} }
@ -382,21 +361,16 @@ public sealed class YamlConfigLoader : IConfigLoader
} }
catch (Exception exception) catch (Exception exception)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.ConfigFileReadFailed,
Name,
$"Failed to read config file '{file}' for table '{Name}'.", $"Failed to read config file '{file}' for table '{Name}'.",
configDirectoryPath: directoryPath, exception);
yamlPath: file,
schemaPath: schema?.SchemaPath,
innerException: exception);
} }
if (schema != null) if (schema != null)
{ {
// 先按 schema 拒绝结构问题并提取跨表引用,避免被 IgnoreUnmatchedProperties 或默认值掩盖配置错误。 // 先按 schema 拒绝结构问题并提取跨表引用,避免被 IgnoreUnmatchedProperties 或默认值掩盖配置错误。
referenceUsages.AddRange( referenceUsages.AddRange(
YamlConfigSchemaValidator.ValidateAndCollectReferences(Name, schema, file, yaml)); YamlConfigSchemaValidator.ValidateAndCollectReferences(schema, file, yaml));
} }
try try
@ -412,15 +386,9 @@ public sealed class YamlConfigLoader : IConfigLoader
} }
catch (Exception exception) catch (Exception exception)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.DeserializationFailed,
Name,
$"Failed to deserialize config file '{file}' for table '{Name}' as '{typeof(TValue).Name}'.", $"Failed to deserialize config file '{file}' for table '{Name}' as '{typeof(TValue).Name}'.",
configDirectoryPath: directoryPath, exception);
yamlPath: file,
schemaPath: schema?.SchemaPath,
detail: $"Target CLR type: {typeof(TValue).FullName}.",
innerException: exception);
} }
} }
@ -431,13 +399,9 @@ public sealed class YamlConfigLoader : IConfigLoader
} }
catch (Exception exception) catch (Exception exception)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.TableBuildFailed,
Name,
$"Failed to build config table '{Name}' from directory '{directoryPath}'.", $"Failed to build config table '{Name}' from directory '{directoryPath}'.",
configDirectoryPath: directoryPath, exception);
schemaPath: schema?.SchemaPath,
innerException: exception);
} }
} }
} }
@ -518,43 +482,21 @@ public sealed class YamlConfigLoader : IConfigLoader
if (!TryResolveTargetTable(registry, loadedTableLookup, referenceUsage.ReferencedTableName, if (!TryResolveTargetTable(registry, loadedTableLookup, referenceUsage.ReferencedTableName,
out var targetTable)) out var targetTable))
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.ReferencedTableNotFound, $"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' references table '{referenceUsage.ReferencedTableName}', but that table is not available in the current loader batch or registry.");
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, if (!TryConvertReferenceKey(referenceUsage, targetTable.KeyType, out var convertedKey,
out var conversionError)) out var conversionError))
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.ReferenceKeyTypeMismatch, $"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' cannot target table '{referenceUsage.ReferencedTableName}' with key type '{targetTable.KeyType.Name}'. {conversionError}");
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!)) if (!ContainsKey(targetTable, convertedKey!))
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.ReferencedKeyNotFound, $"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' references missing key '{referenceUsage.RawValue}' in table '{referenceUsage.ReferencedTableName}'.");
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,5 +1,3 @@
using GFramework.Game.Abstractions.Config;
namespace GFramework.Game.Config; namespace GFramework.Game.Config;
/// <summary> /// <summary>
@ -12,23 +10,16 @@ internal static class YamlConfigSchemaValidator
/// <summary> /// <summary>
/// 从磁盘加载并解析一个 JSON Schema 文件。 /// 从磁盘加载并解析一个 JSON Schema 文件。
/// </summary> /// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param> /// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="cancellationToken">取消令牌。</param> /// <param name="cancellationToken">取消令牌。</param>
/// <returns>解析后的 schema 模型。</returns> /// <returns>解析后的 schema 模型。</returns>
/// <exception cref="ArgumentException">当 <paramref name="tableName" /> 为空时抛出。</exception>
/// <exception cref="ArgumentException">当 <paramref name="schemaPath" /> 为空时抛出。</exception> /// <exception cref="ArgumentException">当 <paramref name="schemaPath" /> 为空时抛出。</exception>
/// <exception cref="ConfigLoadException">当 schema 文件不存在或内容非法时抛出。</exception> /// <exception cref="FileNotFoundException">当 schema 文件不存在时抛出。</exception>
/// <exception cref="InvalidOperationException">当 schema 内容不符合当前运行时支持的子集时抛出。</exception>
internal static async Task<YamlConfigSchema> LoadAsync( internal static async Task<YamlConfigSchema> LoadAsync(
string tableName,
string schemaPath, string schemaPath,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(tableName))
{
throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName));
}
if (string.IsNullOrWhiteSpace(schemaPath)) if (string.IsNullOrWhiteSpace(schemaPath))
{ {
throw new ArgumentException("Schema path cannot be null or whitespace.", nameof(schemaPath)); throw new ArgumentException("Schema path cannot be null or whitespace.", nameof(schemaPath));
@ -36,11 +27,7 @@ internal static class YamlConfigSchemaValidator
if (!File.Exists(schemaPath)) if (!File.Exists(schemaPath))
{ {
throw ConfigLoadExceptionFactory.Create( throw new FileNotFoundException($"Schema file '{schemaPath}' was not found.", schemaPath);
ConfigLoadFailureKind.SchemaFileNotFound,
tableName,
$"Schema file '{schemaPath}' was not found.",
schemaPath: schemaPath);
} }
string schemaText; string schemaText;
@ -50,26 +37,18 @@ internal static class YamlConfigSchemaValidator
} }
catch (Exception exception) catch (Exception exception)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException($"Failed to read schema file '{schemaPath}'.", exception);
ConfigLoadFailureKind.SchemaReadFailed,
tableName,
$"Failed to read schema file '{schemaPath}'.",
schemaPath: schemaPath,
innerException: exception);
} }
try try
{ {
using var document = JsonDocument.Parse(schemaText); using var document = JsonDocument.Parse(schemaText);
var root = document.RootElement; var root = document.RootElement;
var rootNode = ParseNode(tableName, schemaPath, "<root>", root, isRoot: true); var rootNode = ParseNode(schemaPath, "<root>", root, isRoot: true);
if (rootNode.NodeType != YamlConfigSchemaPropertyType.Object) if (rootNode.NodeType != YamlConfigSchemaPropertyType.Object)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.SchemaUnsupported, $"Schema file '{schemaPath}' must declare a root object schema.");
tableName,
$"Schema file '{schemaPath}' must declare a root object schema.",
schemaPath: schemaPath);
} }
var referencedTableNames = new HashSet<string>(StringComparer.Ordinal); var referencedTableNames = new HashSet<string>(StringComparer.Ordinal);
@ -79,55 +58,41 @@ internal static class YamlConfigSchemaValidator
} }
catch (JsonException exception) catch (JsonException exception)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException($"Schema file '{schemaPath}' contains invalid JSON.", exception);
ConfigLoadFailureKind.SchemaInvalidJson,
tableName,
$"Schema file '{schemaPath}' contains invalid JSON.",
schemaPath: schemaPath,
innerException: exception);
} }
} }
/// <summary> /// <summary>
/// 使用已解析的 schema 校验 YAML 文本。 /// 使用已解析的 schema 校验 YAML 文本。
/// </summary> /// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schema">已解析的 schema 模型。</param> /// <param name="schema">已解析的 schema 模型。</param>
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param> /// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
/// <param name="yamlText">YAML 文本内容。</param> /// <param name="yamlText">YAML 文本内容。</param>
/// <exception cref="ArgumentNullException">当参数为空时抛出。</exception> /// <exception cref="ArgumentNullException">当参数为空时抛出。</exception>
/// <exception cref="ConfigLoadException">当 YAML 内容与 schema 不匹配时抛出。</exception> /// <exception cref="InvalidOperationException">当 YAML 内容与 schema 不匹配时抛出。</exception>
internal static void Validate( internal static void Validate(
string tableName,
YamlConfigSchema schema, YamlConfigSchema schema,
string yamlPath, string yamlPath,
string yamlText) string yamlText)
{ {
ValidateAndCollectReferences(tableName, schema, yamlPath, yamlText); ValidateAndCollectReferences(schema, yamlPath, yamlText);
} }
/// <summary> /// <summary>
/// 使用已解析的 schema 校验 YAML 文本,并提取声明过的跨表引用。 /// 使用已解析的 schema 校验 YAML 文本,并提取声明过的跨表引用。
/// 该方法让结构校验与引用采集共享同一份 YAML 解析结果,避免加载器重复解析同一文件。 /// 该方法让结构校验与引用采集共享同一份 YAML 解析结果,避免加载器重复解析同一文件。
/// </summary> /// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schema">已解析的 schema 模型。</param> /// <param name="schema">已解析的 schema 模型。</param>
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param> /// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
/// <param name="yamlText">YAML 文本内容。</param> /// <param name="yamlText">YAML 文本内容。</param>
/// <returns>当前 YAML 文件中声明的跨表引用集合。</returns> /// <returns>当前 YAML 文件中声明的跨表引用集合。</returns>
/// <exception cref="ArgumentNullException">当参数为空时抛出。</exception> /// <exception cref="ArgumentNullException">当参数为空时抛出。</exception>
/// <exception cref="ConfigLoadException">当 YAML 内容与 schema 不匹配时抛出。</exception> /// <exception cref="InvalidOperationException">当 YAML 内容与 schema 不匹配时抛出。</exception>
internal static IReadOnlyList<YamlConfigReferenceUsage> ValidateAndCollectReferences( internal static IReadOnlyList<YamlConfigReferenceUsage> ValidateAndCollectReferences(
string tableName,
YamlConfigSchema schema, YamlConfigSchema schema,
string yamlPath, string yamlPath,
string yamlText) string yamlText)
{ {
if (string.IsNullOrWhiteSpace(tableName))
{
throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName));
}
ArgumentNullException.ThrowIfNull(schema); ArgumentNullException.ThrowIfNull(schema);
ArgumentNullException.ThrowIfNull(yamlPath); ArgumentNullException.ThrowIfNull(yamlPath);
ArgumentNullException.ThrowIfNull(yamlText); ArgumentNullException.ThrowIfNull(yamlText);
@ -140,41 +105,31 @@ internal static class YamlConfigSchemaValidator
} }
catch (Exception exception) catch (Exception exception)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.YamlParseFailed,
tableName,
$"Config file '{yamlPath}' could not be parsed as YAML before schema validation.", $"Config file '{yamlPath}' could not be parsed as YAML before schema validation.",
yamlPath: yamlPath, exception);
schemaPath: schema.SchemaPath,
innerException: exception);
} }
if (yamlStream.Documents.Count != 1) if (yamlStream.Documents.Count != 1)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.InvalidYamlDocument, $"Config file '{yamlPath}' must contain exactly one YAML document.");
tableName,
$"Config file '{yamlPath}' must contain exactly one YAML document.",
yamlPath: yamlPath,
schemaPath: schema.SchemaPath);
} }
var references = new List<YamlConfigReferenceUsage>(); var references = new List<YamlConfigReferenceUsage>();
ValidateNode(tableName, yamlPath, string.Empty, yamlStream.Documents[0].RootNode, schema.RootNode, references); ValidateNode(yamlPath, string.Empty, yamlStream.Documents[0].RootNode, schema.RootNode, references);
return references; return references;
} }
/// <summary> /// <summary>
/// 递归解析 schema 节点,使运行时只保留校验真正需要的最小结构信息。 /// 递归解析 schema 节点,使运行时只保留校验真正需要的最小结构信息。
/// </summary> /// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param> /// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">当前节点的逻辑属性路径。</param> /// <param name="propertyPath">当前节点的逻辑属性路径。</param>
/// <param name="element">Schema JSON 节点。</param> /// <param name="element">Schema JSON 节点。</param>
/// <param name="isRoot">是否为根节点。</param> /// <param name="isRoot">是否为根节点。</param>
/// <returns>可用于运行时校验的节点模型。</returns> /// <returns>可用于运行时校验的节点模型。</returns>
private static YamlConfigSchemaNode ParseNode( private static YamlConfigSchemaNode ParseNode(
string tableName,
string schemaPath, string schemaPath,
string propertyPath, string propertyPath,
JsonElement element, JsonElement element,
@ -183,66 +138,54 @@ internal static class YamlConfigSchemaValidator
if (!element.TryGetProperty("type", out var typeElement) || if (!element.TryGetProperty("type", out var typeElement) ||
typeElement.ValueKind != JsonValueKind.String) typeElement.ValueKind != JsonValueKind.String)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.SchemaUnsupported, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare a string 'type'.");
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare a string 'type'.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
} }
var typeName = typeElement.GetString() ?? string.Empty; var typeName = typeElement.GetString() ?? string.Empty;
var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element); var referenceTableName = TryGetReferenceTableName(schemaPath, propertyPath, element);
switch (typeName) switch (typeName)
{ {
case "object": case "object":
EnsureReferenceKeywordIsSupported(tableName, schemaPath, propertyPath, EnsureReferenceKeywordIsSupported(schemaPath, propertyPath, YamlConfigSchemaPropertyType.Object,
YamlConfigSchemaPropertyType.Object,
referenceTableName); referenceTableName);
return ParseObjectNode(tableName, schemaPath, propertyPath, element, isRoot); return ParseObjectNode(schemaPath, propertyPath, element, isRoot);
case "array": case "array":
return ParseArrayNode(tableName, schemaPath, propertyPath, element, referenceTableName); return ParseArrayNode(schemaPath, propertyPath, element, referenceTableName);
case "integer": case "integer":
return CreateScalarNode(tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.Integer, return CreateScalarNode(schemaPath, propertyPath, YamlConfigSchemaPropertyType.Integer, element,
element, referenceTableName); referenceTableName);
case "number": case "number":
return CreateScalarNode(tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.Number, return CreateScalarNode(schemaPath, propertyPath, YamlConfigSchemaPropertyType.Number, element,
element, referenceTableName); referenceTableName);
case "boolean": case "boolean":
return CreateScalarNode(tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.Boolean, return CreateScalarNode(schemaPath, propertyPath, YamlConfigSchemaPropertyType.Boolean, element,
element, referenceTableName); referenceTableName);
case "string": case "string":
return CreateScalarNode(tableName, schemaPath, propertyPath, YamlConfigSchemaPropertyType.String, return CreateScalarNode(schemaPath, propertyPath, YamlConfigSchemaPropertyType.String, element,
element, referenceTableName); referenceTableName);
default: default:
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.SchemaUnsupported, $"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported type '{typeName}'.");
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported type '{typeName}'.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath),
rawValue: typeName);
} }
} }
/// <summary> /// <summary>
/// 解析对象节点,保留属性字典与必填集合,以便后续递归校验时逐层定位错误。 /// 解析对象节点,保留属性字典与必填集合,以便后续递归校验时逐层定位错误。
/// </summary> /// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param> /// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">对象属性路径。</param> /// <param name="propertyPath">对象属性路径。</param>
/// <param name="element">对象 schema 节点。</param> /// <param name="element">对象 schema 节点。</param>
/// <param name="isRoot">是否为根节点。</param> /// <param name="isRoot">是否为根节点。</param>
/// <returns>对象节点模型。</returns> /// <returns>对象节点模型。</returns>
private static YamlConfigSchemaNode ParseObjectNode( private static YamlConfigSchemaNode ParseObjectNode(
string tableName,
string schemaPath, string schemaPath,
string propertyPath, string propertyPath,
JsonElement element, JsonElement element,
@ -252,12 +195,8 @@ internal static class YamlConfigSchemaValidator
propertiesElement.ValueKind != JsonValueKind.Object) propertiesElement.ValueKind != JsonValueKind.Object)
{ {
var subject = isRoot ? "root schema" : $"object property '{propertyPath}'"; var subject = isRoot ? "root schema" : $"object property '{propertyPath}'";
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.SchemaUnsupported, $"The {subject} in schema file '{schemaPath}' must declare an object-valued 'properties' section.");
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); var requiredProperties = new HashSet<string>(StringComparer.Ordinal);
@ -283,7 +222,6 @@ internal static class YamlConfigSchemaValidator
foreach (var property in propertiesElement.EnumerateObject()) foreach (var property in propertiesElement.EnumerateObject())
{ {
properties[property.Name] = ParseNode( properties[property.Name] = ParseNode(
tableName,
schemaPath, schemaPath,
CombineSchemaPath(propertyPath, property.Name), CombineSchemaPath(propertyPath, property.Name),
property.Value); property.Value);
@ -304,14 +242,12 @@ internal static class YamlConfigSchemaValidator
/// 当前子集支持标量数组和对象数组,不支持数组嵌套数组。 /// 当前子集支持标量数组和对象数组,不支持数组嵌套数组。
/// 当数组声明跨表引用时,会把引用语义挂到元素节点上,便于后续逐项校验。 /// 当数组声明跨表引用时,会把引用语义挂到元素节点上,便于后续逐项校验。
/// </summary> /// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param> /// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">数组属性路径。</param> /// <param name="propertyPath">数组属性路径。</param>
/// <param name="element">数组 schema 节点。</param> /// <param name="element">数组 schema 节点。</param>
/// <param name="referenceTableName">声明在数组节点上的目标引用表。</param> /// <param name="referenceTableName">声明在数组节点上的目标引用表。</param>
/// <returns>数组节点模型。</returns> /// <returns>数组节点模型。</returns>
private static YamlConfigSchemaNode ParseArrayNode( private static YamlConfigSchemaNode ParseArrayNode(
string tableName,
string schemaPath, string schemaPath,
string propertyPath, string propertyPath,
JsonElement element, JsonElement element,
@ -320,27 +256,18 @@ internal static class YamlConfigSchemaValidator
if (!element.TryGetProperty("items", out var itemsElement) || if (!element.TryGetProperty("items", out var itemsElement) ||
itemsElement.ValueKind != JsonValueKind.Object) itemsElement.ValueKind != JsonValueKind.Object)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.SchemaUnsupported, $"Array property '{propertyPath}' in schema file '{schemaPath}' must declare an object-valued 'items' schema.");
tableName,
$"Array property '{propertyPath}' in schema file '{schemaPath}' must declare an object-valued 'items' schema.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
} }
var itemNode = ParseNode(tableName, schemaPath, $"{propertyPath}[]", itemsElement); var itemNode = ParseNode(schemaPath, $"{propertyPath}[]", itemsElement);
if (!string.IsNullOrWhiteSpace(referenceTableName)) if (!string.IsNullOrWhiteSpace(referenceTableName))
{ {
if (itemNode.NodeType != YamlConfigSchemaPropertyType.String && if (itemNode.NodeType != YamlConfigSchemaPropertyType.String &&
itemNode.NodeType != YamlConfigSchemaPropertyType.Integer) itemNode.NodeType != YamlConfigSchemaPropertyType.Integer)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.SchemaUnsupported, $"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.");
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); itemNode = itemNode.WithReferenceTable(referenceTableName);
@ -348,12 +275,8 @@ internal static class YamlConfigSchemaValidator
if (itemNode.NodeType == YamlConfigSchemaPropertyType.Array) if (itemNode.NodeType == YamlConfigSchemaPropertyType.Array)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.SchemaUnsupported, $"Array property '{propertyPath}' in schema file '{schemaPath}' uses unsupported nested array items.");
tableName,
$"Array property '{propertyPath}' in schema file '{schemaPath}' uses unsupported nested array items.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
} }
return new YamlConfigSchemaNode( return new YamlConfigSchemaNode(
@ -369,7 +292,6 @@ internal static class YamlConfigSchemaValidator
/// <summary> /// <summary>
/// 创建标量节点,并在解析阶段就完成 enum 与引用约束的兼容性检查。 /// 创建标量节点,并在解析阶段就完成 enum 与引用约束的兼容性检查。
/// </summary> /// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param> /// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">标量属性路径。</param> /// <param name="propertyPath">标量属性路径。</param>
/// <param name="nodeType">标量类型。</param> /// <param name="nodeType">标量类型。</param>
@ -377,21 +299,20 @@ internal static class YamlConfigSchemaValidator
/// <param name="referenceTableName">目标引用表名称。</param> /// <param name="referenceTableName">目标引用表名称。</param>
/// <returns>标量节点模型。</returns> /// <returns>标量节点模型。</returns>
private static YamlConfigSchemaNode CreateScalarNode( private static YamlConfigSchemaNode CreateScalarNode(
string tableName,
string schemaPath, string schemaPath,
string propertyPath, string propertyPath,
YamlConfigSchemaPropertyType nodeType, YamlConfigSchemaPropertyType nodeType,
JsonElement element, JsonElement element,
string? referenceTableName) string? referenceTableName)
{ {
EnsureReferenceKeywordIsSupported(tableName, schemaPath, propertyPath, nodeType, referenceTableName); EnsureReferenceKeywordIsSupported(schemaPath, propertyPath, nodeType, referenceTableName);
return new YamlConfigSchemaNode( return new YamlConfigSchemaNode(
nodeType, nodeType,
properties: null, properties: null,
requiredProperties: null, requiredProperties: null,
itemNode: null, itemNode: null,
referenceTableName, referenceTableName,
ParseEnumValues(tableName, schemaPath, propertyPath, element, nodeType, "enum"), ParseEnumValues(schemaPath, propertyPath, element, nodeType, "enum"),
schemaPath); schemaPath);
} }
@ -399,14 +320,12 @@ internal static class YamlConfigSchemaValidator
/// 递归校验 YAML 节点。 /// 递归校验 YAML 节点。
/// 每层都带上逻辑字段路径,这样深层对象与数组元素的错误也能直接定位。 /// 每层都带上逻辑字段路径,这样深层对象与数组元素的错误也能直接定位。
/// </summary> /// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param> /// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">当前字段路径;根节点时为空。</param> /// <param name="displayPath">当前字段路径;根节点时为空。</param>
/// <param name="node">实际 YAML 节点。</param> /// <param name="node">实际 YAML 节点。</param>
/// <param name="schemaNode">对应的 schema 节点。</param> /// <param name="schemaNode">对应的 schema 节点。</param>
/// <param name="references">已收集的跨表引用。</param> /// <param name="references">已收集的跨表引用。</param>
private static void ValidateNode( private static void ValidateNode(
string tableName,
string yamlPath, string yamlPath,
string displayPath, string displayPath,
YamlNode node, YamlNode node,
@ -416,43 +335,35 @@ internal static class YamlConfigSchemaValidator
switch (schemaNode.NodeType) switch (schemaNode.NodeType)
{ {
case YamlConfigSchemaPropertyType.Object: case YamlConfigSchemaPropertyType.Object:
ValidateObjectNode(tableName, yamlPath, displayPath, node, schemaNode, references); ValidateObjectNode(yamlPath, displayPath, node, schemaNode, references);
return; return;
case YamlConfigSchemaPropertyType.Array: case YamlConfigSchemaPropertyType.Array:
ValidateArrayNode(tableName, yamlPath, displayPath, node, schemaNode, references); ValidateArrayNode(yamlPath, displayPath, node, schemaNode, references);
return; return;
case YamlConfigSchemaPropertyType.Integer: case YamlConfigSchemaPropertyType.Integer:
case YamlConfigSchemaPropertyType.Number: case YamlConfigSchemaPropertyType.Number:
case YamlConfigSchemaPropertyType.Boolean: case YamlConfigSchemaPropertyType.Boolean:
case YamlConfigSchemaPropertyType.String: case YamlConfigSchemaPropertyType.String:
ValidateScalarNode(tableName, yamlPath, displayPath, node, schemaNode, references); ValidateScalarNode(yamlPath, displayPath, node, schemaNode, references);
return; return;
default: default:
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.UnexpectedFailure, $"Schema node '{displayPath}' uses unsupported runtime node type '{schemaNode.NodeType}'.");
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>
/// 校验对象节点,同时处理重复字段、未知字段和深层必填字段。 /// 校验对象节点,同时处理重复字段、未知字段和深层必填字段。
/// </summary> /// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param> /// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">当前对象的逻辑字段路径。</param> /// <param name="displayPath">当前对象的逻辑字段路径。</param>
/// <param name="node">实际 YAML 节点。</param> /// <param name="node">实际 YAML 节点。</param>
/// <param name="schemaNode">对象 schema 节点。</param> /// <param name="schemaNode">对象 schema 节点。</param>
/// <param name="references">已收集的跨表引用。</param> /// <param name="references">已收集的跨表引用。</param>
private static void ValidateObjectNode( private static void ValidateObjectNode(
string tableName,
string yamlPath, string yamlPath,
string displayPath, string displayPath,
YamlNode node, YamlNode node,
@ -462,13 +373,8 @@ internal static class YamlConfigSchemaValidator
if (node is not YamlMappingNode mappingNode) if (node is not YamlMappingNode mappingNode)
{ {
var subject = displayPath.Length == 0 ? "Root object" : $"Property '{displayPath}'"; var subject = displayPath.Length == 0 ? "Root object" : $"Property '{displayPath}'";
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.PropertyTypeMismatch, $"{subject} in config file '{yamlPath}' must be an object.");
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); var seenProperties = new HashSet<string>(StringComparer.Ordinal);
@ -478,41 +384,26 @@ internal static class YamlConfigSchemaValidator
string.IsNullOrWhiteSpace(keyNode.Value)) string.IsNullOrWhiteSpace(keyNode.Value))
{ {
var subject = displayPath.Length == 0 ? "root object" : $"object property '{displayPath}'"; var subject = displayPath.Length == 0 ? "root object" : $"object property '{displayPath}'";
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.InvalidYamlDocument, $"Config file '{yamlPath}' contains a non-scalar or empty property name inside {subject}.");
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 propertyName = keyNode.Value;
var propertyPath = CombineDisplayPath(displayPath, propertyName); var propertyPath = CombineDisplayPath(displayPath, propertyName);
if (!seenProperties.Add(propertyName)) if (!seenProperties.Add(propertyName))
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.DuplicateProperty, $"Config file '{yamlPath}' contains duplicate property '{propertyPath}'.");
tableName,
$"Config file '{yamlPath}' contains duplicate property '{propertyPath}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: propertyPath);
} }
if (schemaNode.Properties is null || if (schemaNode.Properties is null ||
!schemaNode.Properties.TryGetValue(propertyName, out var propertySchema)) !schemaNode.Properties.TryGetValue(propertyName, out var propertySchema))
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.UnknownProperty, $"Config file '{yamlPath}' contains unknown property '{propertyPath}' that is not declared in schema '{schemaNode.SchemaPathHint}'.");
tableName,
$"Config file '{yamlPath}' contains unknown property '{propertyPath}' that is not declared in schema '{schemaNode.SchemaPathHint}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: propertyPath);
} }
ValidateNode(tableName, yamlPath, propertyPath, entry.Value, propertySchema, references); ValidateNode(yamlPath, propertyPath, entry.Value, propertySchema, references);
} }
if (schemaNode.RequiredProperties is null) if (schemaNode.RequiredProperties is null)
@ -527,28 +418,20 @@ internal static class YamlConfigSchemaValidator
continue; continue;
} }
var requiredPath = CombineDisplayPath(displayPath, requiredProperty); throw new InvalidOperationException(
throw ConfigLoadExceptionFactory.Create( $"Config file '{yamlPath}' is missing required property '{CombineDisplayPath(displayPath, requiredProperty)}' defined by schema '{schemaNode.SchemaPathHint}'.");
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>
/// 校验数组节点,并递归验证每个元素。 /// 校验数组节点,并递归验证每个元素。
/// </summary> /// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param> /// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">数组字段路径。</param> /// <param name="displayPath">数组字段路径。</param>
/// <param name="node">实际 YAML 节点。</param> /// <param name="node">实际 YAML 节点。</param>
/// <param name="schemaNode">数组 schema 节点。</param> /// <param name="schemaNode">数组 schema 节点。</param>
/// <param name="references">已收集的跨表引用。</param> /// <param name="references">已收集的跨表引用。</param>
private static void ValidateArrayNode( private static void ValidateArrayNode(
string tableName,
string yamlPath, string yamlPath,
string displayPath, string displayPath,
YamlNode node, YamlNode node,
@ -557,30 +440,19 @@ internal static class YamlConfigSchemaValidator
{ {
if (node is not YamlSequenceNode sequenceNode) if (node is not YamlSequenceNode sequenceNode)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.PropertyTypeMismatch, $"Property '{displayPath}' in config file '{yamlPath}' must be an array.");
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' must be an array.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath));
} }
if (schemaNode.ItemNode is null) if (schemaNode.ItemNode is null)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.UnexpectedFailure, $"Schema node '{displayPath}' is missing array item information.");
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++) for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++)
{ {
ValidateNode( ValidateNode(
tableName,
yamlPath, yamlPath,
$"{displayPath}[{itemIndex}]", $"{displayPath}[{itemIndex}]",
sequenceNode.Children[itemIndex], sequenceNode.Children[itemIndex],
@ -592,14 +464,12 @@ internal static class YamlConfigSchemaValidator
/// <summary> /// <summary>
/// 校验标量节点,并在值有效时收集跨表引用。 /// 校验标量节点,并在值有效时收集跨表引用。
/// </summary> /// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="yamlPath">YAML 文件路径。</param> /// <param name="yamlPath">YAML 文件路径。</param>
/// <param name="displayPath">标量字段路径。</param> /// <param name="displayPath">标量字段路径。</param>
/// <param name="node">实际 YAML 节点。</param> /// <param name="node">实际 YAML 节点。</param>
/// <param name="schemaNode">标量 schema 节点。</param> /// <param name="schemaNode">标量 schema 节点。</param>
/// <param name="references">已收集的跨表引用。</param> /// <param name="references">已收集的跨表引用。</param>
private static void ValidateScalarNode( private static void ValidateScalarNode(
string tableName,
string yamlPath, string yamlPath,
string displayPath, string displayPath,
YamlNode node, YamlNode node,
@ -608,25 +478,15 @@ internal static class YamlConfigSchemaValidator
{ {
if (node is not YamlScalarNode scalarNode) if (node is not YamlScalarNode scalarNode)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.PropertyTypeMismatch, $"Property '{displayPath}' in config file '{yamlPath}' must be a scalar value of type '{GetTypeName(schemaNode.NodeType)}'.");
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; var value = scalarNode.Value;
if (value is null) if (value is null)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.NullScalarValue, $"Property '{displayPath}' in config file '{yamlPath}' cannot be null when schema type is '{GetTypeName(schemaNode.NodeType)}'.");
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(); var tag = scalarNode.Tag.ToString();
@ -649,29 +509,16 @@ internal static class YamlConfigSchemaValidator
if (!isValid) if (!isValid)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.PropertyTypeMismatch, $"Property '{displayPath}' in config file '{yamlPath}' must be of type '{GetTypeName(schemaNode.NodeType)}', but the current YAML scalar value is '{value}'.");
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); var normalizedValue = NormalizeScalarValue(schemaNode.NodeType, value);
if (schemaNode.AllowedValues is { Count: > 0 } && if (schemaNode.AllowedValues is { Count: > 0 } &&
!schemaNode.AllowedValues.Contains(normalizedValue, StringComparer.Ordinal)) !schemaNode.AllowedValues.Contains(normalizedValue, StringComparer.Ordinal))
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.EnumValueNotAllowed, $"Property '{displayPath}' in config file '{yamlPath}' must be one of [{string.Join(", ", schemaNode.AllowedValues)}], but the current YAML scalar value is '{value}'.");
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) if (schemaNode.ReferenceTableName != null)
@ -679,7 +526,6 @@ internal static class YamlConfigSchemaValidator
references.Add( references.Add(
new YamlConfigReferenceUsage( new YamlConfigReferenceUsage(
yamlPath, yamlPath,
schemaNode.SchemaPathHint,
displayPath, displayPath,
normalizedValue, normalizedValue,
schemaNode.ReferenceTableName, schemaNode.ReferenceTableName,
@ -690,7 +536,6 @@ internal static class YamlConfigSchemaValidator
/// <summary> /// <summary>
/// 解析 enum并在读取阶段验证枚举值与字段类型的兼容性。 /// 解析 enum并在读取阶段验证枚举值与字段类型的兼容性。
/// </summary> /// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param> /// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param> /// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param> /// <param name="element">Schema 节点。</param>
@ -698,7 +543,6 @@ internal static class YamlConfigSchemaValidator
/// <param name="keywordName">当前读取的关键字名称。</param> /// <param name="keywordName">当前读取的关键字名称。</param>
/// <returns>归一化后的枚举值集合;未声明时返回空。</returns> /// <returns>归一化后的枚举值集合;未声明时返回空。</returns>
private static IReadOnlyCollection<string>? ParseEnumValues( private static IReadOnlyCollection<string>? ParseEnumValues(
string tableName,
string schemaPath, string schemaPath,
string propertyPath, string propertyPath,
JsonElement element, JsonElement element,
@ -712,19 +556,14 @@ internal static class YamlConfigSchemaValidator
if (enumElement.ValueKind != JsonValueKind.Array) if (enumElement.ValueKind != JsonValueKind.Array)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.SchemaUnsupported, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as an array.");
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as an array.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
} }
var allowedValues = new List<string>(); var allowedValues = new List<string>();
foreach (var item in enumElement.EnumerateArray()) foreach (var item in enumElement.EnumerateArray())
{ {
allowedValues.Add( allowedValues.Add(NormalizeEnumValue(schemaPath, propertyPath, keywordName, expectedType, item));
NormalizeEnumValue(tableName, schemaPath, propertyPath, keywordName, expectedType, item));
} }
return allowedValues; return allowedValues;
@ -733,13 +572,11 @@ internal static class YamlConfigSchemaValidator
/// <summary> /// <summary>
/// 解析跨表引用目标表名称。 /// 解析跨表引用目标表名称。
/// </summary> /// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param> /// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param> /// <param name="propertyPath">字段路径。</param>
/// <param name="element">Schema 节点。</param> /// <param name="element">Schema 节点。</param>
/// <returns>目标表名称;未声明时返回空。</returns> /// <returns>目标表名称;未声明时返回空。</returns>
private static string? TryGetReferenceTableName( private static string? TryGetReferenceTableName(
string tableName,
string schemaPath, string schemaPath,
string propertyPath, string propertyPath,
JsonElement element) JsonElement element)
@ -751,23 +588,15 @@ internal static class YamlConfigSchemaValidator
if (referenceTableElement.ValueKind != JsonValueKind.String) if (referenceTableElement.ValueKind != JsonValueKind.String)
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.SchemaUnsupported, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare a string 'x-gframework-ref-table' value.");
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(); var referenceTableName = referenceTableElement.GetString();
if (string.IsNullOrWhiteSpace(referenceTableName)) if (string.IsNullOrWhiteSpace(referenceTableName))
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.SchemaUnsupported, $"Property '{propertyPath}' in schema file '{schemaPath}' must declare a non-empty 'x-gframework-ref-table' value.");
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' must declare a non-empty 'x-gframework-ref-table' value.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
} }
return referenceTableName; return referenceTableName;
@ -776,13 +605,11 @@ internal static class YamlConfigSchemaValidator
/// <summary> /// <summary>
/// 验证哪些 schema 类型允许声明跨表引用。 /// 验证哪些 schema 类型允许声明跨表引用。
/// </summary> /// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param> /// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param> /// <param name="propertyPath">字段路径。</param>
/// <param name="propertyType">字段类型。</param> /// <param name="propertyType">字段类型。</param>
/// <param name="referenceTableName">目标表名称。</param> /// <param name="referenceTableName">目标表名称。</param>
private static void EnsureReferenceKeywordIsSupported( private static void EnsureReferenceKeywordIsSupported(
string tableName,
string schemaPath, string schemaPath,
string propertyPath, string propertyPath,
YamlConfigSchemaPropertyType propertyType, YamlConfigSchemaPropertyType propertyType,
@ -799,13 +626,8 @@ internal static class YamlConfigSchemaValidator
return; return;
} }
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.SchemaUnsupported, $"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.");
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> /// <summary>
@ -839,7 +661,6 @@ internal static class YamlConfigSchemaValidator
/// <summary> /// <summary>
/// 将 schema 中的 enum 单值归一化到运行时比较字符串。 /// 将 schema 中的 enum 单值归一化到运行时比较字符串。
/// </summary> /// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param> /// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">字段路径。</param> /// <param name="propertyPath">字段路径。</param>
/// <param name="keywordName">关键字名称。</param> /// <param name="keywordName">关键字名称。</param>
@ -847,7 +668,6 @@ internal static class YamlConfigSchemaValidator
/// <param name="item">当前枚举值节点。</param> /// <param name="item">当前枚举值节点。</param>
/// <returns>归一化后的字符串值。</returns> /// <returns>归一化后的字符串值。</returns>
private static string NormalizeEnumValue( private static string NormalizeEnumValue(
string tableName,
string schemaPath, string schemaPath,
string propertyPath, string propertyPath,
string keywordName, string keywordName,
@ -873,27 +693,11 @@ internal static class YamlConfigSchemaValidator
} }
catch catch
{ {
throw ConfigLoadExceptionFactory.Create( throw new InvalidOperationException(
ConfigLoadFailureKind.SchemaUnsupported, $"Property '{propertyPath}' in schema file '{schemaPath}' contains a '{keywordName}' value that is incompatible with schema type '{GetTypeName(expectedType)}'.");
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> /// <summary>
/// 将 YAML 标量值规范化成运行时比较格式。 /// 将 YAML 标量值规范化成运行时比较格式。
/// </summary> /// </summary>
@ -1121,27 +925,23 @@ internal sealed class YamlConfigReferenceUsage
/// 初始化一个跨表引用使用记录。 /// 初始化一个跨表引用使用记录。
/// </summary> /// </summary>
/// <param name="yamlPath">源 YAML 文件路径。</param> /// <param name="yamlPath">源 YAML 文件路径。</param>
/// <param name="schemaPath">定义该引用的 schema 文件路径。</param>
/// <param name="propertyPath">声明引用的字段路径。</param> /// <param name="propertyPath">声明引用的字段路径。</param>
/// <param name="rawValue">YAML 中的原始标量值。</param> /// <param name="rawValue">YAML 中的原始标量值。</param>
/// <param name="referencedTableName">目标配置表名称。</param> /// <param name="referencedTableName">目标配置表名称。</param>
/// <param name="valueType">引用值的 schema 标量类型。</param> /// <param name="valueType">引用值的 schema 标量类型。</param>
public YamlConfigReferenceUsage( public YamlConfigReferenceUsage(
string yamlPath, string yamlPath,
string schemaPath,
string propertyPath, string propertyPath,
string rawValue, string rawValue,
string referencedTableName, string referencedTableName,
YamlConfigSchemaPropertyType valueType) YamlConfigSchemaPropertyType valueType)
{ {
ArgumentNullException.ThrowIfNull(yamlPath); ArgumentNullException.ThrowIfNull(yamlPath);
ArgumentNullException.ThrowIfNull(schemaPath);
ArgumentNullException.ThrowIfNull(propertyPath); ArgumentNullException.ThrowIfNull(propertyPath);
ArgumentNullException.ThrowIfNull(rawValue); ArgumentNullException.ThrowIfNull(rawValue);
ArgumentNullException.ThrowIfNull(referencedTableName); ArgumentNullException.ThrowIfNull(referencedTableName);
YamlPath = yamlPath; YamlPath = yamlPath;
SchemaPath = schemaPath;
PropertyPath = propertyPath; PropertyPath = propertyPath;
RawValue = rawValue; RawValue = rawValue;
ReferencedTableName = referencedTableName; ReferencedTableName = referencedTableName;
@ -1153,11 +953,6 @@ internal sealed class YamlConfigReferenceUsage
/// </summary> /// </summary>
public string YamlPath { get; } public string YamlPath { get; }
/// <summary>
/// 获取定义该引用的 schema 文件路径。
/// </summary>
public string SchemaPath { get; }
/// <summary> /// <summary>
/// 获取声明引用的字段路径。 /// 获取声明引用的字段路径。
/// </summary> /// </summary>

View File

@ -9,7 +9,7 @@ namespace GFramework.SourceGenerators.Tests.Config;
public class SchemaConfigGeneratorSnapshotTests public class SchemaConfigGeneratorSnapshotTests
{ {
/// <summary> /// <summary>
/// 验证一个最小 monster schema 能生成配置类型、表包装和注册辅助 /// 验证一个最小 monster schema 能生成配置类型和表包装
/// </summary> /// </summary>
[Test] [Test]
public async Task Snapshot_SchemaConfigGenerator() public async Task Snapshot_SchemaConfigGenerator()
@ -35,32 +35,6 @@ public class SchemaConfigGeneratorSnapshotTests
bool ContainsKey(TKey key); bool ContainsKey(TKey key);
IReadOnlyCollection<TValue> All(); IReadOnlyCollection<TValue> All();
} }
public interface IConfigRegistry
{
IConfigTable<TKey, TValue> GetTable<TKey, TValue>(string name)
where TKey : notnull;
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
where TKey : notnull;
}
}
namespace GFramework.Game.Config
{
public sealed class YamlConfigLoader
{
public YamlConfigLoader RegisterTable<TKey, TValue>(
string tableName,
string relativePath,
string schemaRelativePath,
Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? comparer = null)
where TKey : notnull
{
return this;
}
}
} }
"""; """;
@ -156,8 +130,6 @@ public class SchemaConfigGeneratorSnapshotTests
await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterConfig.g.cs", "MonsterConfig.g.txt"); await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterConfig.g.cs", "MonsterConfig.g.txt");
await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterTable.g.cs", "MonsterTable.g.txt"); await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterTable.g.cs", "MonsterTable.g.txt");
await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterConfigBindings.g.cs",
"MonsterConfigBindings.g.txt");
} }
/// <summary> /// <summary>

View File

@ -1,89 +0,0 @@
// <auto-generated />
#nullable enable
namespace GFramework.Game.Config.Generated;
/// <summary>
/// Auto-generated registration and lookup helpers for schema file 'monster.schema.json'.
/// The helper centralizes table naming, config directory, schema path, and strongly-typed registry access so consumer projects do not need to duplicate the same conventions.
/// </summary>
public static class MonsterConfigBindings
{
/// <summary>
/// Gets the runtime registration name of the generated config table.
/// </summary>
public const string TableName = "monster";
/// <summary>
/// Gets the config directory path expected by the generated registration helper.
/// </summary>
public const string ConfigRelativePath = "monster";
/// <summary>
/// Gets the schema file path expected by the generated registration helper.
/// </summary>
public const string SchemaRelativePath = "schemas/monster.schema.json";
/// <summary>
/// Registers the generated config table using the schema-derived runtime conventions.
/// </summary>
/// <param name="loader">The target YAML config loader.</param>
/// <param name="comparer">Optional key comparer for the generated table registration.</param>
/// <returns>The same loader instance so registration can keep chaining.</returns>
public static global::GFramework.Game.Config.YamlConfigLoader RegisterMonsterTable(
this global::GFramework.Game.Config.YamlConfigLoader loader,
global::System.Collections.Generic.IEqualityComparer<int>? comparer = null)
{
if (loader is null)
{
throw new global::System.ArgumentNullException(nameof(loader));
}
return loader.RegisterTable<int, MonsterConfig>(
TableName,
ConfigRelativePath,
SchemaRelativePath,
static config => config.Id,
comparer);
}
/// <summary>
/// Gets the generated config table wrapper from the registry.
/// </summary>
/// <param name="registry">The source config registry.</param>
/// <returns>The generated strongly-typed table wrapper.</returns>
/// <exception cref="global::System.ArgumentNullException">When <paramref name="registry"/> is null.</exception>
public static MonsterTable GetMonsterTable(this global::GFramework.Game.Abstractions.Config.IConfigRegistry registry)
{
if (registry is null)
{
throw new global::System.ArgumentNullException(nameof(registry));
}
return new MonsterTable(registry.GetTable<int, MonsterConfig>(TableName));
}
/// <summary>
/// Tries to get the generated config table wrapper from the registry.
/// </summary>
/// <param name="registry">The source config registry.</param>
/// <param name="table">The generated strongly-typed table wrapper when lookup succeeds; otherwise null.</param>
/// <returns>True when the generated table is registered and type-compatible; otherwise false.</returns>
/// <exception cref="global::System.ArgumentNullException">When <paramref name="registry"/> is null.</exception>
public static bool TryGetMonsterTable(this global::GFramework.Game.Abstractions.Config.IConfigRegistry registry, out MonsterTable? table)
{
if (registry is null)
{
throw new global::System.ArgumentNullException(nameof(registry));
}
if (registry.TryGetTable<int, MonsterConfig>(TableName, out var innerTable) && innerTable is not null)
{
table = new MonsterTable(innerTable);
return true;
}
table = null;
return false;
}
}

View File

@ -1,3 +1,7 @@
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.Json;
using GFramework.SourceGenerators.Diagnostics; using GFramework.SourceGenerators.Diagnostics;
namespace GFramework.SourceGenerators.Config; namespace GFramework.SourceGenerators.Config;
@ -37,9 +41,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
productionContext.AddSource( productionContext.AddSource(
$"{result.Schema.TableName}.g.cs", $"{result.Schema.TableName}.g.cs",
SourceText.From(GenerateTableClass(result.Schema), Encoding.UTF8)); SourceText.From(GenerateTableClass(result.Schema), Encoding.UTF8));
productionContext.AddSource(
$"{result.Schema.EntityName}ConfigBindings.g.cs",
SourceText.From(GenerateBindingsClass(result.Schema), Encoding.UTF8));
}); });
} }
@ -127,18 +128,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
idProperty.TypeSpec.SchemaType)); idProperty.TypeSpec.SchemaType));
} }
var schemaBaseName = GetSchemaBaseName(file.Path);
var schema = new SchemaFileSpec( var schema = new SchemaFileSpec(
Path.GetFileName(file.Path), Path.GetFileName(file.Path),
entityName,
schemaObject.ClassName, schemaObject.ClassName,
$"{entityName}Table", $"{entityName}Table",
GeneratedNamespace, GeneratedNamespace,
idProperty.TypeSpec.ClrType.TrimEnd('?'), idProperty.TypeSpec.ClrType.TrimEnd('?'),
idProperty.PropertyName,
schemaBaseName,
schemaBaseName,
GetSchemaRelativePath(file.Path),
TryGetMetadataString(root, "title"), TryGetMetadataString(root, "title"),
TryGetMetadataString(root, "description"), TryGetMetadataString(root, "description"),
schemaObject); schemaObject);
@ -612,131 +607,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return builder.ToString().TrimEnd(); return builder.ToString().TrimEnd();
} }
/// <summary>
/// 生成运行时注册与访问辅助源码。
/// 该辅助类型把 schema 命名约定、配置目录和 schema 相对路径固化为生成代码,
/// 让消费端无需重复手写字符串常量和主键提取逻辑。
/// </summary>
/// <param name="schema">已解析的 schema 模型。</param>
/// <returns>辅助类型源码。</returns>
private static string GenerateBindingsClass(SchemaFileSpec schema)
{
var registerMethodName = $"Register{schema.EntityName}Table";
var getMethodName = $"Get{schema.EntityName}Table";
var tryGetMethodName = $"TryGet{schema.EntityName}Table";
var bindingsClassName = $"{schema.EntityName}ConfigBindings";
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
builder.AppendLine("#nullable enable");
builder.AppendLine();
builder.AppendLine($"namespace {schema.Namespace};");
builder.AppendLine();
builder.AppendLine("/// <summary>");
builder.AppendLine(
$"/// Auto-generated registration and lookup helpers for schema file '{schema.FileName}'.");
builder.AppendLine(
"/// The helper centralizes table naming, config directory, schema path, and strongly-typed registry access so consumer projects do not need to duplicate the same conventions.");
builder.AppendLine("/// </summary>");
builder.AppendLine($"public static class {bindingsClassName}");
builder.AppendLine("{");
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the runtime registration name of the generated config table.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
$" public const string TableName = {SymbolDisplay.FormatLiteral(schema.TableRegistrationName, true)};");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the config directory path expected by the generated registration helper.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
$" public const string ConfigRelativePath = {SymbolDisplay.FormatLiteral(schema.ConfigRelativePath, true)};");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the schema file path expected by the generated registration helper.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
$" public const string SchemaRelativePath = {SymbolDisplay.FormatLiteral(schema.SchemaRelativePath, true)};");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Registers the generated config table using the schema-derived runtime conventions.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"loader\">The target YAML config loader.</param>");
builder.AppendLine(
" /// <param name=\"comparer\">Optional key comparer for the generated table registration.</param>");
builder.AppendLine(" /// <returns>The same loader instance so registration can keep chaining.</returns>");
builder.AppendLine(
$" public static global::GFramework.Game.Config.YamlConfigLoader {registerMethodName}(");
builder.AppendLine(" this global::GFramework.Game.Config.YamlConfigLoader loader,");
builder.AppendLine(
$" global::System.Collections.Generic.IEqualityComparer<{schema.KeyClrType}>? comparer = null)");
builder.AppendLine(" {");
builder.AppendLine(" if (loader is null)");
builder.AppendLine(" {");
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(loader));");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(
$" return loader.RegisterTable<{schema.KeyClrType}, {schema.ClassName}>(");
builder.AppendLine(" TableName,");
builder.AppendLine(" ConfigRelativePath,");
builder.AppendLine(" SchemaRelativePath,");
builder.AppendLine($" static config => config.{schema.KeyPropertyName},");
builder.AppendLine(" comparer);");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the generated config table wrapper from the registry.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"registry\">The source config registry.</param>");
builder.AppendLine(" /// <returns>The generated strongly-typed table wrapper.</returns>");
builder.AppendLine(
" /// <exception cref=\"global::System.ArgumentNullException\">When <paramref name=\"registry\"/> is null.</exception>");
builder.AppendLine(
$" public static {schema.TableName} {getMethodName}(this global::GFramework.Game.Abstractions.Config.IConfigRegistry registry)");
builder.AppendLine(" {");
builder.AppendLine(" if (registry is null)");
builder.AppendLine(" {");
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(registry));");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(
$" return new {schema.TableName}(registry.GetTable<{schema.KeyClrType}, {schema.ClassName}>(TableName));");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Tries to get the generated config table wrapper from the registry.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"registry\">The source config registry.</param>");
builder.AppendLine(
" /// <param name=\"table\">The generated strongly-typed table wrapper when lookup succeeds; otherwise null.</param>");
builder.AppendLine(
" /// <returns>True when the generated table is registered and type-compatible; otherwise false.</returns>");
builder.AppendLine(
" /// <exception cref=\"global::System.ArgumentNullException\">When <paramref name=\"registry\"/> is null.</exception>");
builder.AppendLine(
$" public static bool {tryGetMethodName}(this global::GFramework.Game.Abstractions.Config.IConfigRegistry registry, out {schema.TableName}? table)");
builder.AppendLine(" {");
builder.AppendLine(" if (registry is null)");
builder.AppendLine(" {");
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(registry));");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(
$" if (registry.TryGetTable<{schema.KeyClrType}, {schema.ClassName}>(TableName, out var innerTable) && innerTable is not null)");
builder.AppendLine(" {");
builder.AppendLine($" table = new {schema.TableName}(innerTable);");
builder.AppendLine(" return true;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" table = null;");
builder.AppendLine(" return false;");
builder.AppendLine(" }");
builder.AppendLine("}");
return builder.ToString().TrimEnd();
}
/// <summary> /// <summary>
/// 递归生成配置对象类型。 /// 递归生成配置对象类型。
/// </summary> /// </summary>
@ -903,32 +773,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return Path.GetFileNameWithoutExtension(fileName); return Path.GetFileNameWithoutExtension(fileName);
} }
/// <summary>
/// 解析生成注册辅助时要使用的 schema 相对路径。
/// 生成器优先保留 `schemas/` 目录以下的相对路径,以便消费端默认约定和 MSBuild AdditionalFiles 约定保持一致。
/// </summary>
/// <param name="path">Schema 文件路径。</param>
/// <returns>用于运行时注册的 schema 相对路径。</returns>
private static string GetSchemaRelativePath(string path)
{
var normalizedPath = path.Replace('\\', '/');
const string rootMarker = "schemas/";
const string nestedMarker = "/schemas/";
if (normalizedPath.StartsWith(rootMarker, StringComparison.OrdinalIgnoreCase))
{
return normalizedPath;
}
var nestedMarkerIndex = normalizedPath.LastIndexOf(nestedMarker, StringComparison.OrdinalIgnoreCase);
if (nestedMarkerIndex >= 0)
{
return normalizedPath.Substring(nestedMarkerIndex + 1);
}
return $"schemas/{Path.GetFileName(path)}";
}
/// <summary> /// <summary>
/// 将 schema 名称转换为 PascalCase 标识符。 /// 将 schema 名称转换为 PascalCase 标识符。
/// </summary> /// </summary>
@ -1152,29 +996,19 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
/// 生成器级 schema 模型。 /// 生成器级 schema 模型。
/// </summary> /// </summary>
/// <param name="FileName">Schema 文件名。</param> /// <param name="FileName">Schema 文件名。</param>
/// <param name="EntityName">实体名基础标识。</param>
/// <param name="ClassName">根配置类型名。</param> /// <param name="ClassName">根配置类型名。</param>
/// <param name="TableName">配置表包装类型名。</param> /// <param name="TableName">配置表包装类型名。</param>
/// <param name="Namespace">目标命名空间。</param> /// <param name="Namespace">目标命名空间。</param>
/// <param name="KeyClrType">主键 CLR 类型。</param> /// <param name="KeyClrType">主键 CLR 类型。</param>
/// <param name="KeyPropertyName">生成配置类型中的主键属性名。</param>
/// <param name="TableRegistrationName">运行时注册名。</param>
/// <param name="ConfigRelativePath">配置目录相对路径。</param>
/// <param name="SchemaRelativePath">Schema 文件相对路径。</param>
/// <param name="Title">根标题元数据。</param> /// <param name="Title">根标题元数据。</param>
/// <param name="Description">根描述元数据。</param> /// <param name="Description">根描述元数据。</param>
/// <param name="RootObject">根对象模型。</param> /// <param name="RootObject">根对象模型。</param>
private sealed record SchemaFileSpec( private sealed record SchemaFileSpec(
string FileName, string FileName,
string EntityName,
string ClassName, string ClassName,
string TableName, string TableName,
string Namespace, string Namespace,
string KeyClrType, string KeyClrType,
string KeyPropertyName,
string TableRegistrationName,
string ConfigRelativePath,
string SchemaRelativePath,
string? Title, string? Title,
string? Description, string? Description,
SchemaObjectSpec RootObject); SchemaObjectSpec RootObject);

View File

@ -21,7 +21,3 @@ global using Microsoft.CodeAnalysis;
global using Microsoft.CodeAnalysis.CSharp.Syntax; global using Microsoft.CodeAnalysis.CSharp.Syntax;
global using Microsoft.CodeAnalysis.CSharp; global using Microsoft.CodeAnalysis.CSharp;
global using Microsoft.CodeAnalysis.Text; global using Microsoft.CodeAnalysis.Text;
global using System.Globalization;
global using System.IO;
global using System.Text;
global using System.Text.Json;

View File

@ -12,7 +12,7 @@
- JSON Schema 作为结构描述 - JSON Schema 作为结构描述
- 一对象一文件的目录组织 - 一对象一文件的目录组织
- 运行时只读查询 - 运行时只读查询
- Source Generator 生成配置类型、表包装和注册/访问辅助 - Source Generator 生成配置类型和表包装
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
## 推荐目录结构 ## 推荐目录结构
@ -83,31 +83,27 @@ dropItems:
## 运行时接入 ## 运行时接入
当你希望加载后的配置在运行时以只读表形式暴露时,优先使用生成器产出的注册与访问辅助 当你希望加载后的配置在运行时以只读表形式暴露时,可以使用 `YamlConfigLoader``ConfigRegistry`
```csharp ```csharp
using GFramework.Game.Config; using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
var registry = new ConfigRegistry(); var registry = new ConfigRegistry();
var loader = new YamlConfigLoader("config-root") var loader = new YamlConfigLoader("config-root")
.RegisterMonsterTable(); .RegisterTable<int, MonsterConfig>(
"monster",
"monster",
"schemas/monster.schema.json",
static config => config.Id);
await loader.LoadAsync(registry); await loader.LoadAsync(registry);
var monsterTable = registry.GetMonsterTable(); var monsterTable = registry.GetTable<int, MonsterConfig>("monster");
var slime = monsterTable.Get(1); var slime = monsterTable.Get(1);
``` ```
这组辅助会把以下约定固化到生成代码里: 这个重载会先按 schema 校验,再进行反序列化和注册。
- 表注册名,例如 `monster`
- 配置目录相对路径,例如 `monster`
- schema 相对路径,例如 `schemas/monster.schema.json`
- 主键提取逻辑,例如 `config => config.Id`
如果你需要自定义目录、表名或 key selector仍然可以直接调用 `YamlConfigLoader.RegisterTable(...)` 原始重载。
## 运行时校验行为 ## 运行时校验行为
@ -154,41 +150,11 @@ var slime = monsterTable.Get(1);
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 这样可以避免错误配置被默认值或 `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 ```csharp
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config; using GFramework.Game.Config;
var registry = new ConfigRegistry(); var registry = new ConfigRegistry();
@ -205,11 +171,7 @@ var hotReload = loader.EnableHotReload(
registry, registry,
onTableReloaded: tableName => Console.WriteLine($"Reloaded: {tableName}"), onTableReloaded: tableName => Console.WriteLine($"Reloaded: {tableName}"),
onTableReloadFailed: (tableName, exception) => 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}");
});
``` ```
当前热重载行为如下: 当前热重载行为如下:
@ -225,12 +187,7 @@ var hotReload = loader.EnableHotReload(
## 生成器接入约定 ## 生成器接入约定
配置生成器会从 `*.schema.json` 生成以下代码: 配置生成器会从 `*.schema.json` 生成配置类型和表包装类。
- 配置类型
- 表包装类型
- `YamlConfigLoader` 注册辅助
- `IConfigRegistry` 强类型访问辅助
通过已打包的 Source Generator 使用时,默认会自动收集 `schemas/**/*.schema.json` 作为 `AdditionalFiles` 通过已打包的 Source Generator 使用时,默认会自动收集 `schemas/**/*.schema.json` 作为 `AdditionalFiles`