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