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(" }"); } ///