feat(config): 添加配置架构生成器和怪物表自动生成

- 实现 SchemaConfigGenerator 源代码生成器
- 自动生成 MonsterTable 配置表包装类
- 支持基于 JSON schema 的类型安全配置访问
- 生成配置表的精确匹配索引查找功能
- 实现懒加载的只读字典索引结构
- 添加配置实体的运行时注册和访问辅助方法
This commit is contained in:
GeWuYou 2026-04-08 12:33:12 +08:00
parent 031d0d1e11
commit 017179466d
3 changed files with 128 additions and 7 deletions

View File

@ -226,6 +226,94 @@ public class SchemaConfigGeneratorTests
Does.Contain("MonsterConfigBindings.Metadata.ConfigRelativePath")); Does.Contain("MonsterConfigBindings.Metadata.ConfigRelativePath"));
} }
/// <summary>
/// 验证生成的索引构建逻辑会跳过运行时空 key避免 Lazy 索引因格式错误数据永久失效。
/// </summary>
[Test]
public void Run_Should_Skip_Runtime_Null_Keys_When_Generating_Indexed_Lookups()
{
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", "name"],
"properties": {
"id": { "type": "integer" },
"name": {
"type": "string",
"x-gframework-index": true
}
}
}
""";
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["MonsterTable.g.cs"], Does.Contain("if (key is null)"));
Assert.That(generatedSources["MonsterTable.g.cs"],
Does.Contain("Throwing here would permanently poison the cached index for this wrapper instance."));
}
/// <summary> /// <summary>
/// 验证 schema 顶层自定义配置目录元数据不能逃逸配置根目录。 /// 验证 schema 顶层自定义配置目录元数据不能逃逸配置根目录。
/// </summary> /// </summary>

View File

@ -72,6 +72,9 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.
/// <typeparam name="TProperty">Indexed property type.</typeparam> /// <typeparam name="TProperty">Indexed property type.</typeparam>
/// <param name="keySelector">Selects the indexed property from one config entry.</param> /// <param name="keySelector">Selects the indexed property from one config entry.</param>
/// <returns>A read-only dictionary whose values preserve snapshot iteration order.</returns> /// <returns>A read-only dictionary whose values preserve snapshot iteration order.</returns>
/// <remarks>
/// The generated index skips runtime null keys even though <typeparamref name="TProperty"/> is constrained to <c>notnull</c>. Malformed YAML payloads can still deserialize missing indexed values to <see langword="null" />, and throwing from this lazy path would permanently poison the cached index for the current table wrapper instance.
/// </remarks>
private global::System.Collections.Generic.IReadOnlyDictionary<TProperty, global::System.Collections.Generic.IReadOnlyList<MonsterConfig>> BuildLookupIndex<TProperty>( private global::System.Collections.Generic.IReadOnlyDictionary<TProperty, global::System.Collections.Generic.IReadOnlyList<MonsterConfig>> BuildLookupIndex<TProperty>(
global::System.Func<MonsterConfig, TProperty> keySelector) global::System.Func<MonsterConfig, TProperty> keySelector)
where TProperty : notnull where TProperty : notnull
@ -82,6 +85,13 @@ public sealed partial class MonsterTable : global::GFramework.Game.Abstractions.
foreach (var candidate in All()) foreach (var candidate in All())
{ {
var key = keySelector(candidate); var key = keySelector(candidate);
if (key is null)
{
// Skip malformed runtime data so the lazy lookup cache remains usable for valid keys.
// Throwing here would permanently poison the cached index for this wrapper instance.
continue;
}
if (!buckets.TryGetValue(key, out var matches)) if (!buckets.TryGetValue(key, out var matches))
{ {
matches = new global::System.Collections.Generic.List<MonsterConfig>(); matches = new global::System.Collections.Generic.List<MonsterConfig>();

View File

@ -1536,6 +1536,10 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" /// <typeparam name=\"TProperty\">Indexed property type.</typeparam>"); builder.AppendLine(" /// <typeparam name=\"TProperty\">Indexed property type.</typeparam>");
builder.AppendLine(" /// <param name=\"keySelector\">Selects the indexed property from one config entry.</param>"); builder.AppendLine(" /// <param name=\"keySelector\">Selects the indexed property from one config entry.</param>");
builder.AppendLine(" /// <returns>A read-only dictionary whose values preserve snapshot iteration order.</returns>"); builder.AppendLine(" /// <returns>A read-only dictionary whose values preserve snapshot iteration order.</returns>");
builder.AppendLine(" /// <remarks>");
builder.AppendLine(
" /// The generated index skips runtime null keys even though <typeparamref name=\"TProperty\"/> is constrained to <c>notnull</c>. Malformed YAML payloads can still deserialize missing indexed values to <see langword=\"null\" />, and throwing from this lazy path would permanently poison the cached index for the current table wrapper instance.");
builder.AppendLine(" /// </remarks>");
builder.AppendLine( builder.AppendLine(
$" private global::System.Collections.Generic.IReadOnlyDictionary<TProperty, global::System.Collections.Generic.IReadOnlyList<{schema.ClassName}>> BuildLookupIndex<TProperty>("); $" private global::System.Collections.Generic.IReadOnlyDictionary<TProperty, global::System.Collections.Generic.IReadOnlyList<{schema.ClassName}>> BuildLookupIndex<TProperty>(");
builder.AppendLine($" global::System.Func<{schema.ClassName}, TProperty> keySelector)"); builder.AppendLine($" global::System.Func<{schema.ClassName}, TProperty> keySelector)");
@ -1549,6 +1553,15 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" foreach (var candidate in All())"); builder.AppendLine(" foreach (var candidate in All())");
builder.AppendLine(" {"); builder.AppendLine(" {");
builder.AppendLine(" var key = keySelector(candidate);"); builder.AppendLine(" var key = keySelector(candidate);");
builder.AppendLine(" if (key is null)");
builder.AppendLine(" {");
builder.AppendLine(
" // Skip malformed runtime data so the lazy lookup cache remains usable for valid keys.");
builder.AppendLine(
" // Throwing here would permanently poison the cached index for this wrapper instance.");
builder.AppendLine(" continue;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" if (!buckets.TryGetValue(key, out var matches))"); builder.AppendLine(" if (!buckets.TryGetValue(key, out var matches))");
builder.AppendLine(" {"); builder.AppendLine(" {");
builder.AppendLine($" matches = new global::System.Collections.Generic.List<{schema.ClassName}>();"); builder.AppendLine($" matches = new global::System.Collections.Generic.List<{schema.ClassName}>();");
@ -1606,7 +1619,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" {"); builder.AppendLine(" {");
if (property.IsIndexedLookup) if (property.IsIndexedLookup)
{ {
if (IsReferenceType(property.TypeSpec.ClrType)) if (RequiresIndexedLookupNullGuard(property.TypeSpec))
{ {
builder.AppendLine(" if (value is null)"); builder.AppendLine(" if (value is null)");
builder.AppendLine(" {"); builder.AppendLine(" {");
@ -1684,7 +1697,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" {"); builder.AppendLine(" {");
if (property.IsIndexedLookup) if (property.IsIndexedLookup)
{ {
if (IsReferenceType(property.TypeSpec.ClrType)) if (RequiresIndexedLookupNullGuard(property.TypeSpec))
{ {
builder.AppendLine(" if (value is null)"); builder.AppendLine(" if (value is null)");
builder.AppendLine(" {"); builder.AppendLine(" {");
@ -2138,13 +2151,23 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
} }
/// <summary> /// <summary>
/// 判断生成字段类型是否为引用类型。 /// 判断某个已支持的索引标量映射是否需要在查询辅助中生成空值守卫。
/// 这里必须显式枚举所有已支持的 schema 标量类型,避免未来新增引用类型标量时静默漏掉空检查。
/// </summary> /// </summary>
/// <param name="clrType">生成的 CLR 类型名。</param> /// <param name="typeSpec">生成字段的标量类型模型。</param>
/// <returns>引用类型时返回 <c>true</c>;否则返回 <c>false</c>。</returns> /// <returns>需要在生成的索引查询辅助中保护 <see langword="null" /> 参数时返回 <c>true</c>;否则返回 <c>false</c>。</returns>
private static bool IsReferenceType(string clrType) /// <exception cref="InvalidOperationException">当前受支持的标量映射未被完整分类时抛出。</exception>
private static bool RequiresIndexedLookupNullGuard(SchemaTypeSpec typeSpec)
{ {
return string.Equals(clrType, "string", StringComparison.Ordinal); return typeSpec.SchemaType switch
{
"integer" => false,
"number" => false,
"boolean" => false,
"string" => true,
_ => throw new InvalidOperationException(
$"Indexed lookup null-guard classification does not cover schema scalar type '{typeSpec.SchemaType}' mapped to '{typeSpec.ClrType}'.")
};
} }
/// <summary> /// <summary>