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