feat(config): 添加 YAML 配置文件 JSON Schema 校验器

- 实现了 YAML 配置与 JSON Schema 的运行时校验功能
- 支持嵌套对象、对象数组、标量数组的递归校验
- 集成了 enum 和引用约束的深度校验机制
- 实现了 multipleOf、uniqueItems 等扩展约束规则
- 添加了跨表引用收集和校验能力
- 提供了异步和同步两种加载校验接口
- 支持 minContains/maxContains 数组匹配计数规则
- 实现了 minProperties/maxProperties 对象属性数量校验
- 集成了日期时间、邮箱、URI 等字符串格式校验
- 提供了详细的错误诊断信息和定位功能
This commit is contained in:
GeWuYou 2026-04-12 14:28:31 +08:00
parent 925b6ce2d2
commit e40703c202
7 changed files with 143 additions and 7 deletions

View File

@ -1,5 +1,4 @@
using System.IO;
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
@ -12,6 +11,8 @@ namespace GFramework.Game.Tests.Config;
[TestFixture]
public class GeneratedConfigConsumerIntegrationTests
{
private string _rootPath = null!;
/// <summary>
/// 为每个端到端测试准备独立的配置根目录,避免编译期 schema 资产与运行时写入互相污染。
/// </summary>
@ -34,8 +35,6 @@ public class GeneratedConfigConsumerIntegrationTests
}
}
private string _rootPath = null!;
/// <summary>
/// 验证生成器自动拾取消费者项目的 schema 后,
/// 可以用生成的聚合注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助。
@ -281,12 +280,17 @@ public class GeneratedConfigConsumerIntegrationTests
var exception = Assert.Throws<ConfigLoadException>(() =>
MonsterConfigBindings.ValidateYaml(_rootPath, "monster/generated.yaml", invalidYaml));
var asyncException = Assert.ThrowsAsync<ConfigLoadException>(async () =>
await MonsterConfigBindings.ValidateYamlAsync(_rootPath, "monster/generated.yaml", invalidYaml));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Diagnostic.SchemaPath, Is.EqualTo(schemaPath));
Assert.That(exception.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.UnknownProperty));
Assert.That(asyncException, Is.Not.Null);
Assert.That(asyncException!.Diagnostic.SchemaPath, Is.EqualTo(schemaPath));
Assert.That(asyncException.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.UnknownProperty));
});
}

View File

