From 1fac2764375d2241c13ad9afa24ab40fe75eb6d2 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Sun, 12 Apr 2026 14:06:06 +0800
Subject: [PATCH 1/7] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0YAML?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E7=9A=84JSON=20Schema?=
=?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现了YAML配置与JSON Schema的运行时校验能力
- 支持嵌套对象、对象数组、标量数组的递归校验
- 提供async和sync两种模式的schema文件加载解析
- 实现跨表引用的收集与校验机制
- 支持enum枚举值、引用约束和深层约束校验
- 添加了multipleOf、uniqueItems、contains等高级校验功能
- 实现了minProperties、maxProperties对象属性数量校验
- 提供详细的错误诊断信息和路径定位功能
---
...GeneratedConfigConsumerIntegrationTests.cs | 73 +++-
.../Config/YamlConfigTextValidatorTests.cs | 165 ++++++++
.../Config/YamlConfigSchemaValidator.cs | 371 +++++++++++-------
.../Config/YamlConfigTextSerializer.cs | 29 ++
.../Config/YamlConfigTextValidator.cs | 44 +++
.../Config/SchemaConfigGeneratorTests.cs | 8 +
.../MonsterConfigBindings.g.txt | 83 ++++
.../Config/SchemaConfigGenerator.cs | 144 ++++++-
8 files changed, 753 insertions(+), 164 deletions(-)
create mode 100644 GFramework.Game.Tests/Config/YamlConfigTextValidatorTests.cs
create mode 100644 GFramework.Game/Config/YamlConfigTextSerializer.cs
create mode 100644 GFramework.Game/Config/YamlConfigTextValidator.cs
diff --git a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
index b44a1cd5..9422aa75 100644
--- a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
+++ b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
@@ -1,6 +1,4 @@
-using System;
using System.IO;
-using System.Linq;
using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
@@ -13,6 +11,8 @@ namespace GFramework.Game.Tests.Config;
[TestFixture]
public class GeneratedConfigConsumerIntegrationTests
{
+ private string _rootPath = null!;
+
///
/// 为每个端到端测试准备独立的配置根目录,避免编译期 schema 资产与运行时写入互相污染。
///
@@ -35,8 +35,6 @@ public class GeneratedConfigConsumerIntegrationTests
}
}
- private string _rootPath = null!;
-
///
/// 验证生成器自动拾取消费者项目的 schema 后,
/// 可以用生成的聚合注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助。
@@ -88,7 +86,8 @@ public class GeneratedConfigConsumerIntegrationTests
Assert.That(monsterTable.Get(1).Name, Is.EqualTo("Slime"));
Assert.That(monsterTable.Get(2).Hp, Is.EqualTo(30));
Assert.That(monsterTable.FindByName("Slime").Select(static config => config.Id), Is.EqualTo(new[] { 1 }));
- Assert.That(dungeonMonsters.Select(static config => config.Name), Is.EquivalentTo(new[] { "Slime", "Goblin" }));
+ Assert.That(dungeonMonsters.Select(static config => config.Name),
+ Is.EquivalentTo(new[] { "Slime", "Goblin" }));
Assert.That(monsterTable.TryFindFirstByName("Goblin", out var goblin), Is.True);
Assert.That(goblin, Is.Not.Null);
Assert.That(goblin!.Id, Is.EqualTo(2));
@@ -154,10 +153,13 @@ public class GeneratedConfigConsumerIntegrationTests
Is.EqualTo(new[] { MonsterConfigBindings.TableName }));
Assert.That(GeneratedConfigCatalog.GetTablesForRegistration().Select(static metadata => metadata.TableName),
Is.SupersetOf(new[] { ItemConfigBindings.TableName, MonsterConfigBindings.TableName }));
- Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(monsterMetadata, monsterOnlyOptions), Is.True);
+ Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(monsterMetadata, monsterOnlyOptions),
+ Is.True);
Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(itemMetadata, monsterOnlyOptions), Is.False);
- Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(monsterMetadata, predicateOnlyOptions), Is.True);
- Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(itemMetadata, predicateOnlyOptions), Is.False);
+ Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(monsterMetadata, predicateOnlyOptions),
+ Is.True);
+ Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(itemMetadata, predicateOnlyOptions),
+ Is.False);
Assert.That(GeneratedConfigCatalog.MatchesRegistrationOptions(monsterMetadata, options: null), Is.True);
});
}
@@ -232,6 +234,61 @@ public class GeneratedConfigConsumerIntegrationTests
});
}
+ ///
+ /// 验证生成绑定会同时暴露 YAML 序列化、schema 路径解析与文本校验入口。
+ ///
+ [Test]
+ public async Task GeneratedBindings_Should_Expose_Serializer_And_Validator_Helpers()
+ {
+ CreateMonsterFiles();
+
+ var config = new MonsterConfig
+ {
+ Id = 3,
+ Name = "Bat",
+ Hp = 12,
+ Faction = "cave"
+ };
+
+ var yaml = MonsterConfigBindings.SerializeToYaml(config);
+ var schemaPath = MonsterConfigBindings.GetSchemaPath(_rootPath);
+ var configDirectoryPath = MonsterConfigBindings.GetConfigDirectoryPath(_rootPath);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(schemaPath, Is.EqualTo(Path.Combine(_rootPath, "schemas", "monster.schema.json")));
+ Assert.That(configDirectoryPath, Is.EqualTo(Path.Combine(_rootPath, "monster")));
+ Assert.That(yaml, Does.Contain("id: 3"));
+ Assert.That(yaml, Does.Contain("name: Bat"));
+ Assert.That(yaml, Does.Contain("hp: 12"));
+ Assert.That(yaml, Does.Contain("faction: cave"));
+ Assert.That(yaml.EndsWith(Environment.NewLine, StringComparison.Ordinal), Is.True);
+ });
+
+ Assert.DoesNotThrow(() =>
+ MonsterConfigBindings.ValidateYaml(_rootPath, "monster/generated.yaml", yaml));
+
+ Assert.DoesNotThrowAsync(async () =>
+ await MonsterConfigBindings.ValidateYamlAsync(_rootPath, "monster/generated.yaml", yaml));
+
+ var invalidYaml = """
+ id: 3
+ name: Bat
+ hp: 12
+ unknownField: true
+ """;
+
+ var exception = Assert.Throws(() =>
+ MonsterConfigBindings.ValidateYaml(_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));
+ });
+ }
+
///
/// 在临时消费者根目录中创建测试文件。
///
diff --git a/GFramework.Game.Tests/Config/YamlConfigTextValidatorTests.cs b/GFramework.Game.Tests/Config/YamlConfigTextValidatorTests.cs
new file mode 100644
index 00000000..02403d75
--- /dev/null
+++ b/GFramework.Game.Tests/Config/YamlConfigTextValidatorTests.cs
@@ -0,0 +1,165 @@
+using System.IO;
+using GFramework.Game.Config;
+
+namespace GFramework.Game.Tests.Config;
+
+///
+/// 验证公开的 YAML 文本校验入口可以在保存前复用运行时同一套 schema 规则。
+///
+[TestFixture]
+public sealed class YamlConfigTextValidatorTests
+{
+ private string _rootPath = null!;
+
+ ///
+ /// 为每个测试准备独立临时目录。
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ _rootPath = Path.Combine(Path.GetTempPath(), "GFramework.TextValidatorTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_rootPath);
+ }
+
+ ///
+ /// 清理测试临时目录。
+ ///
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(_rootPath))
+ {
+ Directory.Delete(_rootPath, true);
+ }
+ }
+
+ ///
+ /// 验证合法 YAML 文本会通过公开校验入口。
+ ///
+ [Test]
+ public void Validate_Should_Succeed_When_Yaml_Matches_Schema()
+ {
+ var schemaPath = CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name", "hp"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "hp": { "type": "integer" }
+ }
+ }
+ """);
+
+ Assert.DoesNotThrow(() =>
+ YamlConfigTextValidator.Validate(
+ "monster",
+ schemaPath,
+ "monster/generated.yaml",
+ """
+ id: 1
+ name: Slime
+ hp: 10
+ """));
+ }
+
+ ///
+ /// 验证结构错误会继续通过稳定的配置异常类型暴露给宿主。
+ ///
+ [Test]
+ public void Validate_Should_Throw_ConfigLoadException_When_Yaml_Contains_Unknown_Field()
+ {
+ var schemaPath = CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" }
+ }
+ }
+ """);
+
+ var exception = Assert.Throws(() =>
+ YamlConfigTextValidator.Validate(
+ "monster",
+ schemaPath,
+ "monster/generated.yaml",
+ """
+ id: 1
+ name: Slime
+ hp: 10
+ """));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception, Is.Not.Null);
+ Assert.That(exception!.Diagnostic.TableName, Is.EqualTo("monster"));
+ Assert.That(exception.Diagnostic.SchemaPath, Is.EqualTo(schemaPath));
+ Assert.That(exception.Diagnostic.YamlPath, Is.EqualTo("monster/generated.yaml"));
+ Assert.That(exception.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.UnknownProperty));
+ });
+ }
+
+ ///
+ /// 验证异步入口与同步入口共享相同校验语义。
+ ///
+ [Test]
+ public async Task ValidateAsync_Should_Throw_ConfigLoadException_When_Required_Field_Is_Missing()
+ {
+ var schemaPath = CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" }
+ }
+ }
+ """);
+
+ var exception = Assert.ThrowsAsync(async () =>
+ await YamlConfigTextValidator.ValidateAsync(
+ "monster",
+ schemaPath,
+ "monster/generated.yaml",
+ """
+ id: 1
+ """));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception, Is.Not.Null);
+ Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.MissingRequiredProperty));
+ Assert.That(exception.Diagnostic.SchemaPath, Is.EqualTo(schemaPath));
+ Assert.That(exception.Diagnostic.YamlPath, Is.EqualTo("monster/generated.yaml"));
+ });
+ }
+
+ ///
+ /// 在临时目录中创建 schema 文件。
+ ///
+ /// 相对根目录的路径。
+ /// 文件内容。
+ /// 写入后的绝对路径。
+ 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;
+ }
+}
diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
index 3c86d649..4c689264 100644
--- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs
+++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
@@ -19,18 +19,23 @@ internal static class YamlConfigSchemaValidator
// JS tooling so grouping and backreferences behave consistently across environments.
private const RegexOptions SupportedPatternRegexOptions = RegexOptions.CultureInvariant;
private const string SupportedStringFormatNames = "'date', 'date-time', 'email', 'uri', 'uuid'";
+
private static readonly Regex ExactDecimalPattern = new(
@"^(?[+-]?)(?:(?\d+)(?:\.(?\d*))?|\.(?\d+))(?:[eE](?[+-]?\d+))?$",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
+
private static readonly Regex SupportedEmailFormatRegex = new(
@"^[^@\s]+@[^@\s]+\.[^@\s]+$",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
+
private static readonly Regex SupportedDateFormatRegex = new(
@"^(?\d{4})-(?\d{2})-(?\d{2})$",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
+
private static readonly Regex SupportedDateTimeFormatRegex = new(
@"^(?\d{4})-(?\d{2})-(?\d{2})T(?\d{2}):(?\d{2}):(?\d{2})(?\.\d+)?(?Z|[+-]\d{2}:\d{2})$",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
+
private static readonly Regex SupportedUriSchemeRegex = new(
@"^[A-Za-z][A-Za-z0-9+\.-]*:",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
@@ -84,34 +89,57 @@ internal static class YamlConfigSchemaValidator
innerException: exception);
}
- try
+ return ParseLoadedSchema(tableName, schemaPath, schemaText);
+ }
+
+ ///
+ /// 从磁盘同步加载并解析一个 JSON Schema 文件。
+ ///
+ /// 所属配置表名称。
+ /// Schema 文件路径。
+ /// 解析后的 schema 模型。
+ /// 当 为空时抛出。
+ /// 当 为空时抛出。
+ /// 当 schema 文件不存在或内容非法时抛出。
+ internal static YamlConfigSchema Load(
+ string tableName,
+ string schemaPath)
+ {
+ if (string.IsNullOrWhiteSpace(tableName))
{
- using var document = JsonDocument.Parse(schemaText);
- var root = document.RootElement;
- var rootNode = ParseNode(tableName, schemaPath, "", root, isRoot: true);
- if (rootNode.NodeType != YamlConfigSchemaPropertyType.Object)
- {
- throw ConfigLoadExceptionFactory.Create(
- ConfigLoadFailureKind.SchemaUnsupported,
- tableName,
- $"Schema file '{schemaPath}' must declare a root object schema.",
- schemaPath: schemaPath);
- }
-
- var referencedTableNames = new HashSet(StringComparer.Ordinal);
- CollectReferencedTableNames(rootNode, referencedTableNames);
-
- return new YamlConfigSchema(schemaPath, rootNode, referencedTableNames.ToArray());
+ throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName));
}
- catch (JsonException exception)
+
+ if (string.IsNullOrWhiteSpace(schemaPath))
+ {
+ throw new ArgumentException("Schema path cannot be null or whitespace.", nameof(schemaPath));
+ }
+
+ if (!File.Exists(schemaPath))
{
throw ConfigLoadExceptionFactory.Create(
- ConfigLoadFailureKind.SchemaInvalidJson,
+ ConfigLoadFailureKind.SchemaFileNotFound,
tableName,
- $"Schema file '{schemaPath}' contains invalid JSON.",
+ $"Schema file '{schemaPath}' was not found.",
+ schemaPath: schemaPath);
+ }
+
+ string schemaText;
+ try
+ {
+ schemaText = File.ReadAllText(schemaPath);
+ }
+ catch (Exception exception)
+ {
+ throw ConfigLoadExceptionFactory.Create(
+ ConfigLoadFailureKind.SchemaReadFailed,
+ tableName,
+ $"Failed to read schema file '{schemaPath}'.",
schemaPath: schemaPath,
innerException: exception);
}
+
+ return ParseLoadedSchema(tableName, schemaPath, schemaText);
}
///
@@ -211,6 +239,48 @@ internal static class YamlConfigSchemaValidator
ValidateNode(tableName, yamlPath, string.Empty, yamlStream.Documents[0].RootNode, schema.RootNode, references);
}
+ ///
+ /// 解析已读取到内存中的 schema 文本,并构造运行时最小模型。
+ ///
+ /// 所属配置表名称。
+ /// Schema 文件路径,仅用于诊断信息。
+ /// Schema 文本内容。
+ /// 解析后的 schema 模型。
+ private static YamlConfigSchema ParseLoadedSchema(
+ string tableName,
+ string schemaPath,
+ string schemaText)
+ {
+ try
+ {
+ using var document = JsonDocument.Parse(schemaText);
+ var root = document.RootElement;
+ var rootNode = ParseNode(tableName, schemaPath, "", root, isRoot: true);
+ if (rootNode.NodeType != YamlConfigSchemaPropertyType.Object)
+ {
+ throw ConfigLoadExceptionFactory.Create(
+ ConfigLoadFailureKind.SchemaUnsupported,
+ tableName,
+ $"Schema file '{schemaPath}' must declare a root object schema.",
+ schemaPath: schemaPath);
+ }
+
+ var referencedTableNames = new HashSet(StringComparer.Ordinal);
+ CollectReferencedTableNames(rootNode, referencedTableNames);
+
+ return new YamlConfigSchema(schemaPath, rootNode, referencedTableNames.ToArray());
+ }
+ catch (JsonException exception)
+ {
+ throw ConfigLoadExceptionFactory.Create(
+ ConfigLoadFailureKind.SchemaInvalidJson,
+ tableName,
+ $"Schema file '{schemaPath}' contains invalid JSON.",
+ schemaPath: schemaPath,
+ innerException: exception);
+ }
+ }
+
///
/// 递归解析 schema 节点,使运行时只保留校验真正需要的最小结构信息。
///
@@ -662,7 +732,8 @@ internal static class YamlConfigSchemaValidator
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
- detail: $"Minimum property count: {constraints.MinProperties.Value.ToString(CultureInfo.InvariantCulture)}.");
+ detail:
+ $"Minimum property count: {constraints.MinProperties.Value.ToString(CultureInfo.InvariantCulture)}.");
}
if (constraints.MaxProperties.HasValue &&
@@ -676,7 +747,8 @@ internal static class YamlConfigSchemaValidator
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
- detail: $"Maximum property count: {constraints.MaxProperties.Value.ToString(CultureInfo.InvariantCulture)}.");
+ detail:
+ $"Maximum property count: {constraints.MaxProperties.Value.ToString(CultureInfo.InvariantCulture)}.");
}
}
@@ -1017,7 +1089,7 @@ internal static class YamlConfigSchemaValidator
}
var properties = schemaNode.Properties
- ?? throw new InvalidOperationException("Object schema nodes must expose declared properties.");
+ ?? throw new InvalidOperationException("Object schema nodes must expose declared properties.");
var objectEntries = new List>();
foreach (var property in element.EnumerateObject())
{
@@ -1087,19 +1159,18 @@ internal static class YamlConfigSchemaValidator
return "[" +
string.Join(
",",
- element.EnumerateArray().Select(
- (item, index) =>
- {
- var comparableValue = BuildComparableConstantValue(
- tableName,
- schemaPath,
- $"{propertyPath}[{index}]",
- keywordName,
- item,
- schemaNode.ItemNode);
- return
- $"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}";
- })) +
+ element.EnumerateArray().Select((item, index) =>
+ {
+ var comparableValue = BuildComparableConstantValue(
+ tableName,
+ schemaPath,
+ $"{propertyPath}[{index}]",
+ keywordName,
+ item,
+ schemaNode.ItemNode);
+ return
+ $"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}";
+ })) +
"]";
}
@@ -1133,11 +1204,11 @@ internal static class YamlConfigSchemaValidator
}
///
-/// 解析标量字段支持的范围、长度与模式约束。
-/// 当前共享子集支持:
-/// `integer/number` 上的 `minimum/maximum/exclusiveMinimum/exclusiveMaximum`,
-/// 以及 `string` 上的 `minLength/maxLength/pattern/format`。
-///
+ /// 解析标量字段支持的范围、长度与模式约束。
+ /// 当前共享子集支持:
+ /// `integer/number` 上的 `minimum/maximum/exclusiveMinimum/exclusiveMaximum`,
+ /// 以及 `string` 上的 `minLength/maxLength/pattern/format`。
+ ///
/// 所属配置表名称。
/// Schema 文件路径。
/// 字段路径。
@@ -1420,7 +1491,8 @@ internal static class YamlConfigSchemaValidator
JsonElement element,
YamlConfigSchemaPropertyType nodeType)
{
- var multipleOf = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "multipleOf");
+ var multipleOf =
+ TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "multipleOf");
if (!multipleOf.HasValue)
{
return null;
@@ -2060,7 +2132,8 @@ internal static class YamlConfigSchemaValidator
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
- detail: $"Exclusive minimum allowed value: {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}.");
+ detail:
+ $"Exclusive minimum allowed value: {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}.");
}
if (constraints.Maximum.HasValue && numericValue > constraints.Maximum.Value)
@@ -2086,7 +2159,8 @@ internal static class YamlConfigSchemaValidator
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
- detail: $"Exclusive maximum allowed value: {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}.");
+ detail:
+ $"Exclusive maximum allowed value: {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}.");
}
if (constraints.MultipleOf.HasValue &&
@@ -2100,7 +2174,8 @@ internal static class YamlConfigSchemaValidator
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue,
- detail: $"Required numeric step: {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}.");
+ detail:
+ $"Required numeric step: {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}.");
}
}
@@ -2534,7 +2609,8 @@ internal static class YamlConfigSchemaValidator
return true;
}
- catch (ConfigLoadException exception) when (exception.Diagnostic.FailureKind != ConfigLoadFailureKind.UnexpectedFailure)
+ catch (ConfigLoadException exception) when (exception.Diagnostic.FailureKind !=
+ ConfigLoadFailureKind.UnexpectedFailure)
{
return false;
}
@@ -2577,7 +2653,8 @@ internal static class YamlConfigSchemaValidator
}
var properties = schemaNode.Properties
- ?? throw new InvalidOperationException("Validated object nodes must expose declared properties.");
+ ?? throw new InvalidOperationException(
+ "Validated object nodes must expose declared properties.");
var objectEntries = new List>(mappingNode.Children.Count);
foreach (var entry in mappingNode.Children)
{
@@ -2619,12 +2696,11 @@ internal static class YamlConfigSchemaValidator
return "[" +
string.Join(
",",
- sequenceNode.Children.Select(
- item =>
- {
- var comparableValue = BuildComparableNodeValue(item, schemaNode.ItemNode);
- return $"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}";
- })) +
+ sequenceNode.Children.Select(item =>
+ {
+ var comparableValue = BuildComparableNodeValue(item, schemaNode.ItemNode);
+ return $"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}";
+ })) +
"]";
}
@@ -2644,7 +2720,8 @@ internal static class YamlConfigSchemaValidator
}
var normalizedScalar = NormalizeScalarValue(schemaNode.NodeType, scalarNode.Value);
- return $"{schemaNode.NodeType}:{normalizedScalar.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedScalar}";
+ return
+ $"{schemaNode.NodeType}:{normalizedScalar.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedScalar}";
}
///
@@ -3119,87 +3196,6 @@ internal sealed class YamlConfigSchemaNode
private readonly NodeChildren _children;
private readonly NodeValidation _validation;
- ///
- /// 创建对象节点描述。
- ///
- /// 对象属性集合。
- /// 对象必填属性集合。
- /// 对象属性数量约束。
- /// 用于错误信息的 schema 文件路径提示。
- /// 对象节点模型。
- public static YamlConfigSchemaNode CreateObject(
- IReadOnlyDictionary? properties,
- IReadOnlyCollection? requiredProperties,
- YamlConfigObjectConstraints? objectConstraints,
- string schemaPathHint)
- {
- return new YamlConfigSchemaNode(
- YamlConfigSchemaPropertyType.Object,
- new NodeChildren(properties, requiredProperties, itemNode: null),
- new NodeValidation(
- referenceTableName: null,
- allowedValues: null,
- constraints: null,
- arrayConstraints: null,
- objectConstraints,
- constantValue: null),
- schemaPathHint);
- }
-
- ///
- /// 创建数组节点描述。
- ///
- /// 数组元素节点。
- /// 数组元素数量约束。
- /// 用于错误信息的 schema 文件路径提示。
- /// 数组节点模型。
- public static YamlConfigSchemaNode CreateArray(
- YamlConfigSchemaNode itemNode,
- YamlConfigArrayConstraints? arrayConstraints,
- string schemaPathHint)
- {
- return new YamlConfigSchemaNode(
- YamlConfigSchemaPropertyType.Array,
- new NodeChildren(properties: null, requiredProperties: null, itemNode),
- new NodeValidation(
- referenceTableName: null,
- allowedValues: null,
- constraints: null,
- arrayConstraints,
- objectConstraints: null,
- constantValue: null),
- schemaPathHint);
- }
-
- ///
- /// 创建标量节点描述。
- ///
- /// 标量节点类型。
- /// 目标引用表名称。
- /// 标量允许值集合。
- /// 标量范围与长度约束。
- /// 用于错误信息的 schema 文件路径提示。
- /// 标量节点模型。
- public static YamlConfigSchemaNode CreateScalar(
- YamlConfigSchemaPropertyType nodeType,
- string? referenceTableName,
- IReadOnlyCollection? allowedValues,
- YamlConfigScalarConstraints? constraints,
- string schemaPathHint)
- {
- return new YamlConfigSchemaNode(
- nodeType,
- NodeChildren.None,
- new NodeValidation(
- referenceTableName,
- allowedValues,
- constraints,
- arrayConstraints: null,
- objectConstraints: null,
- constantValue: null),
- schemaPathHint);
- }
-
private YamlConfigSchemaNode(
YamlConfigSchemaPropertyType nodeType,
NodeChildren children,
@@ -3281,6 +3277,87 @@ internal sealed class YamlConfigSchemaNode
///
public string SchemaPathHint { get; }
+ ///
+ /// 创建对象节点描述。
+ ///
+ /// 对象属性集合。
+ /// 对象必填属性集合。
+ /// 对象属性数量约束。
+ /// 用于错误信息的 schema 文件路径提示。
+ /// 对象节点模型。
+ public static YamlConfigSchemaNode CreateObject(
+ IReadOnlyDictionary? properties,
+ IReadOnlyCollection? requiredProperties,
+ YamlConfigObjectConstraints? objectConstraints,
+ string schemaPathHint)
+ {
+ return new YamlConfigSchemaNode(
+ YamlConfigSchemaPropertyType.Object,
+ new NodeChildren(properties, requiredProperties, itemNode: null),
+ new NodeValidation(
+ referenceTableName: null,
+ allowedValues: null,
+ constraints: null,
+ arrayConstraints: null,
+ objectConstraints,
+ constantValue: null),
+ schemaPathHint);
+ }
+
+ ///
+ /// 创建数组节点描述。
+ ///
+ /// 数组元素节点。
+ /// 数组元素数量约束。
+ /// 用于错误信息的 schema 文件路径提示。
+ /// 数组节点模型。
+ public static YamlConfigSchemaNode CreateArray(
+ YamlConfigSchemaNode itemNode,
+ YamlConfigArrayConstraints? arrayConstraints,
+ string schemaPathHint)
+ {
+ return new YamlConfigSchemaNode(
+ YamlConfigSchemaPropertyType.Array,
+ new NodeChildren(properties: null, requiredProperties: null, itemNode),
+ new NodeValidation(
+ referenceTableName: null,
+ allowedValues: null,
+ constraints: null,
+ arrayConstraints,
+ objectConstraints: null,
+ constantValue: null),
+ schemaPathHint);
+ }
+
+ ///
+ /// 创建标量节点描述。
+ ///
+ /// 标量节点类型。
+ /// 目标引用表名称。
+ /// 标量允许值集合。
+ /// 标量范围与长度约束。
+ /// 用于错误信息的 schema 文件路径提示。
+ /// 标量节点模型。
+ public static YamlConfigSchemaNode CreateScalar(
+ YamlConfigSchemaPropertyType nodeType,
+ string? referenceTableName,
+ IReadOnlyCollection? allowedValues,
+ YamlConfigScalarConstraints? constraints,
+ string schemaPathHint)
+ {
+ return new YamlConfigSchemaNode(
+ nodeType,
+ NodeChildren.None,
+ new NodeValidation(
+ referenceTableName,
+ allowedValues,
+ constraints,
+ arrayConstraints: null,
+ objectConstraints: null,
+ constantValue: null),
+ schemaPathHint);
+ }
+
///
/// 基于当前节点复制一个只替换引用表名称的新节点。
/// 该方法用于把数组级别的 ref-table 语义挂接到元素节点上。
@@ -3312,8 +3389,6 @@ internal sealed class YamlConfigSchemaNode
private sealed class NodeChildren
{
- public static NodeChildren None { get; } = new(properties: null, requiredProperties: null, itemNode: null);
-
public NodeChildren(
IReadOnlyDictionary? properties,
IReadOnlyCollection? requiredProperties,
@@ -3324,6 +3399,8 @@ internal sealed class YamlConfigSchemaNode
ItemNode = itemNode;
}
+ public static NodeChildren None { get; } = new(properties: null, requiredProperties: null, itemNode: null);
+
public IReadOnlyDictionary? Properties { get; }
public IReadOnlyCollection? RequiredProperties { get; }
@@ -3333,14 +3410,6 @@ internal sealed class YamlConfigSchemaNode
private sealed class NodeValidation
{
- public static NodeValidation None { get; } = new(
- referenceTableName: null,
- allowedValues: null,
- constraints: null,
- arrayConstraints: null,
- objectConstraints: null,
- constantValue: null);
-
public NodeValidation(
string? referenceTableName,
IReadOnlyCollection? allowedValues,
@@ -3357,6 +3426,14 @@ internal sealed class YamlConfigSchemaNode
ConstantValue = constantValue;
}
+ public static NodeValidation None { get; } = new(
+ referenceTableName: null,
+ allowedValues: null,
+ constraints: null,
+ arrayConstraints: null,
+ objectConstraints: null,
+ constantValue: null);
+
public string? ReferenceTableName { get; }
public IReadOnlyCollection? AllowedValues { get; }
@@ -3534,8 +3611,8 @@ internal sealed class YamlConfigNumericConstraints
internal sealed class YamlConfigStringConstraints
{
///
-/// 初始化字符串约束模型。
-///
+ /// 初始化字符串约束模型。
+ ///
/// 最小长度约束。
/// 最大长度约束。
/// 正则模式约束原文。
diff --git a/GFramework.Game/Config/YamlConfigTextSerializer.cs b/GFramework.Game/Config/YamlConfigTextSerializer.cs
new file mode 100644
index 00000000..f8467220
--- /dev/null
+++ b/GFramework.Game/Config/YamlConfigTextSerializer.cs
@@ -0,0 +1,29 @@
+namespace GFramework.Game.Config;
+
+///
+/// 提供可复用的 YAML 文本序列化入口,供生成配置绑定与宿主写回流程共享。
+///
+public static class YamlConfigTextSerializer
+{
+ private static readonly ISerializer Serializer = new SerializerBuilder()
+ .WithNamingConvention(CamelCaseNamingConvention.Instance)
+ .DisableAliases()
+ .ConfigureDefaultValuesHandling(DefaultValuesHandling.Preserve)
+ .Build();
+
+ ///
+ /// 将配置对象序列化为 YAML 文本。
+ ///
+ /// 配置对象类型。
+ /// 要序列化的配置对象。
+ /// 带尾随换行的 YAML 文本。
+ public static string Serialize(TValue value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+
+ var yaml = Serializer.Serialize(value);
+ return yaml.EndsWith('\n')
+ ? yaml
+ : $"{yaml}{Environment.NewLine}";
+ }
+}
diff --git a/GFramework.Game/Config/YamlConfigTextValidator.cs b/GFramework.Game/Config/YamlConfigTextValidator.cs
new file mode 100644
index 00000000..84747633
--- /dev/null
+++ b/GFramework.Game/Config/YamlConfigTextValidator.cs
@@ -0,0 +1,44 @@
+namespace GFramework.Game.Config;
+
+///
+/// 提供面向宿主的 YAML 文本校验入口,使保存前校验可以复用运行时同一套 schema 规则。
+///
+public static class YamlConfigTextValidator
+{
+ ///
+ /// 使用指定 schema 文件同步校验 YAML 文本。
+ ///
+ /// 所属配置表名称。
+ /// Schema 文件绝对路径。
+ /// YAML 文件路径,仅用于诊断信息。
+ /// 待校验的 YAML 文本。
+ public static void Validate(
+ string tableName,
+ string schemaPath,
+ string yamlPath,
+ string yamlText)
+ {
+ var schema = YamlConfigSchemaValidator.Load(tableName, schemaPath);
+ YamlConfigSchemaValidator.Validate(tableName, schema, yamlPath, yamlText);
+ }
+
+ ///
+ /// 使用指定 schema 文件异步校验 YAML 文本。
+ ///
+ /// 所属配置表名称。
+ /// Schema 文件绝对路径。
+ /// YAML 文件路径,仅用于诊断信息。
+ /// 待校验的 YAML 文本。
+ /// 取消令牌。
+ public static async Task ValidateAsync(
+ string tableName,
+ string schemaPath,
+ string yamlPath,
+ string yamlText,
+ CancellationToken cancellationToken = default)
+ {
+ var schema = await YamlConfigSchemaValidator.LoadAsync(tableName, schemaPath, cancellationToken)
+ .ConfigureAwait(false);
+ YamlConfigSchemaValidator.Validate(tableName, schema, yamlPath, yamlText);
+ }
+}
diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
index 73ce4d4e..f325d87c 100644
--- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
+++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
@@ -446,6 +446,14 @@ public class SchemaConfigGeneratorTests
Assert.That(generatedSources["MonsterConfigBindings.g.cs"],
Does.Contain("public const string ConfigRelativePath = \"config/monster\";"));
Assert.That(generatedSources["MonsterConfigBindings.g.cs"], Does.Contain("Metadata.ConfigRelativePath,"));
+ Assert.That(generatedSources["MonsterConfigBindings.g.cs"],
+ Does.Contain("public static string SerializeToYaml(MonsterConfig config)"));
+ Assert.That(generatedSources["MonsterConfigBindings.g.cs"],
+ Does.Contain("public static string GetSchemaPath(string configRootPath)"));
+ Assert.That(generatedSources["MonsterConfigBindings.g.cs"],
+ Does.Contain("public static void ValidateYaml(string configRootPath, string yamlPath, string yamlText)"));
+ Assert.That(generatedSources["MonsterConfigBindings.g.cs"],
+ Does.Contain("public static global::System.Threading.Tasks.Task ValidateYamlAsync("));
Assert.That(generatedSources["GeneratedConfigCatalog.g.cs"],
Does.Contain("MonsterConfigBindings.Metadata.ConfigRelativePath"));
}
diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt
index 66ea9bf9..e657c7ff 100644
--- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt
+++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt
@@ -100,6 +100,89 @@ public static class MonsterConfigBindings
///
public const string SchemaRelativePath = Metadata.SchemaRelativePath;
+ ///
+ /// Serializes one generated config instance to YAML text using the shared runtime naming convention.
+ ///
+ /// The generated config instance to serialize.
+ /// YAML text that preserves the shared camelCase field naming convention.
+ public static string SerializeToYaml(MonsterConfig config)
+ {
+ return global::GFramework.Game.Config.YamlConfigTextSerializer.Serialize(config);
+ }
+
+ ///
+ /// Resolves the absolute config directory path by combining the caller-supplied config root with the generated relative directory.
+ ///
+ /// Absolute or workspace-local config root directory.
+ /// The absolute config directory path for the generated table.
+ public static string GetConfigDirectoryPath(string configRootPath)
+ {
+ return ResolveAbsolutePath(configRootPath, Metadata.ConfigRelativePath);
+ }
+
+ ///
+ /// Resolves the absolute schema file path by combining the caller-supplied config root with the generated relative schema path.
+ ///
+ /// Absolute or workspace-local config root directory.
+ /// The absolute schema file path for the generated table.
+ public static string GetSchemaPath(string configRootPath)
+ {
+ return ResolveAbsolutePath(configRootPath, Metadata.SchemaRelativePath);
+ }
+
+ ///
+ /// Validates YAML text against the generated schema file located under the supplied config root directory.
+ ///
+ /// Absolute or workspace-local config root directory.
+ /// Logical or absolute YAML path used for diagnostics.
+ /// YAML text to validate.
+ public static void ValidateYaml(string configRootPath, string yamlPath, string yamlText)
+ {
+ global::GFramework.Game.Config.YamlConfigTextValidator.Validate(
+ Metadata.TableName,
+ GetSchemaPath(configRootPath),
+ yamlPath,
+ yamlText);
+ }
+
+ ///
+ /// Asynchronously validates YAML text against the generated schema file located under the supplied config root directory.
+ ///
+ /// Absolute or workspace-local config root directory.
+ /// Logical or absolute YAML path used for diagnostics.
+ /// YAML text to validate.
+ /// Cancellation token.
+ public static global::System.Threading.Tasks.Task ValidateYamlAsync(
+ string configRootPath,
+ string yamlPath,
+ string yamlText,
+ global::System.Threading.CancellationToken cancellationToken = default)
+ {
+ return global::GFramework.Game.Config.YamlConfigTextValidator.ValidateAsync(
+ Metadata.TableName,
+ GetSchemaPath(configRootPath),
+ yamlPath,
+ yamlText,
+ cancellationToken);
+ }
+
+ private static string ResolveAbsolutePath(string configRootPath, string relativePath)
+ {
+ if (string.IsNullOrWhiteSpace(configRootPath))
+ {
+ throw new global::System.ArgumentException("Config root path cannot be null or whitespace.", nameof(configRootPath));
+ }
+
+ if (relativePath is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(relativePath));
+ }
+
+ var normalizedRelativePath = relativePath.Replace('/', global::System.IO.Path.DirectorySeparatorChar)
+ .Replace('\\', global::System.IO.Path.DirectorySeparatorChar);
+ return global::System.IO.Path.Combine(configRootPath, normalizedRelativePath);
+ }
+
///
/// Exposes generated metadata for schema properties that declare x-gframework-ref-table.
///
diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
index cec8282e..bb9e7f7b 100644
--- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
+++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
@@ -17,14 +17,19 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
private const string ConfigPathMetadataKey = "x-gframework-config-path";
private const string LookupIndexMetadataKey = "x-gframework-index";
private const string GeneratedNamespace = "GFramework.Game.Config.Generated";
+
private const string LookupIndexTopLevelScalarOnlyMessage =
"Only top-level required non-key scalar properties can declare a generated lookup index.";
+
private const string LookupIndexRequiresRequiredScalarMessage =
"Generated lookup indexes currently require a required scalar property so dictionary keys remain non-null.";
+
private const string LookupIndexPrimaryKeyMessage =
"The primary key already has Get/TryGet lookup semantics and should not declare a generated lookup index.";
+
private const string LookupIndexReferencePropertyMessage =
"Reference properties are excluded from generated lookup indexes because they already carry cross-table semantics.";
+
private const string SupportedStringFormatNames = "'date', 'date-time', 'email', 'uri', and 'uuid'";
///
@@ -434,7 +439,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
if (isIndexedLookup)
{
return ParsedPropertyResult.FromDiagnostic(
- CreateInvalidLookupIndexDiagnostic(filePath, displayPath, LookupIndexTopLevelScalarOnlyMessage));
+ CreateInvalidLookupIndexDiagnostic(filePath, displayPath,
+ LookupIndexTopLevelScalarOnlyMessage));
}
if (!string.IsNullOrWhiteSpace(refTableName))
@@ -752,7 +758,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}
var itemType = itemTypeElement.GetString() ?? string.Empty;
- if (!TryValidateStringFormatMetadata(filePath, $"{displayPath}[]", itemsElement, itemType, out var formatDiagnostic))
+ if (!TryValidateStringFormatMetadata(filePath, $"{displayPath}[]", itemsElement, itemType,
+ out var formatDiagnostic))
{
return ParsedPropertyResult.FromDiagnostic(formatDiagnostic!);
}
@@ -1128,6 +1135,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" /// ");
builder.AppendLine(" public const string SchemaRelativePath = Metadata.SchemaRelativePath;");
builder.AppendLine();
+ AppendYamlSerializationHelpers(builder, schema);
+ builder.AppendLine();
builder.AppendLine(" /// ");
builder.AppendLine(
" /// Exposes generated metadata for schema properties that declare x-gframework-ref-table.");
@@ -1430,7 +1439,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(
" /// Resolves the generated table metadata entries that belong to the specified logical config domain.");
builder.AppendLine(" /// ");
- builder.AppendLine(" /// Logical config domain derived from the schema base name.");
+ builder.AppendLine(
+ " /// Logical config domain derived from the schema base name.");
builder.AppendLine(
" /// A deterministic metadata snapshot for the requested config domain, or an empty list when no generated table belongs to that domain.");
builder.AppendLine(
@@ -1665,6 +1675,117 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return builder.ToString().TrimEnd();
}
+ ///
+ /// 为生成的绑定类输出 YAML 序列化与 schema 路径辅助。
+ ///
+ /// 输出缓冲区。
+ /// 生成器级 schema 模型。
+ private static void AppendYamlSerializationHelpers(
+ StringBuilder builder,
+ SchemaFileSpec schema)
+ {
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Serializes one generated config instance to YAML text using the shared runtime naming convention.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(" /// The generated config instance to serialize.");
+ builder.AppendLine(
+ " /// YAML text that preserves the shared camelCase field naming convention.");
+ builder.AppendLine($" public static string SerializeToYaml({schema.ClassName} config)");
+ builder.AppendLine(" {");
+ builder.AppendLine(" return global::GFramework.Game.Config.YamlConfigTextSerializer.Serialize(config);");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Resolves the absolute config directory path by combining the caller-supplied config root with the generated relative directory.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Absolute or workspace-local config root directory.");
+ builder.AppendLine(" /// The absolute config directory path for the generated table.");
+ builder.AppendLine(" public static string GetConfigDirectoryPath(string configRootPath)");
+ builder.AppendLine(" {");
+ builder.AppendLine(" return ResolveAbsolutePath(configRootPath, Metadata.ConfigRelativePath);");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Resolves the absolute schema file path by combining the caller-supplied config root with the generated relative schema path.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Absolute or workspace-local config root directory.");
+ builder.AppendLine(" /// The absolute schema file path for the generated table.");
+ builder.AppendLine(" public static string GetSchemaPath(string configRootPath)");
+ builder.AppendLine(" {");
+ builder.AppendLine(" return ResolveAbsolutePath(configRootPath, Metadata.SchemaRelativePath);");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Validates YAML text against the generated schema file located under the supplied config root directory.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Absolute or workspace-local config root directory.");
+ builder.AppendLine(
+ " /// Logical or absolute YAML path used for diagnostics.");
+ builder.AppendLine(" /// YAML text to validate.");
+ builder.AppendLine(
+ " public static void ValidateYaml(string configRootPath, string yamlPath, string yamlText)");
+ builder.AppendLine(" {");
+ builder.AppendLine(" global::GFramework.Game.Config.YamlConfigTextValidator.Validate(");
+ builder.AppendLine(" Metadata.TableName,");
+ builder.AppendLine(" GetSchemaPath(configRootPath),");
+ builder.AppendLine(" yamlPath,");
+ builder.AppendLine(" yamlText);");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Asynchronously validates YAML text against the generated schema file located under the supplied config root directory.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Absolute or workspace-local config root directory.");
+ builder.AppendLine(
+ " /// Logical or absolute YAML path used for diagnostics.");
+ builder.AppendLine(" /// YAML text to validate.");
+ builder.AppendLine(" /// Cancellation token.");
+ builder.AppendLine(
+ " public static global::System.Threading.Tasks.Task ValidateYamlAsync(");
+ builder.AppendLine(" string configRootPath,");
+ builder.AppendLine(" string yamlPath,");
+ builder.AppendLine(" string yamlText,");
+ builder.AppendLine(" global::System.Threading.CancellationToken cancellationToken = default)");
+ builder.AppendLine(" {");
+ builder.AppendLine(" return global::GFramework.Game.Config.YamlConfigTextValidator.ValidateAsync(");
+ builder.AppendLine(" Metadata.TableName,");
+ builder.AppendLine(" GetSchemaPath(configRootPath),");
+ builder.AppendLine(" yamlPath,");
+ builder.AppendLine(" yamlText,");
+ builder.AppendLine(" cancellationToken);");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(" private static string ResolveAbsolutePath(string configRootPath, string relativePath)");
+ builder.AppendLine(" {");
+ builder.AppendLine(" if (string.IsNullOrWhiteSpace(configRootPath))");
+ builder.AppendLine(" {");
+ builder.AppendLine(
+ " throw new global::System.ArgumentException(\"Config root path cannot be null or whitespace.\", nameof(configRootPath));");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(" if (relativePath is null)");
+ builder.AppendLine(" {");
+ builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(relativePath));");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(
+ " var normalizedRelativePath = relativePath.Replace('/', global::System.IO.Path.DirectorySeparatorChar)");
+ builder.AppendLine(
+ " .Replace('\\\\', global::System.IO.Path.DirectorySeparatorChar);");
+ builder.AppendLine(
+ " return global::System.IO.Path.Combine(configRootPath, normalizedRelativePath);");
+ builder.AppendLine(" }");
+ }
+
///
/// 收集 schema 中声明的跨表引用元数据,并为生成代码分配稳定成员名。
///
@@ -1778,8 +1899,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
" /// Materializes a read-only exact-match lookup index from the current table snapshot.");
builder.AppendLine(" /// ");
builder.AppendLine(" /// Indexed property type.");
- builder.AppendLine(" /// Selects the indexed property from one config entry.");
- builder.AppendLine(" /// A read-only dictionary whose values preserve snapshot iteration order.");
+ builder.AppendLine(
+ " /// Selects the indexed property from one config entry.");
+ builder.AppendLine(
+ " /// A read-only dictionary whose values preserve snapshot iteration order.");
builder.AppendLine(" /// ");
builder.AppendLine(
" /// The generated index skips runtime null keys even though is constrained to notnull. Malformed YAML payloads can still deserialize missing indexed values to , and throwing from this lazy path would permanently poison the cached index for the current table wrapper instance.");
@@ -1789,8 +1912,9 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine($" global::System.Func<{schema.ClassName}, TProperty> keySelector)");
builder.AppendLine(" where TProperty : notnull");
builder.AppendLine(" {");
- builder.AppendLine(" var buckets = new global::System.Collections.Generic.Dictionary>();");
+ builder.AppendLine(
+ " var buckets = new global::System.Collections.Generic.Dictionary>();");
builder.AppendLine();
builder.AppendLine(
" // Capture the current table snapshot once so indexed lookups stay deterministic for this wrapper instance.");
@@ -1808,7 +1932,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine();
builder.AppendLine(" if (!buckets.TryGetValue(key, out var matches))");
builder.AppendLine(" {");
- builder.AppendLine($" matches = new global::System.Collections.Generic.List<{schema.ClassName}>();");
+ builder.AppendLine(
+ $" matches = new global::System.Collections.Generic.List<{schema.ClassName}>();");
builder.AppendLine(" buckets.Add(key, matches);");
builder.AppendLine(" }");
builder.AppendLine();
@@ -2830,7 +2955,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return schemaType switch
{
- "integer" when constElement.ValueKind == JsonValueKind.Number && constElement.TryGetInt64(out var intValue) =>
+ "integer" when constElement.ValueKind == JsonValueKind.Number &&
+ constElement.TryGetInt64(out var intValue) =>
intValue.ToString(CultureInfo.InvariantCulture),
"number" when constElement.ValueKind == JsonValueKind.Number =>
constElement.GetDouble().ToString(CultureInfo.InvariantCulture),
From 925b6ce2d2ec3b50a6769e98c65e63a7ae30836b Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Sun, 12 Apr 2026 14:09:53 +0800
Subject: [PATCH 2/7] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0YAML?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=BA=8F=E5=88=97=E5=8C=96=E6=94=AF=E6=8C=81?=
=?UTF-8?q?=E5=B9=B6=E5=AE=8C=E5=96=84=E6=B5=8B=E8=AF=95=E4=BE=9D=E8=B5=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 集成YamlDotNet库实现YAML配置文件的序列化功能
- 在配置消费者集成测试中添加抽象配置接口引用
- 在YAML配置验证测试中添加抽象配置接口引用
- 统一配置模块的依赖注入和接口抽象层次
---
.../Config/GeneratedConfigConsumerIntegrationTests.cs | 5 +++--
GFramework.Game.Tests/Config/YamlConfigTextValidatorTests.cs | 5 +++--
GFramework.Game/Config/YamlConfigTextSerializer.cs | 3 +++
3 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
index 9422aa75..4cdac7da 100644
--- a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
+++ b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
@@ -1,4 +1,5 @@
using System.IO;
+using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
@@ -11,8 +12,6 @@ namespace GFramework.Game.Tests.Config;
[TestFixture]
public class GeneratedConfigConsumerIntegrationTests
{
- private string _rootPath = null!;
-
///
/// 为每个端到端测试准备独立的配置根目录,避免编译期 schema 资产与运行时写入互相污染。
///
@@ -35,6 +34,8 @@ public class GeneratedConfigConsumerIntegrationTests
}
}
+ private string _rootPath = null!;
+
///
/// 验证生成器自动拾取消费者项目的 schema 后,
/// 可以用生成的聚合注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助。
diff --git a/GFramework.Game.Tests/Config/YamlConfigTextValidatorTests.cs b/GFramework.Game.Tests/Config/YamlConfigTextValidatorTests.cs
index 02403d75..8c6eeb36 100644
--- a/GFramework.Game.Tests/Config/YamlConfigTextValidatorTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigTextValidatorTests.cs
@@ -1,4 +1,5 @@
using System.IO;
+using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
namespace GFramework.Game.Tests.Config;
@@ -9,8 +10,6 @@ namespace GFramework.Game.Tests.Config;
[TestFixture]
public sealed class YamlConfigTextValidatorTests
{
- private string _rootPath = null!;
-
///
/// 为每个测试准备独立临时目录。
///
@@ -33,6 +32,8 @@ public sealed class YamlConfigTextValidatorTests
}
}
+ private string _rootPath = null!;
+
///
/// 验证合法 YAML 文本会通过公开校验入口。
///
diff --git a/GFramework.Game/Config/YamlConfigTextSerializer.cs b/GFramework.Game/Config/YamlConfigTextSerializer.cs
index f8467220..12a26e31 100644
--- a/GFramework.Game/Config/YamlConfigTextSerializer.cs
+++ b/GFramework.Game/Config/YamlConfigTextSerializer.cs
@@ -1,3 +1,6 @@
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
namespace GFramework.Game.Config;
///
From e40703c20257de55ecef2a2d74c65d56a09e7529 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Sun, 12 Apr 2026 14:28:31 +0800
Subject: [PATCH 3/7] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=20YAML?=
=?UTF-8?q?=20=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=20JSON=20Schema=20?=
=?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=99=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现了 YAML 配置与 JSON Schema 的运行时校验功能
- 支持嵌套对象、对象数组、标量数组的递归校验
- 集成了 enum 和引用约束的深度校验机制
- 实现了 multipleOf、uniqueItems 等扩展约束规则
- 添加了跨表引用收集和校验能力
- 提供了异步和同步两种加载校验接口
- 支持 minContains/maxContains 数组匹配计数规则
- 实现了 minProperties/maxProperties 对象属性数量校验
- 集成了日期时间、邮箱、URI 等字符串格式校验
- 提供了详细的错误诊断信息和定位功能
---
...GeneratedConfigConsumerIntegrationTests.cs | 10 +-
.../Config/YamlConfigSchemaValidatorTests.cs | 93 +++++++++++++++++++
.../Config/YamlConfigSchemaValidator.cs | 7 +-
.../Config/YamlConfigTextSerializer.cs | 4 +-
.../Config/YamlConfigTextValidator.cs | 7 ++
.../MonsterConfigBindings.g.txt | 10 ++
.../Config/SchemaConfigGenerator.cs | 19 ++++
7 files changed, 143 insertions(+), 7 deletions(-)
create mode 100644 GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs
diff --git a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
index 4cdac7da..dc7f6dc2 100644
--- a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
+++ b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
@@ -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!;
+
///
/// 为每个端到端测试准备独立的配置根目录,避免编译期 schema 资产与运行时写入互相污染。
///
@@ -34,8 +35,6 @@ public class GeneratedConfigConsumerIntegrationTests
}
}
- private string _rootPath = null!;
-
///
/// 验证生成器自动拾取消费者项目的 schema 后,
/// 可以用生成的聚合注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助。
@@ -281,12 +280,17 @@ public class GeneratedConfigConsumerIntegrationTests
var exception = Assert.Throws(() =>
MonsterConfigBindings.ValidateYaml(_rootPath, "monster/generated.yaml", invalidYaml));
+ var asyncException = Assert.ThrowsAsync(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));
});
}
diff --git a/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs b/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs
new file mode 100644
index 00000000..11b26894
--- /dev/null
+++ b/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs
@@ -0,0 +1,93 @@
+using System.IO;
+using GFramework.Game.Config;
+
+namespace GFramework.Game.Tests.Config;
+
+///
+/// 验证内部 schema 解析器会输出稳定且可预期的运行时依赖元数据。
+///
+[TestFixture]
+public sealed class YamlConfigSchemaValidatorTests
+{
+ private string _rootPath = null!;
+
+ ///
+ /// 为每个测试准备独立临时目录。
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ _rootPath = Path.Combine(Path.GetTempPath(), "GFramework.SchemaValidatorTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_rootPath);
+ }
+
+ ///
+ /// 清理测试临时目录。
+ ///
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(_rootPath))
+ {
+ Directory.Delete(_rootPath, true);
+ }
+ }
+
+ ///
+ /// 验证 schema 中声明的跨表引用名称会以序数排序形式输出,
+ /// 避免热重载依赖推导与测试快照受哈希集合枚举顺序影响。
+ ///
+ [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" }));
+ }
+
+ ///
+ /// 在临时目录中创建 schema 文件。
+ ///
+ /// 相对根目录的路径。
+ /// 文件内容。
+ /// 写入后的绝对路径。
+ 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;
+ }
+}
diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
index 4c689264..b525cf79 100644
--- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs
+++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
@@ -267,8 +267,13 @@ internal static class YamlConfigSchemaValidator
var referencedTableNames = new HashSet(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)
{
diff --git a/GFramework.Game/Config/YamlConfigTextSerializer.cs b/GFramework.Game/Config/YamlConfigTextSerializer.cs
index 12a26e31..726a2e39 100644
--- a/GFramework.Game/Config/YamlConfigTextSerializer.cs
+++ b/GFramework.Game/Config/YamlConfigTextSerializer.cs
@@ -1,6 +1,3 @@
-using YamlDotNet.Serialization;
-using YamlDotNet.Serialization.NamingConventions;
-
namespace GFramework.Game.Config;
///
@@ -20,6 +17,7 @@ public static class YamlConfigTextSerializer
/// 配置对象类型。
/// 要序列化的配置对象。
/// 带尾随换行的 YAML 文本。
+ /// 当 为 时抛出。
public static string Serialize(TValue value)
{
ArgumentNullException.ThrowIfNull(value);
diff --git a/GFramework.Game/Config/YamlConfigTextValidator.cs b/GFramework.Game/Config/YamlConfigTextValidator.cs
index 84747633..e8e79a0b 100644
--- a/GFramework.Game/Config/YamlConfigTextValidator.cs
+++ b/GFramework.Game/Config/YamlConfigTextValidator.cs
@@ -12,6 +12,9 @@ public static class YamlConfigTextValidator
/// Schema 文件绝对路径。
/// YAML 文件路径,仅用于诊断信息。
/// 待校验的 YAML 文本。
+ /// 当 或 为空白时抛出。
+ /// 当 或 为 时抛出。
+ /// 当 schema 文件不可用,或 YAML 内容与 schema 不匹配时抛出。
public static void Validate(
string tableName,
string schemaPath,
@@ -30,6 +33,10 @@ public static class YamlConfigTextValidator
/// YAML 文件路径,仅用于诊断信息。
/// 待校验的 YAML 文本。
/// 取消令牌。
+ /// 表示异步校验操作的任务。
+ /// 当 或 为空白时抛出。
+ /// 当 或 为 时抛出。
+ /// 当 schema 文件不可用,或 YAML 内容与 schema 不匹配时抛出。
public static async Task ValidateAsync(
string tableName,
string schemaPath,
diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt
index e657c7ff..08f2a506 100644
--- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt
+++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt
@@ -105,6 +105,7 @@ public static class MonsterConfigBindings
///
/// The generated config instance to serialize.
/// YAML text that preserves the shared camelCase field naming convention.
+ /// Thrown when is .
public static string SerializeToYaml(MonsterConfig config)
{
return global::GFramework.Game.Config.YamlConfigTextSerializer.Serialize(config);
@@ -115,6 +116,7 @@ public static class MonsterConfigBindings
///
/// Absolute or workspace-local config root directory.
/// The absolute config directory path for the generated table.
+ /// Thrown when is null, empty, or whitespace.
public static string GetConfigDirectoryPath(string configRootPath)
{
return ResolveAbsolutePath(configRootPath, Metadata.ConfigRelativePath);
@@ -125,6 +127,7 @@ public static class MonsterConfigBindings
///
/// Absolute or workspace-local config root directory.
/// The absolute schema file path for the generated table.
+ /// Thrown when is null, empty, or whitespace.
public static string GetSchemaPath(string configRootPath)
{
return ResolveAbsolutePath(configRootPath, Metadata.SchemaRelativePath);
@@ -136,6 +139,9 @@ public static class MonsterConfigBindings
/// Absolute or workspace-local config root directory.
/// Logical or absolute YAML path used for diagnostics.
/// YAML text to validate.
+ /// Thrown when is null, empty, or whitespace.
+ /// Thrown when or is .
+ /// Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation.
public static void ValidateYaml(string configRootPath, string yamlPath, string yamlText)
{
global::GFramework.Game.Config.YamlConfigTextValidator.Validate(
@@ -152,6 +158,10 @@ public static class MonsterConfigBindings
/// Logical or absolute YAML path used for diagnostics.
/// YAML text to validate.
/// Cancellation token.
+ /// A task that represents the asynchronous validation operation.
+ /// Thrown when is null, empty, or whitespace.
+ /// Thrown when or is .
+ /// Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation.
public static global::System.Threading.Tasks.Task ValidateYamlAsync(
string configRootPath,
string yamlPath,
diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
index bb9e7f7b..ac1c7d70 100644
--- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
+++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
@@ -1691,6 +1691,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" /// The generated config instance to serialize.");
builder.AppendLine(
" /// YAML text that preserves the shared camelCase field naming convention.");
+ builder.AppendLine(
+ " /// Thrown when is .");
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(
" /// Absolute or workspace-local config root directory.");
builder.AppendLine(" /// The absolute config directory path for the generated table.");
+ builder.AppendLine(
+ " /// Thrown when is null, empty, or whitespace.");
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(
" /// Absolute or workspace-local config root directory.");
builder.AppendLine(" /// The absolute schema file path for the generated table.");
+ builder.AppendLine(
+ " /// Thrown when is null, empty, or whitespace.");
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(
" /// Logical or absolute YAML path used for diagnostics.");
builder.AppendLine(" /// YAML text to validate.");
+ builder.AppendLine(
+ " /// Thrown when is null, empty, or whitespace.");
+ builder.AppendLine(
+ " /// Thrown when or is .");
+ builder.AppendLine(
+ " /// Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation.");
builder.AppendLine(
" public static void ValidateYaml(string configRootPath, string yamlPath, string yamlText)");
builder.AppendLine(" {");
@@ -1749,6 +1761,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
" /// Logical or absolute YAML path used for diagnostics.");
builder.AppendLine(" /// YAML text to validate.");
builder.AppendLine(" /// Cancellation token.");
+ builder.AppendLine(" /// A task that represents the asynchronous validation operation.");
+ builder.AppendLine(
+ " /// Thrown when is null, empty, or whitespace.");
+ builder.AppendLine(
+ " /// Thrown when or is .");
+ builder.AppendLine(
+ " /// Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation.");
builder.AppendLine(
" public static global::System.Threading.Tasks.Task ValidateYamlAsync(");
builder.AppendLine(" string configRootPath,");
From 7473adb78907e314d022812c5824a3da6c4a13bb Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Sun, 12 Apr 2026 14:51:36 +0800
Subject: [PATCH 4/7] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0YAML?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=BA=8F=E5=88=97=E5=8C=96=E5=99=A8=E5=B9=B6?=
=?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增YamlConfigTextSerializer类提供统一的YAML序列化功能
- 集成测试中添加配置抽象接口引用
- 序列化器使用驼峰命名约定和默认值保留策略
- 自动确保YAML输出以换行符结尾
- 配置对象序列化时验证空值并抛出异常
---
.../Config/GeneratedConfigConsumerIntegrationTests.cs | 5 +++--
GFramework.Game/Config/YamlConfigTextSerializer.cs | 3 +++
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
index dc7f6dc2..248f7f9f 100644
--- a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
+++ b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
@@ -1,4 +1,5 @@
using System.IO;
+using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
@@ -11,8 +12,6 @@ namespace GFramework.Game.Tests.Config;
[TestFixture]
public class GeneratedConfigConsumerIntegrationTests
{
- private string _rootPath = null!;
-
///
/// 为每个端到端测试准备独立的配置根目录,避免编译期 schema 资产与运行时写入互相污染。
///
@@ -35,6 +34,8 @@ public class GeneratedConfigConsumerIntegrationTests
}
}
+ private string _rootPath = null!;
+
///
/// 验证生成器自动拾取消费者项目的 schema 后,
/// 可以用生成的聚合注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助。
diff --git a/GFramework.Game/Config/YamlConfigTextSerializer.cs b/GFramework.Game/Config/YamlConfigTextSerializer.cs
index 726a2e39..b270dd7b 100644
--- a/GFramework.Game/Config/YamlConfigTextSerializer.cs
+++ b/GFramework.Game/Config/YamlConfigTextSerializer.cs
@@ -1,3 +1,6 @@
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
namespace GFramework.Game.Config;
///
From 12e54ce6378f03807665450fe82538b6f3b082f4 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Sun, 12 Apr 2026 15:41:45 +0800
Subject: [PATCH 5/7] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0YAML?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=BA=8F=E5=88=97=E5=8C=96=E5=92=8C=E6=A0=A1?=
=?UTF-8?q?=E9=AA=8C=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现YamlConfigTextSerializer提供YAML文本序列化功能
- 实现YamlConfigTextValidator提供YAML文本校验功能
- 添加缓存机制优化schema文件加载性能
- 实现同步和异步校验接口支持
- 添加集成测试验证生成配置绑定功能
- 扩展SchemaConfigGenerator支持配置类型生成
- 实现GeneratedConfigConsumerIntegrationTests完整测试覆盖
---
...GeneratedConfigConsumerIntegrationTests.cs | 2 +-
.../Config/YamlConfigTextSerializerTests.cs | 60 ++++++++
.../Config/YamlConfigTextValidatorTests.cs | 54 +++++++
.../Config/YamlConfigTextSerializer.cs | 30 ++--
.../Config/YamlConfigTextValidator.cs | 134 +++++++++++++++++-
.../Config/SchemaConfigGeneratorTests.cs | 6 +
.../GeneratedConfigCatalog.g.txt | 25 ++++
.../MonsterConfigBindings.g.txt | 21 +--
.../Config/SchemaConfigGenerator.cs | 61 +++++---
9 files changed, 338 insertions(+), 55 deletions(-)
create mode 100644 GFramework.Game.Tests/Config/YamlConfigTextSerializerTests.cs
diff --git a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
index 248f7f9f..98bf01d8 100644
--- a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
+++ b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
@@ -263,7 +263,7 @@ public class GeneratedConfigConsumerIntegrationTests
Assert.That(yaml, Does.Contain("name: Bat"));
Assert.That(yaml, Does.Contain("hp: 12"));
Assert.That(yaml, Does.Contain("faction: cave"));
- Assert.That(yaml.EndsWith(Environment.NewLine, StringComparison.Ordinal), Is.True);
+ Assert.That(yaml.EndsWith("\n", StringComparison.Ordinal), Is.True);
});
Assert.DoesNotThrow(() =>
diff --git a/GFramework.Game.Tests/Config/YamlConfigTextSerializerTests.cs b/GFramework.Game.Tests/Config/YamlConfigTextSerializerTests.cs
new file mode 100644
index 00000000..79337d1f
--- /dev/null
+++ b/GFramework.Game.Tests/Config/YamlConfigTextSerializerTests.cs
@@ -0,0 +1,60 @@
+using GFramework.Game.Config;
+
+namespace GFramework.Game.Tests.Config;
+
+///
+/// 验证公开 YAML 文本序列化入口的换行与参数契约。
+///
+[TestFixture]
+public sealed class YamlConfigTextSerializerTests
+{
+ ///
+ /// 验证序列化结果会稳定地以 LF 作为尾随换行,
+ /// 避免不同宿主平台的行尾约定影响生成内容。
+ ///
+ [Test]
+ public void Serialize_Should_Use_Trailing_Lf_Newline()
+ {
+ var yaml = YamlConfigTextSerializer.Serialize(new MonsterYamlStub
+ {
+ Id = 1,
+ Name = "Slime"
+ });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(yaml, Does.Contain("id: 1"));
+ Assert.That(yaml, Does.Contain("name: Slime"));
+ Assert.That(yaml.EndsWith("\n", StringComparison.Ordinal), Is.True);
+ Assert.That(yaml.EndsWith("\r\n", StringComparison.Ordinal), Is.False);
+ });
+ }
+
+ ///
+ /// 验证空对象引用会继续通过参数异常暴露给调用方。
+ ///
+ [Test]
+ public void Serialize_Should_Throw_When_Value_Is_Null()
+ {
+ var exception = Assert.Throws(() =>
+ YamlConfigTextSerializer.Serialize(null!));
+
+ Assert.That(exception!.ParamName, Is.EqualTo("value"));
+ }
+
+ ///
+ /// 用于 YAML 序列化测试的最小配置对象。
+ ///
+ private sealed class MonsterYamlStub
+ {
+ ///
+ /// 获取或设置配置标识。
+ ///
+ public int Id { get; init; }
+
+ ///
+ /// 获取或设置配置名称。
+ ///
+ public string Name { get; init; } = string.Empty;
+ }
+}
diff --git a/GFramework.Game.Tests/Config/YamlConfigTextValidatorTests.cs b/GFramework.Game.Tests/Config/YamlConfigTextValidatorTests.cs
index 8c6eeb36..047b642c 100644
--- a/GFramework.Game.Tests/Config/YamlConfigTextValidatorTests.cs
+++ b/GFramework.Game.Tests/Config/YamlConfigTextValidatorTests.cs
@@ -143,6 +143,60 @@ public sealed class YamlConfigTextValidatorTests
});
}
+ ///
+ /// 验证公开校验入口会在 schema 文件发生变化后失效旧缓存,
+ /// 避免保存路径持续沿用过期的字段约束。
+ ///
+ [Test]
+ public void Validate_Should_Refresh_Cached_Schema_When_File_Timestamp_Changes()
+ {
+ var schemaPath = CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "required": ["id", "name", "hp"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "hp": { "type": "integer" }
+ }
+ }
+ """);
+ var yaml = """
+ id: 1
+ name: Slime
+ hp: 10
+ """;
+
+ Assert.DoesNotThrow(() =>
+ YamlConfigTextValidator.Validate("monster", schemaPath, "monster/generated.yaml", yaml));
+
+ File.WriteAllText(
+ schemaPath,
+ """
+ {
+ "type": "object",
+ "required": ["id", "name"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" }
+ }
+ }
+ """.Replace("\n", Environment.NewLine, StringComparison.Ordinal));
+ File.SetLastWriteTimeUtc(schemaPath, new DateTime(2040, 1, 1, 0, 0, 1, DateTimeKind.Utc));
+
+ var exception = Assert.Throws(() =>
+ YamlConfigTextValidator.Validate("monster", schemaPath, "monster/generated.yaml", yaml));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception, Is.Not.Null);
+ Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.UnknownProperty));
+ Assert.That(exception.Diagnostic.SchemaPath, Is.EqualTo(schemaPath));
+ });
+ }
+
///
/// 在临时目录中创建 schema 文件。
///
diff --git a/GFramework.Game/Config/YamlConfigTextSerializer.cs b/GFramework.Game/Config/YamlConfigTextSerializer.cs
index b270dd7b..4a79cc12 100644
--- a/GFramework.Game/Config/YamlConfigTextSerializer.cs
+++ b/GFramework.Game/Config/YamlConfigTextSerializer.cs
@@ -8,26 +8,36 @@ namespace GFramework.Game.Config;
///
public static class YamlConfigTextSerializer
{
- private static readonly ISerializer Serializer = new SerializerBuilder()
- .WithNamingConvention(CamelCaseNamingConvention.Instance)
- .DisableAliases()
- .ConfigureDefaultValuesHandling(DefaultValuesHandling.Preserve)
- .Build();
-
///
- /// 将配置对象序列化为 YAML 文本。
+ /// 将配置对象序列化为 YAML 文本,并统一以 LF 作为尾随换行。
+ /// 该约定与底层 YamlDotNet 输出保持一致,避免不同操作系统的宿主行尾约定影响生成结果。
///
/// 配置对象类型。
/// 要序列化的配置对象。
- /// 带尾随换行的 YAML 文本。
+ /// 带尾随 LF 换行的 YAML 文本。
/// 当 为 时抛出。
public static string Serialize(TValue value)
{
ArgumentNullException.ThrowIfNull(value);
- var yaml = Serializer.Serialize(value);
+ // Build one serializer per call so the helper does not rely on undocumented
+ // cross-thread safety guarantees from YamlDotNet's serializer implementation.
+ var yaml = CreateSerializer().Serialize(value);
return yaml.EndsWith('\n')
? yaml
- : $"{yaml}{Environment.NewLine}";
+ : $"{yaml}\n";
+ }
+
+ ///
+ /// 创建与运行时配置绑定共享的 YAML 序列化器。
+ ///
+ /// 复用统一命名与默认值策略的序列化器。
+ private static ISerializer CreateSerializer()
+ {
+ return new SerializerBuilder()
+ .WithNamingConvention(CamelCaseNamingConvention.Instance)
+ .DisableAliases()
+ .ConfigureDefaultValuesHandling(DefaultValuesHandling.Preserve)
+ .Build();
}
}
diff --git a/GFramework.Game/Config/YamlConfigTextValidator.cs b/GFramework.Game/Config/YamlConfigTextValidator.cs
index e8e79a0b..1990354d 100644
--- a/GFramework.Game/Config/YamlConfigTextValidator.cs
+++ b/GFramework.Game/Config/YamlConfigTextValidator.cs
@@ -5,6 +5,10 @@ namespace GFramework.Game.Config;
///
public static class YamlConfigTextValidator
{
+ // Cache parsed schemas by table/path plus last write time so save-path validation can
+ // avoid repeated disk IO and JSON parsing while still observing schema edits.
+ private static readonly ConcurrentDictionary SchemaCache = new();
+
///
/// 使用指定 schema 文件同步校验 YAML 文本。
///
@@ -21,7 +25,7 @@ public static class YamlConfigTextValidator
string yamlPath,
string yamlText)
{
- var schema = YamlConfigSchemaValidator.Load(tableName, schemaPath);
+ var schema = GetOrLoadSchema(tableName, schemaPath);
YamlConfigSchemaValidator.Validate(tableName, schema, yamlPath, yamlText);
}
@@ -44,8 +48,134 @@ public static class YamlConfigTextValidator
string yamlText,
CancellationToken cancellationToken = default)
{
- var schema = await YamlConfigSchemaValidator.LoadAsync(tableName, schemaPath, cancellationToken)
+ var schema = await GetOrLoadSchemaAsync(tableName, schemaPath, cancellationToken)
.ConfigureAwait(false);
YamlConfigSchemaValidator.Validate(tableName, schema, yamlPath, yamlText);
}
+
+ ///
+ /// 获取可复用的 schema 模型,必要时从磁盘重新加载。
+ ///
+ /// 所属配置表名称。
+ /// Schema 文件绝对路径。
+ /// 与当前 schema 文件内容匹配的已解析模型。
+ /// 当 或 为空白时抛出。
+ /// 当 schema 文件不可用或内容非法时抛出。
+ private static YamlConfigSchema GetOrLoadSchema(
+ string tableName,
+ string schemaPath)
+ {
+ var cacheKey = CreateCacheKey(tableName, schemaPath);
+ if (TryGetCachedSchema(cacheKey, out var cachedSchema))
+ {
+ return cachedSchema;
+ }
+
+ var schema = YamlConfigSchemaValidator.Load(tableName, schemaPath);
+ CacheSchema(cacheKey, schema);
+ return schema;
+ }
+
+ ///
+ /// 异步获取可复用的 schema 模型,必要时从磁盘重新加载。
+ ///
+ /// 所属配置表名称。
+ /// Schema 文件绝对路径。
+ /// 取消令牌。
+ /// 与当前 schema 文件内容匹配的已解析模型。
+ /// 当 或 为空白时抛出。
+ /// 当 schema 文件不可用或内容非法时抛出。
+ private static async Task GetOrLoadSchemaAsync(
+ string tableName,
+ string schemaPath,
+ CancellationToken cancellationToken)
+ {
+ var cacheKey = CreateCacheKey(tableName, schemaPath);
+ if (TryGetCachedSchema(cacheKey, out var cachedSchema))
+ {
+ return cachedSchema;
+ }
+
+ var schema = await YamlConfigSchemaValidator.LoadAsync(tableName, schemaPath, cancellationToken)
+ .ConfigureAwait(false);
+ CacheSchema(cacheKey, schema);
+ return schema;
+ }
+
+ ///
+ /// 创建 schema 缓存键,并提前执行与公开入口一致的参数契约检查。
+ ///
+ /// 所属配置表名称。
+ /// Schema 文件绝对路径。
+ /// 用于缓存查找的稳定键。
+ /// 当 或 为空白时抛出。
+ private static SchemaCacheKey CreateCacheKey(
+ string tableName,
+ string schemaPath)
+ {
+ if (string.IsNullOrWhiteSpace(tableName))
+ {
+ throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName));
+ }
+
+ if (string.IsNullOrWhiteSpace(schemaPath))
+ {
+ throw new ArgumentException("Schema path cannot be null or whitespace.", nameof(schemaPath));
+ }
+
+ return new SchemaCacheKey(tableName, schemaPath);
+ }
+
+ ///
+ /// 尝试命中当前 schema 文件版本对应的缓存项。
+ ///
+ /// 缓存键。
+ /// 命中的 schema;未命中时为 。
+ /// 当缓存项仍与当前文件时间戳一致时返回 。
+ private static bool TryGetCachedSchema(
+ SchemaCacheKey cacheKey,
+ out YamlConfigSchema schema)
+ {
+ var lastWriteTimeUtc = File.GetLastWriteTimeUtc(cacheKey.SchemaPath);
+ if (SchemaCache.TryGetValue(cacheKey, out var cacheEntry) &&
+ cacheEntry.LastWriteTimeUtc == lastWriteTimeUtc)
+ {
+ schema = cacheEntry.Schema;
+ return true;
+ }
+
+ schema = null!;
+ return false;
+ }
+
+ ///
+ /// 使用最新的文件时间戳刷新 schema 缓存。
+ ///
+ /// 缓存键。
+ /// 最新加载的 schema。
+ private static void CacheSchema(
+ SchemaCacheKey cacheKey,
+ YamlConfigSchema schema)
+ {
+ var lastWriteTimeUtc = File.GetLastWriteTimeUtc(cacheKey.SchemaPath);
+ SchemaCache[cacheKey] = new SchemaCacheEntry(lastWriteTimeUtc, schema);
+ }
+
+ ///
+ /// 表示一个 schema 缓存键。
+ ///
+ /// 所属配置表名称。
+ /// Schema 文件绝对路径。
+ private readonly record struct SchemaCacheKey(
+ string TableName,
+ string SchemaPath);
+
+ ///
+ /// 表示一个带文件时间戳的 schema 缓存条目。
+ ///
+ /// 加载时观察到的 schema 文件修改时间。
+ /// 已解析的 schema 模型。
+ private readonly record struct SchemaCacheEntry(
+ DateTime LastWriteTimeUtc,
+ YamlConfigSchema Schema);
}
diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
index f325d87c..3e74a9a1 100644
--- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
+++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs
@@ -454,8 +454,14 @@ public class SchemaConfigGeneratorTests
Does.Contain("public static void ValidateYaml(string configRootPath, string yamlPath, string yamlText)"));
Assert.That(generatedSources["MonsterConfigBindings.g.cs"],
Does.Contain("public static global::System.Threading.Tasks.Task ValidateYamlAsync("));
+ Assert.That(generatedSources["MonsterConfigBindings.g.cs"],
+ Does.Contain("GeneratedConfigCatalog.ResolveAbsolutePath(configRootPath, Metadata.ConfigRelativePath)"));
+ Assert.That(generatedSources["MonsterConfigBindings.g.cs"],
+ Does.Not.Contain("private static string ResolveAbsolutePath"));
Assert.That(generatedSources["GeneratedConfigCatalog.g.cs"],
Does.Contain("MonsterConfigBindings.Metadata.ConfigRelativePath"));
+ Assert.That(generatedSources["GeneratedConfigCatalog.g.cs"],
+ Does.Contain("internal static string ResolveAbsolutePath(string configRootPath, string relativePath)"));
}
///
diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/GeneratedConfigCatalog.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/GeneratedConfigCatalog.g.txt
index 97983970..4fb93c90 100644
--- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/GeneratedConfigCatalog.g.txt
+++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/GeneratedConfigCatalog.g.txt
@@ -66,6 +66,31 @@ public static class GeneratedConfigCatalog
MonsterConfigBindings.Metadata.SchemaRelativePath),
});
+ ///
+ /// Resolves one generated relative config path against the caller-supplied config root directory.
+ ///
+ /// Absolute or workspace-local config root directory.
+ /// Generated relative config or schema path.
+ /// The combined absolute path.
+ /// When is null, empty, or whitespace.
+ /// When is null.
+ internal static string ResolveAbsolutePath(string configRootPath, string relativePath)
+ {
+ if (string.IsNullOrWhiteSpace(configRootPath))
+ {
+ throw new global::System.ArgumentException("Config root path cannot be null or whitespace.", nameof(configRootPath));
+ }
+
+ if (relativePath is null)
+ {
+ throw new global::System.ArgumentNullException(nameof(relativePath));
+ }
+
+ var normalizedRelativePath = relativePath.Replace('/', global::System.IO.Path.DirectorySeparatorChar)
+ .Replace('\\', global::System.IO.Path.DirectorySeparatorChar);
+ return global::System.IO.Path.Combine(configRootPath, normalizedRelativePath);
+ }
+
///
/// Tries to resolve generated table metadata by runtime registration name.
///
diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt
index 08f2a506..467a07c1 100644
--- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt
+++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt
@@ -119,7 +119,7 @@ public static class MonsterConfigBindings
/// Thrown when is null, empty, or whitespace.
public static string GetConfigDirectoryPath(string configRootPath)
{
- return ResolveAbsolutePath(configRootPath, Metadata.ConfigRelativePath);
+ return GeneratedConfigCatalog.ResolveAbsolutePath(configRootPath, Metadata.ConfigRelativePath);
}
///
@@ -130,7 +130,7 @@ public static class MonsterConfigBindings
/// Thrown when is null, empty, or whitespace.
public static string GetSchemaPath(string configRootPath)
{
- return ResolveAbsolutePath(configRootPath, Metadata.SchemaRelativePath);
+ return GeneratedConfigCatalog.ResolveAbsolutePath(configRootPath, Metadata.SchemaRelativePath);
}
///
@@ -176,23 +176,6 @@ public static class MonsterConfigBindings
cancellationToken);
}
- private static string ResolveAbsolutePath(string configRootPath, string relativePath)
- {
- if (string.IsNullOrWhiteSpace(configRootPath))
- {
- throw new global::System.ArgumentException("Config root path cannot be null or whitespace.", nameof(configRootPath));
- }
-
- if (relativePath is null)
- {
- throw new global::System.ArgumentNullException(nameof(relativePath));
- }
-
- var normalizedRelativePath = relativePath.Replace('/', global::System.IO.Path.DirectorySeparatorChar)
- .Replace('\\', global::System.IO.Path.DirectorySeparatorChar);
- return global::System.IO.Path.Combine(configRootPath, normalizedRelativePath);
- }
-
///
/// Exposes generated metadata for schema properties that declare x-gframework-ref-table.
///
diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
index ac1c7d70..857b9092 100644
--- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
+++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
@@ -1403,6 +1403,40 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" });");
builder.AppendLine();
builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Resolves one generated relative config path against the caller-supplied config root directory.");
+ builder.AppendLine(" /// ");
+ builder.AppendLine(
+ " /// Absolute or workspace-local config root directory.");
+ builder.AppendLine(" /// Generated relative config or schema path.");
+ builder.AppendLine(" /// The combined absolute path.");
+ builder.AppendLine(
+ " /// When is null, empty, or whitespace.");
+ builder.AppendLine(
+ " /// When is null.");
+ builder.AppendLine(
+ " internal static string ResolveAbsolutePath(string configRootPath, string relativePath)");
+ builder.AppendLine(" {");
+ builder.AppendLine(" if (string.IsNullOrWhiteSpace(configRootPath))");
+ builder.AppendLine(" {");
+ builder.AppendLine(
+ " throw new global::System.ArgumentException(\"Config root path cannot be null or whitespace.\", nameof(configRootPath));");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(" if (relativePath is null)");
+ builder.AppendLine(" {");
+ builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(relativePath));");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(
+ " var normalizedRelativePath = relativePath.Replace('/', global::System.IO.Path.DirectorySeparatorChar)");
+ builder.AppendLine(
+ " .Replace('\\\\', global::System.IO.Path.DirectorySeparatorChar);");
+ builder.AppendLine(
+ " return global::System.IO.Path.Combine(configRootPath, normalizedRelativePath);");
+ builder.AppendLine(" }");
+ builder.AppendLine();
+ builder.AppendLine(" /// ");
builder.AppendLine(" /// Tries to resolve generated table metadata by runtime registration name.");
builder.AppendLine(" /// ");
builder.AppendLine(" /// Runtime registration name.");
@@ -1709,7 +1743,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
" /// Thrown when is null, empty, or whitespace.");
builder.AppendLine(" public static string GetConfigDirectoryPath(string configRootPath)");
builder.AppendLine(" {");
- builder.AppendLine(" return ResolveAbsolutePath(configRootPath, Metadata.ConfigRelativePath);");
+ builder.AppendLine(
+ " return GeneratedConfigCatalog.ResolveAbsolutePath(configRootPath, Metadata.ConfigRelativePath);");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// ");
@@ -1723,7 +1758,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
" /// Thrown when is null, empty, or whitespace.");
builder.AppendLine(" public static string GetSchemaPath(string configRootPath)");
builder.AppendLine(" {");
- builder.AppendLine(" return ResolveAbsolutePath(configRootPath, Metadata.SchemaRelativePath);");
+ builder.AppendLine(
+ " return GeneratedConfigCatalog.ResolveAbsolutePath(configRootPath, Metadata.SchemaRelativePath);");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// ");
@@ -1782,27 +1818,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" yamlText,");
builder.AppendLine(" cancellationToken);");
builder.AppendLine(" }");
- builder.AppendLine();
- builder.AppendLine(" private static string ResolveAbsolutePath(string configRootPath, string relativePath)");
- builder.AppendLine(" {");
- builder.AppendLine(" if (string.IsNullOrWhiteSpace(configRootPath))");
- builder.AppendLine(" {");
- builder.AppendLine(
- " throw new global::System.ArgumentException(\"Config root path cannot be null or whitespace.\", nameof(configRootPath));");
- builder.AppendLine(" }");
- builder.AppendLine();
- builder.AppendLine(" if (relativePath is null)");
- builder.AppendLine(" {");
- builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(relativePath));");
- builder.AppendLine(" }");
- builder.AppendLine();
- builder.AppendLine(
- " var normalizedRelativePath = relativePath.Replace('/', global::System.IO.Path.DirectorySeparatorChar)");
- builder.AppendLine(
- " .Replace('\\\\', global::System.IO.Path.DirectorySeparatorChar);");
- builder.AppendLine(
- " return global::System.IO.Path.Combine(configRootPath, normalizedRelativePath);");
- builder.AppendLine(" }");
}
///
From 949904b57c3322fc598b80a681ea8b6e98c13721 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Sun, 12 Apr 2026 15:47:43 +0800
Subject: [PATCH 6/7] =?UTF-8?q?docs(Config):=20=E6=9B=B4=E6=96=B0YAML?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=96=87=E6=9C=AC=E9=AA=8C=E8=AF=81=E5=99=A8?=
=?UTF-8?q?=E6=96=87=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 为Validate方法添加详细的remarks文档说明同步加载schema的特性
- 为ValidateAsync方法添加cancellation token异常说明和异步加载schema的详细文档
- 补充异步验证方法的I/O密集场景适用性说明
---
GFramework.Game/Config/YamlConfigTextValidator.cs | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/GFramework.Game/Config/YamlConfigTextValidator.cs b/GFramework.Game/Config/YamlConfigTextValidator.cs
index 1990354d..40b9dc72 100644
--- a/GFramework.Game/Config/YamlConfigTextValidator.cs
+++ b/GFramework.Game/Config/YamlConfigTextValidator.cs
@@ -19,6 +19,9 @@ public static class YamlConfigTextValidator
/// 当 或 为空白时抛出。
/// 当 或 为 时抛出。
/// 当 schema 文件不可用,或 YAML 内容与 schema 不匹配时抛出。
+ ///
+ /// 同步加载 schema 并立即校验,适合非异步上下文;内部委托 执行校验逻辑。
+ ///
public static void Validate(
string tableName,
string schemaPath,
@@ -41,6 +44,11 @@ public static class YamlConfigTextValidator
/// 当 或 为空白时抛出。
/// 当 或 为 时抛出。
/// 当 schema 文件不可用,或 YAML 内容与 schema 不匹配时抛出。
+ /// 当 已被触发时抛出。
+ ///
+ /// 异步加载 schema(调用 )后同步执行校验,适合 I/O 密集场景;
+ /// 校验本身不涉及异步操作。
+ ///
public static async Task ValidateAsync(
string tableName,
string schemaPath,
From 774b69f5608d6a007278151c129949502582d009 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Sun, 12 Apr 2026 16:09:07 +0800
Subject: [PATCH 7/7] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0YAML?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=96=87=E6=9C=AC=E6=A0=A1=E9=AA=8C=E5=99=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现同步和异步YAML文本校验功能
- 添加基于schema文件的配置校验支持
- 实现schema缓存机制避免重复磁盘IO
- 提供配置表名称和文件路径参数验证
- 集成取消令牌支持异步操作取消
- 添加详细的异常处理和诊断信息
---
GFramework.Game/Config/YamlConfigTextValidator.cs | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/GFramework.Game/Config/YamlConfigTextValidator.cs b/GFramework.Game/Config/YamlConfigTextValidator.cs
index 40b9dc72..b558e520 100644
--- a/GFramework.Game/Config/YamlConfigTextValidator.cs
+++ b/GFramework.Game/Config/YamlConfigTextValidator.cs
@@ -79,8 +79,9 @@ public static class YamlConfigTextValidator
return cachedSchema;
}
+ var lastWriteTimeUtc = File.GetLastWriteTimeUtc(schemaPath);
var schema = YamlConfigSchemaValidator.Load(tableName, schemaPath);
- CacheSchema(cacheKey, schema);
+ CacheSchema(cacheKey, lastWriteTimeUtc, schema);
return schema;
}
@@ -104,9 +105,10 @@ public static class YamlConfigTextValidator
return cachedSchema;
}
+ var lastWriteTimeUtc = File.GetLastWriteTimeUtc(schemaPath);
var schema = await YamlConfigSchemaValidator.LoadAsync(tableName, schemaPath, cancellationToken)
.ConfigureAwait(false);
- CacheSchema(cacheKey, schema);
+ CacheSchema(cacheKey, lastWriteTimeUtc, schema);
return schema;
}
@@ -157,15 +159,18 @@ public static class YamlConfigTextValidator
}
///
- /// 使用最新的文件时间戳刷新 schema 缓存。
+ /// 使用读取前捕获的文件时间戳刷新 schema 缓存。
+ /// 这样即使 schema 在读取过程中发生变化,后续访问也会因时间戳变新而重新加载,
+ /// 避免把“旧内容 + 新时间戳”写入缓存。
///
/// 缓存键。
+ /// 本次读取开始前捕获的 schema 文件修改时间。
/// 最新加载的 schema。
private static void CacheSchema(
SchemaCacheKey cacheKey,
+ DateTime lastWriteTimeUtc,
YamlConfigSchema schema)
{
- var lastWriteTimeUtc = File.GetLastWriteTimeUtc(cacheKey.SchemaPath);
SchemaCache[cacheKey] = new SchemaCacheEntry(lastWriteTimeUtc, schema);
}