feat(config): 添加基于YAML的配置加载器实现

- 实现YamlConfigLoader类,支持从文件目录加载YAML配置
- 提供RegisterTable方法支持配置表定义注册
- 实现热重载功能,监听文件变更并自动重新加载
- 支持schema校验,拒绝未知字段和类型错误
- 实现跨表引用校验,确保配置一致性
- 添加YamlConfigTableRegistrationOptions选项类
- 支持防抖机制避免频繁重载
- 提供详细的错误诊断信息
This commit is contained in:
GeWuYou 2026-04-06 07:37:59 +08:00
parent 34a333a0c1
commit a416e093ee
7 changed files with 286 additions and 10 deletions

View File

@ -0,0 +1,46 @@
using GFramework.Game.Config;
namespace GFramework.Game.Tests.Config;
/// <summary>
/// 验证 YAML 配置表注册选项会在构造阶段建立最小不变量,避免非法路径状态继续向后传播。
/// </summary>
[TestFixture]
public class YamlConfigTableRegistrationOptionsTests
{
/// <summary>
/// 验证构造函数会拒绝空的或仅空白字符的表名。
/// </summary>
/// <param name="tableName">待验证的表名。</param>
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void Constructor_Should_Throw_When_Table_Name_Is_Null_Or_Whitespace(string? tableName)
{
var exception = Assert.Throws<ArgumentException>(() =>
_ = new YamlConfigTableRegistrationOptions<int, string>(
tableName!,
"monster",
static config => config.Length));
Assert.That(exception!.ParamName, Is.EqualTo("tableName"));
}
/// <summary>
/// 验证构造函数会拒绝空的或仅空白字符的相对目录路径。
/// </summary>
/// <param name="relativePath">待验证的相对目录路径。</param>
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void Constructor_Should_Throw_When_Relative_Path_Is_Null_Or_Whitespace(string? relativePath)
{
var exception = Assert.Throws<ArgumentException>(() =>
_ = new YamlConfigTableRegistrationOptions<int, string>(
"monster",
relativePath!,
static config => config.Length));
Assert.That(exception!.ParamName, Is.EqualTo("relativePath"));
}
}

View File

