feat(config): 添加AI-First配置系统及源生成器

- 实现YAML配置文件加载和JSON Schema校验功能
- 提供Source Generator自动生成配置类型和表包装类
- 添加VS Code插件支持配置浏览和表单编辑
- 支持跨表引用校验和开发期热重载功能
- 生成强类型的配置访问辅助方法和注册绑定
- 实现嵌套对象和对象数组的类型安全访问
This commit is contained in:
GeWuYou 2026-04-03 09:25:06 +08:00
parent eaa1e5dff4
commit 48fd8a22bb
4 changed files with 307 additions and 11 deletions

View File

@ -9,7 +9,7 @@ namespace GFramework.SourceGenerators.Tests.Config;
public class SchemaConfigGeneratorSnapshotTests
{
/// <summary>
/// 验证一个最小 monster schema 能生成配置类型和表包装
/// 验证一个最小 monster schema 能生成配置类型、表包装和注册辅助
/// </summary>
[Test]
public async Task Snapshot_SchemaConfigGenerator()
@ -35,6 +35,32 @@ public class SchemaConfigGeneratorSnapshotTests
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;
}
}
}
""";
@ -130,6 +156,8 @@ public class SchemaConfigGeneratorSnapshotTests
await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterConfig.g.cs", "MonsterConfig.g.txt");
await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterTable.g.cs", "MonsterTable.g.txt");
await AssertSnapshotAsync(generatedSources, snapshotFolder, "MonsterConfigBindings.g.cs",
"MonsterConfigBindings.g.txt");
}
/// <summary>

View File

@ -0,0 +1,89 @@
// <auto-generated />
#nullable enable
namespace GFramework.Game.Config.Generated;
/// <summary>
/// Auto-generated registration and lookup helpers for schema file 'monster.schema.json'.
/// The helper centralizes table naming, config directory, schema path, and strong-typed registry access so consumer projects do not need to duplicate the same conventions.
/// </summary>
public static class MonsterConfigBindings
{
/// <summary>
/// Gets the runtime registration name of the generated config table.
/// </summary>
public const string TableName = "monster";
/// <summary>
/// Gets the config directory path expected by the generated registration helper.
/// </summary>
public const string ConfigRelativePath = "monster";
/// <summary>
/// Gets the schema file path expected by the generated registration helper.
/// </summary>
public const string SchemaRelativePath = "schemas/monster.schema.json";
/// <summary>
/// Registers the generated config table using the schema-derived runtime conventions.
/// </summary>
/// <param name="loader">The target YAML config loader.</param>
/// <param name="comparer">Optional key comparer for the generated table registration.</param>
/// <returns>The same loader instance so registration can keep chaining.</returns>
public static global::GFramework.Game.Config.YamlConfigLoader RegisterMonsterTable(
this global::GFramework.Game.Config.YamlConfigLoader loader,
global::System.Collections.Generic.IEqualityComparer<int>? comparer = null)
{
if (loader is null)
{
throw new global::System.ArgumentNullException(nameof(loader));
}
return loader.RegisterTable<int, MonsterConfig>(
TableName,
ConfigRelativePath,
SchemaRelativePath,
static config => config.Id,
comparer);
}
/// <summary>
/// Gets the generated config table wrapper from the registry.
/// </summary>
/// <param name="registry">The source config registry.</param>
/// <returns>The generated strong-typed table wrapper.</returns>
/// <exception cref="global::System.ArgumentNullException">When <paramref name="registry"/> is null.</exception>
public static MonsterTable GetMonsterTable(this global::GFramework.Game.Abstractions.Config.IConfigRegistry registry)
{
if (registry is null)
{
throw new global::System.ArgumentNullException(nameof(registry));
}
return new MonsterTable(registry.GetTable<int, MonsterConfig>(TableName));
}
/// <summary>
/// Tries to get the generated config table wrapper from the registry.
/// </summary>
/// <param name="registry">The source config registry.</param>
/// <param name="table">The generated strong-typed table wrapper when lookup succeeds; otherwise null.</param>
/// <returns>True when the generated table is registered and type-compatible; otherwise false.</returns>
/// <exception cref="global::System.ArgumentNullException">When <paramref name="registry"/> is null.</exception>
public static bool TryGetMonsterTable(this global::GFramework.Game.Abstractions.Config.IConfigRegistry registry, out MonsterTable? table)
{
if (registry is null)
{
throw new global::System.ArgumentNullException(nameof(registry));
}
if (registry.TryGetTable<int, MonsterConfig>(TableName, out var innerTable) && innerTable is not null)
{
table = new MonsterTable(innerTable);
return true;
}
table = null;
return false;
}
}

View File

@ -41,6 +41,9 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
productionContext.AddSource(
$"{result.Schema.TableName}.g.cs",
SourceText.From(GenerateTableClass(result.Schema), Encoding.UTF8));
productionContext.AddSource(
$"{result.Schema.EntityName}ConfigBindings.g.cs",
SourceText.From(GenerateBindingsClass(result.Schema), Encoding.UTF8));
});
}
@ -128,12 +131,18 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
idProperty.TypeSpec.SchemaType));
}
var schemaBaseName = GetSchemaBaseName(file.Path);
var schema = new SchemaFileSpec(
Path.GetFileName(file.Path),
entityName,
schemaObject.ClassName,
$"{entityName}Table",
GeneratedNamespace,
idProperty.TypeSpec.ClrType.TrimEnd('?'),
idProperty.PropertyName,
schemaBaseName,
schemaBaseName,
GetSchemaRelativePath(file.Path),
TryGetMetadataString(root, "title"),
TryGetMetadataString(root, "description"),
schemaObject);
@ -607,6 +616,131 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return builder.ToString().TrimEnd();
}
/// <summary>
/// 生成运行时注册与访问辅助源码。
/// 该辅助类型把 schema 命名约定、配置目录和 schema 相对路径固化为生成代码,
/// 让消费端无需重复手写字符串常量和主键提取逻辑。
/// </summary>
/// <param name="schema">已解析的 schema 模型。</param>
/// <returns>辅助类型源码。</returns>
private static string GenerateBindingsClass(SchemaFileSpec schema)
{
var registerMethodName = $"Register{schema.EntityName}Table";
var getMethodName = $"Get{schema.EntityName}Table";
var tryGetMethodName = $"TryGet{schema.EntityName}Table";
var bindingsClassName = $"{schema.EntityName}ConfigBindings";
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
builder.AppendLine("#nullable enable");
builder.AppendLine();
builder.AppendLine($"namespace {schema.Namespace};");
builder.AppendLine();
builder.AppendLine("/// <summary>");
builder.AppendLine(
$"/// Auto-generated registration and lookup helpers for schema file '{schema.FileName}'.");
builder.AppendLine(
"/// The helper centralizes table naming, config directory, schema path, and strong-typed registry access so consumer projects do not need to duplicate the same conventions.");
builder.AppendLine("/// </summary>");
builder.AppendLine($"public static class {bindingsClassName}");
builder.AppendLine("{");
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the runtime registration name of the generated config table.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
$" public const string TableName = {SymbolDisplay.FormatLiteral(schema.TableRegistrationName, true)};");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the config directory path expected by the generated registration helper.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
$" public const string ConfigRelativePath = {SymbolDisplay.FormatLiteral(schema.ConfigRelativePath, true)};");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the schema file path expected by the generated registration helper.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
$" public const string SchemaRelativePath = {SymbolDisplay.FormatLiteral(schema.SchemaRelativePath, true)};");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// Registers the generated config table using the schema-derived runtime conventions.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"loader\">The target YAML config loader.</param>");
builder.AppendLine(
" /// <param name=\"comparer\">Optional key comparer for the generated table registration.</param>");
builder.AppendLine(" /// <returns>The same loader instance so registration can keep chaining.</returns>");
builder.AppendLine(
$" public static global::GFramework.Game.Config.YamlConfigLoader {registerMethodName}(");
builder.AppendLine(" this global::GFramework.Game.Config.YamlConfigLoader loader,");
builder.AppendLine(
$" global::System.Collections.Generic.IEqualityComparer<{schema.KeyClrType}>? comparer = null)");
builder.AppendLine(" {");
builder.AppendLine(" if (loader is null)");
builder.AppendLine(" {");
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(loader));");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(
$" return loader.RegisterTable<{schema.KeyClrType}, {schema.ClassName}>(");
builder.AppendLine(" TableName,");
builder.AppendLine(" ConfigRelativePath,");
builder.AppendLine(" SchemaRelativePath,");
builder.AppendLine($" static config => config.{schema.KeyPropertyName},");
builder.AppendLine(" comparer);");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Gets the generated config table wrapper from the registry.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"registry\">The source config registry.</param>");
builder.AppendLine(" /// <returns>The generated strong-typed table wrapper.</returns>");
builder.AppendLine(
" /// <exception cref=\"global::System.ArgumentNullException\">When <paramref name=\"registry\"/> is null.</exception>");
builder.AppendLine(
$" public static {schema.TableName} {getMethodName}(this global::GFramework.Game.Abstractions.Config.IConfigRegistry registry)");
builder.AppendLine(" {");
builder.AppendLine(" if (registry is null)");
builder.AppendLine(" {");
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(registry));");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(
$" return new {schema.TableName}(registry.GetTable<{schema.KeyClrType}, {schema.ClassName}>(TableName));");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(" /// Tries to get the generated config table wrapper from the registry.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"registry\">The source config registry.</param>");
builder.AppendLine(
" /// <param name=\"table\">The generated strong-typed table wrapper when lookup succeeds; otherwise null.</param>");
builder.AppendLine(
" /// <returns>True when the generated table is registered and type-compatible; otherwise false.</returns>");
builder.AppendLine(
" /// <exception cref=\"global::System.ArgumentNullException\">When <paramref name=\"registry\"/> is null.</exception>");
builder.AppendLine(
$" public static bool {tryGetMethodName}(this global::GFramework.Game.Abstractions.Config.IConfigRegistry registry, out {schema.TableName}? table)");
builder.AppendLine(" {");
builder.AppendLine(" if (registry is null)");
builder.AppendLine(" {");
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(registry));");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(
$" if (registry.TryGetTable<{schema.KeyClrType}, {schema.ClassName}>(TableName, out var innerTable) && innerTable is not null)");
builder.AppendLine(" {");
builder.AppendLine($" table = new {schema.TableName}(innerTable);");
builder.AppendLine(" return true;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" table = null;");
builder.AppendLine(" return false;");
builder.AppendLine(" }");
builder.AppendLine("}");
return builder.ToString().TrimEnd();
}
/// <summary>
/// 递归生成配置对象类型。
/// </summary>
@ -773,6 +907,32 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
return Path.GetFileNameWithoutExtension(fileName);
}
/// <summary>
/// 解析生成注册辅助时要使用的 schema 相对路径。
/// 生成器优先保留 `schemas/` 目录以下的相对路径,以便消费端默认约定和 MSBuild AdditionalFiles 约定保持一致。
/// </summary>
/// <param name="path">Schema 文件路径。</param>
/// <returns>用于运行时注册的 schema 相对路径。</returns>
private static string GetSchemaRelativePath(string path)
{
var normalizedPath = path.Replace('\\', '/');
const string rootMarker = "schemas/";
const string nestedMarker = "/schemas/";
if (normalizedPath.StartsWith(rootMarker, StringComparison.OrdinalIgnoreCase))
{
return normalizedPath;
}
var nestedMarkerIndex = normalizedPath.LastIndexOf(nestedMarker, StringComparison.OrdinalIgnoreCase);
if (nestedMarkerIndex >= 0)
{
return normalizedPath.Substring(nestedMarkerIndex + 1);
}
return $"schemas/{Path.GetFileName(path)}";
}
/// <summary>
/// 将 schema 名称转换为 PascalCase 标识符。
/// </summary>
@ -996,19 +1156,29 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
/// 生成器级 schema 模型。
/// </summary>
/// <param name="FileName">Schema 文件名。</param>
/// <param name="EntityName">实体名基础标识。</param>
/// <param name="ClassName">根配置类型名。</param>
/// <param name="TableName">配置表包装类型名。</param>
/// <param name="Namespace">目标命名空间。</param>
/// <param name="KeyClrType">主键 CLR 类型。</param>
/// <param name="KeyPropertyName">生成配置类型中的主键属性名。</param>
/// <param name="TableRegistrationName">运行时注册名。</param>
/// <param name="ConfigRelativePath">配置目录相对路径。</param>
/// <param name="SchemaRelativePath">Schema 文件相对路径。</param>
/// <param name="Title">根标题元数据。</param>
/// <param name="Description">根描述元数据。</param>
/// <param name="RootObject">根对象模型。</param>
private sealed record SchemaFileSpec(
string FileName,
string EntityName,
string ClassName,
string TableName,
string Namespace,
string KeyClrType,
string KeyPropertyName,
string TableRegistrationName,
string ConfigRelativePath,
string SchemaRelativePath,
string? Title,
string? Description,
SchemaObjectSpec RootObject);

View File

@ -12,7 +12,7 @@
- JSON Schema 作为结构描述
- 一对象一文件的目录组织
- 运行时只读查询
- Source Generator 生成配置类型和表包装
- Source Generator 生成配置类型、表包装和注册/访问辅助
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
## 推荐目录结构
@ -83,27 +83,31 @@ dropItems:
## 运行时接入
当你希望加载后的配置在运行时以只读表形式暴露时,可以使用 `YamlConfigLoader``ConfigRegistry`
当你希望加载后的配置在运行时以只读表形式暴露时,优先使用生成器产出的注册与访问辅助
```csharp
using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
var registry = new ConfigRegistry();
var loader = new YamlConfigLoader("config-root")
.RegisterTable<int, MonsterConfig>(
"monster",
"monster",
"schemas/monster.schema.json",
static config => config.Id);
.RegisterMonsterTable();
await loader.LoadAsync(registry);
var monsterTable = registry.GetTable<int, MonsterConfig>("monster");
var monsterTable = registry.GetMonsterTable();
var slime = monsterTable.Get(1);
```
这个重载会先按 schema 校验,再进行反序列化和注册。
这组辅助会把以下约定固化到生成代码里:
- 表注册名,例如 `monster`
- 配置目录相对路径,例如 `monster`
- schema 相对路径,例如 `schemas/monster.schema.json`
- 主键提取逻辑,例如 `config => config.Id`
如果你需要自定义目录、表名或 key selector仍然可以直接调用 `YamlConfigLoader.RegisterTable(...)` 原始重载。
## 运行时校验行为
@ -187,7 +191,12 @@ var hotReload = loader.EnableHotReload(
## 生成器接入约定
配置生成器会从 `*.schema.json` 生成配置类型和表包装类。
配置生成器会从 `*.schema.json` 生成以下代码:
- 配置类型
- 表包装类型
- `YamlConfigLoader` 注册辅助
- `IConfigRegistry` 强类型访问辅助
通过已打包的 Source Generator 使用时,默认会自动收集 `schemas/**/*.schema.json` 作为 `AdditionalFiles`