mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
feat(config): 添加AI-First配置系统及源生成器
- 实现YAML配置文件加载和JSON Schema校验功能 - 提供Source Generator自动生成配置类型和表包装类 - 添加VS Code插件支持配置浏览和表单编辑 - 支持跨表引用校验和开发期热重载功能 - 生成强类型的配置访问辅助方法和注册绑定 - 实现嵌套对象和对象数组的类型安全访问
This commit is contained in:
parent
eaa1e5dff4
commit
48fd8a22bb
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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`。
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user