@ -161,6 +161,10 @@ public sealed class YamlConfigLoader : IConfigLoader
/// <param name="keySelector">配置项主键提取器。</param>
/// <param name="comparer">可选主键比较器。</param>
/// <returns>当前加载器实例,以便链式注册。</returns>
/// <exception cref="ArgumentException">
/// 当 <paramref name="tableName" /> 或 <paramref name="relativePath" /> 为 null、空字符串或空白字符串时抛出。
/// </exception>
/// <exception cref="ArgumentNullException">当 <paramref name="keySelector" /> 为 null 时抛出。</exception>
public YamlConfigLoader RegisterTable<TKey, TValue>(
string tableName,
string relativePath,
@ -188,6 +192,11 @@ public sealed class YamlConfigLoader : IConfigLoader
/// <param name="keySelector">配置项主键提取器。</param>
/// <param name="comparer">可选主键比较器。</param>
/// <returns>当前加载器实例,以便链式注册。</returns>
/// <exception cref="ArgumentException">
/// 当 <paramref name="tableName" />、<paramref name="relativePath" /> 或 <paramref name="schemaRelativePath" />
/// 为 null、空字符串或空白字符串时抛出。
/// </exception>
/// <exception cref="ArgumentNullException">当 <paramref name="keySelector" /> 为 null 时抛出。</exception>
public YamlConfigLoader RegisterTable<TKey, TValue>(
string tableName,
string relativePath,
@ -214,6 +223,11 @@ public sealed class YamlConfigLoader : IConfigLoader
/// <param name="options">配置表注册选项。</param>
/// <returns>当前加载器实例,以便链式注册。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="options" /> 为空时抛出。</exception>
/// <exception cref="ArgumentException">
/// 当 <paramref name="options" /> 内的 <see cref="YamlConfigTableRegistrationOptions{TKey, TValue}.TableName" />、
/// <see cref="YamlConfigTableRegistrationOptions{TKey, TValue}.RelativePath" /> 或
/// <see cref="YamlConfigTableRegistrationOptions{TKey, TValue}.SchemaRelativePath" /> 为 null、空字符串或空白字符串时抛出。
/// </exception>
public YamlConfigLoader RegisterTable<TKey, TValue>(YamlConfigTableRegistrationOptions<TKey, TValue> options)
where TKey : notnull
{

View File

@ -10,18 +10,34 @@ namespace GFramework.Game.Config;
public sealed class YamlConfigTableRegistrationOptions<TKey, TValue>
where TKey : notnull
{
private const string TableNameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace.";
private const string RelativePathCannotBeNullOrWhiteSpaceMessage = "Relative path cannot be null or whitespace.";
/// <summary>
/// 使用最小必需参数创建配置表注册选项。
/// </summary>
/// <param name="tableName">运行时配置表名称。</param>
/// <param name="relativePath">相对配置根目录的子目录。</param>
/// <param name="keySelector">配置项主键提取器。</param>
/// <exception cref="ArgumentException">
/// 当 <paramref name="tableName" /> 或 <paramref name="relativePath" /> 为 null、空字符串或空白字符串时抛出。
/// </exception>
/// <exception cref="ArgumentNullException">当 <paramref name="keySelector" /> 为 null 时抛出。</exception>
public YamlConfigTableRegistrationOptions(
string tableName,
string relativePath,
Func<TValue, TKey> keySelector)
{
if (string.IsNullOrWhiteSpace(tableName))
{
throw new ArgumentException(TableNameCannotBeNullOrWhiteSpaceMessage, nameof(tableName));
}
if (string.IsNullOrWhiteSpace(relativePath))
{
throw new ArgumentException(RelativePathCannotBeNullOrWhiteSpaceMessage, nameof(relativePath));
}
ArgumentNullException.ThrowIfNull(keySelector);
TableName = tableName;

View File

@ -91,4 +91,144 @@ public class SchemaConfigGeneratorTests
Assert.That(diagnostic.GetMessage(), Does.Contain("array<array>"));
});
}
/// <summary>
/// 验证 schema 字段名无法映射为合法 C# 标识符时会直接给出诊断,而不是生成不可编译代码。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_Schema_Key_Maps_To_Invalid_CSharp_Identifier()
{
const string source = """
namespace TestApp
{
public sealed class Dummy
{
}
}
""";
const string schema = """
{
"type": "object",
"required": ["id", "drop$item"],
"properties": {
"id": { "type": "integer" },
"drop$item": { "type": "string" }
}
}
""";
var result = SchemaGeneratorTestDriver.Run(
source,
("monster.schema.json", schema));
var diagnostic = result.Results.Single().Diagnostics.Single();
Assert.Multiple(() =>
{
Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_006"));
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
Assert.That(diagnostic.GetMessage(), Does.Contain("drop$item"));
Assert.That(diagnostic.GetMessage(), Does.Contain("Drop$item"));
});
}
/// <summary>
/// 验证引用元数据成员名在不同路径规范化后发生碰撞时,生成器仍会分配全局唯一的成员名。
/// </summary>
[Test]
public void Run_Should_Assign_Globally_Unique_Reference_Metadata_Member_Names()
{
const string source = """
using System;
using System.Collections.Generic;
namespace GFramework.Game.Abstractions.Config
{
public interface IConfigTable
{
Type KeyType { get; }
Type ValueType { get; }
int Count { get; }
}
public interface IConfigTable<TKey, TValue> : IConfigTable
where TKey : notnull
{
TValue Get(TKey key);
bool TryGet(TKey key, out TValue? value);
bool ContainsKey(TKey key);
IReadOnlyCollection<TValue> All();
}
public interface IConfigRegistry
{
IConfigTable<TKey, TValue> GetTable<TKey, TValue>(string name)
where TKey : notnull;
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
where TKey : notnull;
}
}
namespace GFramework.Game.Config
{
public sealed class YamlConfigLoader
{
public YamlConfigLoader RegisterTable<TKey, TValue>(
string tableName,
string relativePath,
string schemaRelativePath,
Func<TValue, TKey> keySelector,
IEqualityComparer<TKey>? comparer = null)
where TKey : notnull
{
return this;
}
}
}
""";
const string schema = """
{
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "integer" },
"drop-items": {
"type": "array",
"items": { "type": "string" },
"x-gframework-ref-table": "item"
},
"drop_items": {
"type": "array",
"items": { "type": "string" },
"x-gframework-ref-table": "item"
},
"dropItems1": {
"type": "string",
"x-gframework-ref-table": "item"
}
}
}
""";
var result = SchemaGeneratorTestDriver.Run(
source,
("monster.schema.json", schema));
var generatedSources = result.Results
.Single()
.GeneratedSources
.ToDictionary(
static sourceResult => sourceResult.HintName,
static sourceResult => sourceResult.SourceText.ToString(),
StringComparer.Ordinal);
Assert.That(result.Results.Single().Diagnostics, Is.Empty);
Assert.That(generatedSources.TryGetValue("MonsterConfigBindings.g.cs", out var bindingsSource), Is.True);
Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems ="));
Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems1 ="));
Assert.That(bindingsSource, Does.Contain("public static readonly ReferenceMetadata DropItems11 ="));
}
}

View File

@ -20,6 +20,7 @@
GF_ConfigSchema_003 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_004 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_005 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_006 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_Priority_001 | GFramework.Priority | Error | PriorityDiagnostic
GF_Priority_002 | GFramework.Priority | Warning | PriorityDiagnostic
GF_Priority_003 | GFramework.Priority | Error | PriorityDiagnostic

View File

@ -253,7 +253,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
var title = TryGetMetadataString(property.Value, "title");
var description = TryGetMetadataString(property.Value, "description");
var refTableName = TryGetMetadataString(property.Value, "x-gframework-ref-table");
var propertyName = ToPascalCase(property.Name);
if (!TryBuildPropertyIdentifier(filePath, displayPath, property.Name, out var propertyName, out var diagnostic))
{
return ParsedPropertyResult.FromDiagnostic(diagnostic!);
}
switch (schemaType)
{
@ -934,26 +937,37 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
/// <returns>生成期引用元数据集合。</returns>
private static IEnumerable<GeneratedReferenceSpec> CollectReferenceSpecs(SchemaObjectSpec rootObject)
{
var memberNameCounts = new Dictionary<string, int>(StringComparer.Ordinal);
var nextSuffixByBaseMemberName = new Dictionary<string, int>(StringComparer.Ordinal);
var allocatedMemberNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var referenceSeed in EnumerateReferenceSeeds(rootObject.Properties))
{
var baseMemberName = BuildReferenceMemberName(referenceSeed.DisplayPath);
if (memberNameCounts.TryGetValue(baseMemberName, out var duplicateCount))
var memberName = baseMemberName;
if (!allocatedMemberNames.Add(memberName))
{
// Reuse the tracked duplicate count so repeated reference paths keep their generated member names stable.
duplicateCount++;
memberNameCounts[baseMemberName] = duplicateCount;
baseMemberName =
$"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}";
// Track globally allocated member names because a suffixed duplicate from one path can collide
// with the unsuffixed base name produced by a later, different path.
var duplicateCount = nextSuffixByBaseMemberName.TryGetValue(baseMemberName, out var nextSuffix)
? nextSuffix + 1
: 1;
memberName = $"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}";
while (!allocatedMemberNames.Add(memberName))
{
duplicateCount++;
memberName = $"{baseMemberName}{duplicateCount.ToString(CultureInfo.InvariantCulture)}";
}
nextSuffixByBaseMemberName[baseMemberName] = duplicateCount;
}
else
{
memberNameCounts[baseMemberName] = 0;
nextSuffixByBaseMemberName[baseMemberName] = 0;
}
yield return new GeneratedReferenceSpec(
baseMemberName,
memberName,
referenceSeed.DisplayPath,
referenceSeed.ReferencedTableName,
referenceSeed.ValueSchemaType,
@ -1165,6 +1179,40 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine($"{indent}/// </remarks>");
}
/// <summary>
/// 将 schema 字段名转换并验证为生成代码可直接使用的属性标识符。
/// 生成器会在这里拒绝无法映射为合法 C# 标识符的外部输入,避免生成源码后才在编译阶段失败。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="schemaName">Schema 原始字段名。</param>
/// <param name="propertyName">生成后的属性名。</param>
/// <param name="diagnostic">字段名非法时生成的诊断。</param>
/// <returns>是否成功生成合法属性标识符。</returns>
private static bool TryBuildPropertyIdentifier(
string filePath,
string displayPath,
string schemaName,
out string propertyName,
out Diagnostic? diagnostic)
{
propertyName = ToPascalCase(schemaName);
if (SyntaxFacts.IsValidIdentifier(propertyName))
{
diagnostic = null;
return true;
}
diagnostic = Diagnostic.Create(
ConfigSchemaDiagnostics.InvalidGeneratedIdentifier,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
schemaName,
propertyName);
return false;
}
/// <summary>
/// 从 schema 文件路径提取实体基础名。
/// </summary>

View File

@ -63,4 +63,15 @@ public static class ConfigSchemaDiagnostics
SourceGeneratorsConfigCategory,
DiagnosticSeverity.Error,
true);
/// <summary>
/// schema 字段名无法安全映射为 C# 标识符。
/// </summary>
public static readonly DiagnosticDescriptor InvalidGeneratedIdentifier = new(
"GF_ConfigSchema_006",
"Config schema property name cannot be converted to a valid C# identifier",
"Property '{1}' in schema file '{0}' uses schema key '{2}', which generates invalid C# identifier '{3}'",
SourceGeneratorsConfigCategory,
DiagnosticSeverity.Error,
true);
}