mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-08 09:34:30 +08:00
feat(config): 添加基于YAML的配置加载器实现
- 实现YamlConfigLoader类,支持从文件目录加载YAML配置 - 提供RegisterTable方法支持配置表定义注册 - 实现热重载功能,监听文件变更并自动重新加载 - 支持schema校验,拒绝未知字段和类型错误 - 实现跨表引用校验,确保配置一致性 - 添加YamlConfigTableRegistrationOptions选项类 - 支持防抖机制避免频繁重载 - 提供详细的错误诊断信息
This commit is contained in:
parent
34a333a0c1
commit
a416e093ee
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 ="));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user