@ -0,0 +1,93 @@
using System.IO;
using GFramework.Game.Config;
namespace GFramework.Game.Tests.Config;
/// <summary>
/// 验证内部 schema 解析器会输出稳定且可预期的运行时依赖元数据。
/// </summary>
[TestFixture]
public sealed class YamlConfigSchemaValidatorTests
{
private string _rootPath = null!;
/// <summary>
/// 为每个测试准备独立临时目录。
/// </summary>
[SetUp]
public void SetUp()
{
_rootPath = Path.Combine(Path.GetTempPath(), "GFramework.SchemaValidatorTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_rootPath);
}
/// <summary>
/// 清理测试临时目录。
/// </summary>
[TearDown]
public void TearDown()
{
if (Directory.Exists(_rootPath))
{
Directory.Delete(_rootPath, true);
}
}
/// <summary>
/// 验证 schema 中声明的跨表引用名称会以序数排序形式输出,
/// 避免热重载依赖推导与测试快照受哈希集合枚举顺序影响。
/// </summary>
[Test]
public void Load_Should_Return_Referenced_Table_Names_In_Ordinal_Sorted_Order()
{
var schemaPath = CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"properties": {
"weaponId": {
"type": "string",
"x-gframework-ref-table": "weapon"
},
"allies": {
"type": "array",
"items": {
"type": "integer",
"x-gframework-ref-table": "ally"
}
},
"itemId": {
"type": "string",
"x-gframework-ref-table": "item"
}
}
}
""");
var schema = YamlConfigSchemaValidator.Load("monster", schemaPath);
Assert.That(schema.ReferencedTableNames, Is.EqualTo(new[] { "ally", "item", "weapon" }));
}
/// <summary>
/// 在临时目录中创建 schema 文件。
/// </summary>
/// <param name="relativePath">相对根目录的路径。</param>
/// <param name="content">文件内容。</param>
/// <returns>写入后的绝对路径。</returns>
private string CreateSchemaFile(
string relativePath,
string content)
{
var fullPath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
var directoryPath = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(directoryPath))
{
Directory.CreateDirectory(directoryPath);
}
File.WriteAllText(fullPath, content.Replace("\n", Environment.NewLine, StringComparison.Ordinal));
return fullPath;
}
}

View File

@ -267,8 +267,13 @@ internal static class YamlConfigSchemaValidator
var referencedTableNames = new HashSet<string>(StringComparer.Ordinal);
CollectReferencedTableNames(rootNode, referencedTableNames);
// Preserve a deterministic dependency order so hot-reload bookkeeping and tests
// do not depend on HashSet enumeration details.
var orderedReferencedTableNames = referencedTableNames
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
return new YamlConfigSchema(schemaPath, rootNode, referencedTableNames.ToArray());
return new YamlConfigSchema(schemaPath, rootNode, orderedReferencedTableNames);
}
catch (JsonException exception)
{

View File

@ -1,6 +1,3 @@
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace GFramework.Game.Config;
/// <summary>
@ -20,6 +17,7 @@ public static class YamlConfigTextSerializer
/// <typeparam name="TValue">配置对象类型。</typeparam>
/// <param name="value">要序列化的配置对象。</param>
/// <returns>带尾随换行的 YAML 文本。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="value" /> 为 <see langword="null" /> 时抛出。</exception>
public static string Serialize<TValue>(TValue value)
{
ArgumentNullException.ThrowIfNull(value);

View File

@ -12,6 +12,9 @@ public static class YamlConfigTextValidator
/// <param name="schemaPath">Schema 文件绝对路径。</param>
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
/// <param name="yamlText">待校验的 YAML 文本。</param>
/// <exception cref="ArgumentException">当 <paramref name="tableName" /> 或 <paramref name="schemaPath" /> 为空白时抛出。</exception>
/// <exception cref="ArgumentNullException">当 <paramref name="yamlPath" /> 或 <paramref name="yamlText" /> 为 <see langword="null" /> 时抛出。</exception>
/// <exception cref="GFramework.Game.Abstractions.Config.ConfigLoadException">当 schema 文件不可用,或 YAML 内容与 schema 不匹配时抛出。</exception>
public static void Validate(
string tableName,
string schemaPath,
@ -30,6 +33,10 @@ public static class YamlConfigTextValidator
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
/// <param name="yamlText">待校验的 YAML 文本。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示异步校验操作的任务。</returns>
/// <exception cref="ArgumentException">当 <paramref name="tableName" /> 或 <paramref name="schemaPath" /> 为空白时抛出。</exception>
/// <exception cref="ArgumentNullException">当 <paramref name="yamlPath" /> 或 <paramref name="yamlText" /> 为 <see langword="null" /> 时抛出。</exception>
/// <exception cref="GFramework.Game.Abstractions.Config.ConfigLoadException">当 schema 文件不可用,或 YAML 内容与 schema 不匹配时抛出。</exception>
public static async Task ValidateAsync(
string tableName,
string schemaPath,

View File

@ -105,6 +105,7 @@ public static class MonsterConfigBindings
/// </summary>
/// <param name="config">The generated config instance to serialize.</param>
/// <returns>YAML text that preserves the shared camelCase field naming convention.</returns>
/// <exception cref="global::System.ArgumentNullException">Thrown when <paramref name="config"/> is <see langword="null"/>.</exception>
public static string SerializeToYaml(MonsterConfig config)
{
return global::GFramework.Game.Config.YamlConfigTextSerializer.Serialize(config);
@ -115,6 +116,7 @@ public static class MonsterConfigBindings
/// </summary>
/// <param name="configRootPath">Absolute or workspace-local config root directory.</param>
/// <returns>The absolute config directory path for the generated table.</returns>
/// <exception cref="global::System.ArgumentException">Thrown when <paramref name="configRootPath"/> is null, empty, or whitespace.</exception>
public static string GetConfigDirectoryPath(string configRootPath)
{
return ResolveAbsolutePath(configRootPath, Metadata.ConfigRelativePath);
@ -125,6 +127,7 @@ public static class MonsterConfigBindings
/// </summary>
/// <param name="configRootPath">Absolute or workspace-local config root directory.</param>
/// <returns>The absolute schema file path for the generated table.</returns>
/// <exception cref="global::System.ArgumentException">Thrown when <paramref name="configRootPath"/> is null, empty, or whitespace.</exception>
public static string GetSchemaPath(string configRootPath)
{
return ResolveAbsolutePath(configRootPath, Metadata.SchemaRelativePath);
@ -136,6 +139,9 @@ public static class MonsterConfigBindings
/// <param name="configRootPath">Absolute or workspace-local config root directory.</param>
/// <param name="yamlPath">Logical or absolute YAML path used for diagnostics.</param>
/// <param name="yamlText">YAML text to validate.</param>
/// <exception cref="global::System.ArgumentException">Thrown when <paramref name="configRootPath"/> is null, empty, or whitespace.</exception>
/// <exception cref="global::System.ArgumentNullException">Thrown when <paramref name="yamlPath"/> or <paramref name="yamlText"/> is <see langword="null"/>.</exception>
/// <exception cref="global::GFramework.Game.Abstractions.Config.ConfigLoadException">Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation.</exception>
public static void ValidateYaml(string configRootPath, string yamlPath, string yamlText)
{
global::GFramework.Game.Config.YamlConfigTextValidator.Validate(
@ -152,6 +158,10 @@ public static class MonsterConfigBindings
/// <param name="yamlPath">Logical or absolute YAML path used for diagnostics.</param>
/// <param name="yamlText">YAML text to validate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous validation operation.</returns>
/// <exception cref="global::System.ArgumentException">Thrown when <paramref name="configRootPath"/> is null, empty, or whitespace.</exception>
/// <exception cref="global::System.ArgumentNullException">Thrown when <paramref name="yamlPath"/> or <paramref name="yamlText"/> is <see langword="null"/>.</exception>
/// <exception cref="global::GFramework.Game.Abstractions.Config.ConfigLoadException">Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation.</exception>
public static global::System.Threading.Tasks.Task ValidateYamlAsync(
string configRootPath,
string yamlPath,

View File

@ -1691,6 +1691,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" /// <param name=\"config\">The generated config instance to serialize.</param>");
builder.AppendLine(
" /// <returns>YAML text that preserves the shared camelCase field naming convention.</returns>");
builder.AppendLine(
" /// <exception cref=\"global::System.ArgumentNullException\">Thrown when <paramref name=\"config\"/> is <see langword=\"null\"/>.</exception>");
builder.AppendLine($" public static string SerializeToYaml({schema.ClassName} config)");
builder.AppendLine(" {");
builder.AppendLine(" return global::GFramework.Game.Config.YamlConfigTextSerializer.Serialize(config);");
@ -1703,6 +1705,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(
" /// <param name=\"configRootPath\">Absolute or workspace-local config root directory.</param>");
builder.AppendLine(" /// <returns>The absolute config directory path for the generated table.</returns>");
builder.AppendLine(
" /// <exception cref=\"global::System.ArgumentException\">Thrown when <paramref name=\"configRootPath\"/> is null, empty, or whitespace.</exception>");
builder.AppendLine(" public static string GetConfigDirectoryPath(string configRootPath)");
builder.AppendLine(" {");
builder.AppendLine(" return ResolveAbsolutePath(configRootPath, Metadata.ConfigRelativePath);");
@ -1715,6 +1719,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(
" /// <param name=\"configRootPath\">Absolute or workspace-local config root directory.</param>");
builder.AppendLine(" /// <returns>The absolute schema file path for the generated table.</returns>");
builder.AppendLine(
" /// <exception cref=\"global::System.ArgumentException\">Thrown when <paramref name=\"configRootPath\"/> is null, empty, or whitespace.</exception>");
builder.AppendLine(" public static string GetSchemaPath(string configRootPath)");
builder.AppendLine(" {");
builder.AppendLine(" return ResolveAbsolutePath(configRootPath, Metadata.SchemaRelativePath);");
@ -1729,6 +1735,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(
" /// <param name=\"yamlPath\">Logical or absolute YAML path used for diagnostics.</param>");
builder.AppendLine(" /// <param name=\"yamlText\">YAML text to validate.</param>");
builder.AppendLine(
" /// <exception cref=\"global::System.ArgumentException\">Thrown when <paramref name=\"configRootPath\"/> is null, empty, or whitespace.</exception>");
builder.AppendLine(
" /// <exception cref=\"global::System.ArgumentNullException\">Thrown when <paramref name=\"yamlPath\"/> or <paramref name=\"yamlText\"/> is <see langword=\"null\"/>.</exception>");
builder.AppendLine(
" /// <exception cref=\"global::GFramework.Game.Abstractions.Config.ConfigLoadException\">Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation.</exception>");
builder.AppendLine(
" public static void ValidateYaml(string configRootPath, string yamlPath, string yamlText)");
builder.AppendLine(" {");
@ -1749,6 +1761,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
" /// <param name=\"yamlPath\">Logical or absolute YAML path used for diagnostics.</param>");
builder.AppendLine(" /// <param name=\"yamlText\">YAML text to validate.</param>");
builder.AppendLine(" /// <param name=\"cancellationToken\">Cancellation token.</param>");
builder.AppendLine(" /// <returns>A task that represents the asynchronous validation operation.</returns>");
builder.AppendLine(
" /// <exception cref=\"global::System.ArgumentException\">Thrown when <paramref name=\"configRootPath\"/> is null, empty, or whitespace.</exception>");
builder.AppendLine(
" /// <exception cref=\"global::System.ArgumentNullException\">Thrown when <paramref name=\"yamlPath\"/> or <paramref name=\"yamlText\"/> is <see langword=\"null\"/>.</exception>");
builder.AppendLine(
" /// <exception cref=\"global::GFramework.Game.Abstractions.Config.ConfigLoadException\">Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation.</exception>");
builder.AppendLine(
" public static global::System.Threading.Tasks.Task ValidateYamlAsync(");
builder.AppendLine(" string configRootPath,");