mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-06 16:16:44 +08:00
Merge pull request #175 from GeWuYou/feat/ai-first-config-system
Feat/ai first config system
This commit is contained in:
commit
6f17e2c437
@ -91,9 +91,18 @@ public class GeneratedConfigConsumerIntegrationTests
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(MonsterConfigBindings.ConfigDomain, Is.EqualTo("monster"));
|
||||
Assert.That(MonsterConfigBindings.TableName, Is.EqualTo("monster"));
|
||||
Assert.That(MonsterConfigBindings.ConfigRelativePath, Is.EqualTo("monster"));
|
||||
Assert.That(MonsterConfigBindings.SchemaRelativePath, Is.EqualTo("schemas/monster.schema.json"));
|
||||
Assert.That(MonsterConfigBindings.Metadata.ConfigDomain, Is.EqualTo(MonsterConfigBindings.ConfigDomain));
|
||||
Assert.That(MonsterConfigBindings.Metadata.TableName, Is.EqualTo(MonsterConfigBindings.TableName));
|
||||
Assert.That(MonsterConfigBindings.Metadata.ConfigRelativePath,
|
||||
Is.EqualTo(MonsterConfigBindings.ConfigRelativePath));
|
||||
Assert.That(MonsterConfigBindings.Metadata.SchemaRelativePath,
|
||||
Is.EqualTo(MonsterConfigBindings.SchemaRelativePath));
|
||||
Assert.That(MonsterConfigBindings.References.All, Is.Empty);
|
||||
Assert.That(MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out _), Is.False);
|
||||
Assert.That(table.Count, Is.EqualTo(2));
|
||||
Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
|
||||
Assert.That(table.Get(2).Hp, Is.EqualTo(30));
|
||||
|
||||
@ -71,6 +71,68 @@ public class YamlConfigLoaderTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证加载器支持通过选项对象注册带 schema 校验的配置表。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task RegisterTable_Should_Support_Options_Object()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
hp: 10
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"hp": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable(
|
||||
new YamlConfigTableRegistrationOptions<int, MonsterConfigStub>(
|
||||
"monster",
|
||||
"monster",
|
||||
static config => config.Id)
|
||||
{
|
||||
SchemaRelativePath = "schemas/monster.schema.json"
|
||||
});
|
||||
var registry = new ConfigRegistry();
|
||||
|
||||
await loader.LoadAsync(registry);
|
||||
|
||||
var table = registry.GetTable<int, MonsterConfigStub>("monster");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(table.Count, Is.EqualTo(1));
|
||||
Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
|
||||
Assert.That(table.Get(1).Hp, Is.EqualTo(10));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证加载器会拒绝空的配置表注册选项对象。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void RegisterTable_Should_Throw_When_Options_Are_Null()
|
||||
{
|
||||
var loader = new YamlConfigLoader(_rootPath);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
loader.RegisterTable<int, MonsterConfigStub>(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证注册的配置目录不存在时会抛出清晰错误。
|
||||
/// </summary>
|
||||
@ -999,6 +1061,98 @@ public class YamlConfigLoaderTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证热重载支持通过选项对象配置回调和防抖延迟。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task EnableHotReload_Should_Support_Options_Object()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
hp: 10
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"hp": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable(
|
||||
new YamlConfigTableRegistrationOptions<int, MonsterConfigStub>(
|
||||
"monster",
|
||||
"monster",
|
||||
static config => config.Id)
|
||||
{
|
||||
SchemaRelativePath = "schemas/monster.schema.json"
|
||||
});
|
||||
var registry = new ConfigRegistry();
|
||||
await loader.LoadAsync(registry);
|
||||
|
||||
var reloadTaskSource = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var hotReload = loader.EnableHotReload(
|
||||
registry,
|
||||
new YamlConfigHotReloadOptions
|
||||
{
|
||||
OnTableReloaded = tableName => reloadTaskSource.TrySetResult(tableName),
|
||||
DebounceDelay = TimeSpan.FromMilliseconds(150)
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
hp: 25
|
||||
""");
|
||||
|
||||
var tableName = await WaitForTaskWithinAsync(reloadTaskSource.Task, TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(tableName, Is.EqualTo("monster"));
|
||||
Assert.That(registry.GetTable<int, MonsterConfigStub>("monster").Get(1).Hp, Is.EqualTo(25));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
hotReload.UnRegister();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证热重载会在启动前拒绝负的防抖延迟,避免后台延迟任务才暴露参数错误。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void EnableHotReload_Should_Throw_When_Debounce_Delay_Is_Negative()
|
||||
{
|
||||
var loader = new YamlConfigLoader(_rootPath);
|
||||
var registry = new ConfigRegistry();
|
||||
|
||||
var exception = Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
loader.EnableHotReload(
|
||||
registry,
|
||||
new YamlConfigHotReloadOptions
|
||||
{
|
||||
DebounceDelay = TimeSpan.FromMilliseconds(-1)
|
||||
}));
|
||||
|
||||
Assert.That(exception!.ParamName, Is.EqualTo("options"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。
|
||||
/// </summary>
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
27
GFramework.Game/Config/YamlConfigHotReloadOptions.cs
Normal file
27
GFramework.Game/Config/YamlConfigHotReloadOptions.cs
Normal file
@ -0,0 +1,27 @@
|
||||
namespace GFramework.Game.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 描述开发期热重载的可选行为。
|
||||
/// 该选项对象集中承载回调和防抖等可扩展参数,
|
||||
/// 以避免后续继续在 <see cref="YamlConfigLoader.EnableHotReload(GFramework.Game.Abstractions.Config.IConfigRegistry,YamlConfigHotReloadOptions?)" />
|
||||
/// 上堆叠额外重载。
|
||||
/// </summary>
|
||||
public sealed class YamlConfigHotReloadOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置单个配置表重载成功后的可选回调。
|
||||
/// </summary>
|
||||
public Action<string>? OnTableReloaded { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置单个配置表重载失败后的可选回调。
|
||||
/// 当失败来自加载器本身时,异常通常为 <see cref="GFramework.Game.Abstractions.Config.ConfigLoadException" />。
|
||||
/// </summary>
|
||||
public Action<string, Exception>? OnTableReloadFailed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置文件系统事件的防抖延迟。
|
||||
/// 默认值为 200 毫秒,用于吸收编辑器保存时的短时间重复触发。
|
||||
/// </summary>
|
||||
public TimeSpan DebounceDelay { get; init; } = TimeSpan.FromMilliseconds(200);
|
||||
}
|
||||
@ -20,6 +20,8 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
private const string SchemaRelativePathCannotBeNullOrWhiteSpaceMessage =
|
||||
"Schema relative path cannot be null or whitespace.";
|
||||
|
||||
private static readonly TimeSpan DefaultHotReloadDebounceDelay = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
private readonly IDeserializer _deserializer;
|
||||
|
||||
private readonly Dictionary<string, IReadOnlyCollection<string>> _lastSuccessfulDependencies =
|
||||
@ -95,13 +97,50 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
/// <param name="debounceDelay">防抖延迟;为空时默认使用 200 毫秒。</param>
|
||||
/// <returns>用于停止热重载监听的注销句柄。</returns>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为空时抛出。</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// 当显式提供的 <paramref name="debounceDelay" /> 小于 <see cref="TimeSpan.Zero" /> 时抛出。
|
||||
/// </exception>
|
||||
public IUnRegister EnableHotReload(
|
||||
IConfigRegistry registry,
|
||||
Action<string>? onTableReloaded = null,
|
||||
Action<string, Exception>? onTableReloadFailed = null,
|
||||
TimeSpan? debounceDelay = null)
|
||||
{
|
||||
return EnableHotReload(
|
||||
registry,
|
||||
new YamlConfigHotReloadOptions
|
||||
{
|
||||
OnTableReloaded = onTableReloaded,
|
||||
OnTableReloadFailed = onTableReloadFailed,
|
||||
DebounceDelay = debounceDelay ?? DefaultHotReloadDebounceDelay
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启用开发期热重载,并通过选项对象集中配置回调和防抖行为。
|
||||
/// 该入口用于减少继续堆叠位置参数重载的需要,
|
||||
/// 也为未来扩展过滤策略或日志钩子预留稳定形态。
|
||||
/// </summary>
|
||||
/// <param name="registry">要被热重载更新的配置注册表。</param>
|
||||
/// <param name="options">热重载配置选项;为空时使用默认选项。</param>
|
||||
/// <returns>用于停止热重载监听的注销句柄。</returns>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为空时抛出。</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// 当 <paramref name="options" /> 的 <see cref="YamlConfigHotReloadOptions.DebounceDelay" /> 小于
|
||||
/// <see cref="TimeSpan.Zero" /> 时抛出。
|
||||
/// </exception>
|
||||
public IUnRegister EnableHotReload(
|
||||
IConfigRegistry registry,
|
||||
YamlConfigHotReloadOptions? options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
options ??= new YamlConfigHotReloadOptions();
|
||||
if (options.DebounceDelay < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(options),
|
||||
"DebounceDelay must be greater than or equal to zero.");
|
||||
}
|
||||
|
||||
return new HotReloadSession(
|
||||
_rootPath,
|
||||
@ -109,9 +148,9 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
registry,
|
||||
_registrations,
|
||||
_lastSuccessfulDependencies,
|
||||
onTableReloaded,
|
||||
onTableReloadFailed,
|
||||
debounceDelay ?? TimeSpan.FromMilliseconds(200));
|
||||
options.OnTableReloaded,
|
||||
options.OnTableReloadFailed,
|
||||
options.DebounceDelay);
|
||||
}
|
||||
|
||||
private void UpdateLastSuccessfulDependencies(IEnumerable<YamlTableLoadResult> loadedTables)
|
||||
@ -135,6 +174,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,
|
||||
@ -142,7 +185,11 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
IEqualityComparer<TKey>? comparer = null)
|
||||
where TKey : notnull
|
||||
{
|
||||
return RegisterTableCore(tableName, relativePath, null, keySelector, comparer);
|
||||
return RegisterTable(
|
||||
new YamlConfigTableRegistrationOptions<TKey, TValue>(tableName, relativePath, keySelector)
|
||||
{
|
||||
Comparer = comparer
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -158,6 +205,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,
|
||||
@ -166,7 +218,40 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
IEqualityComparer<TKey>? comparer = null)
|
||||
where TKey : notnull
|
||||
{
|
||||
return RegisterTableCore(tableName, relativePath, schemaRelativePath, keySelector, comparer);
|
||||
return RegisterTable(
|
||||
new YamlConfigTableRegistrationOptions<TKey, TValue>(tableName, relativePath, keySelector)
|
||||
{
|
||||
SchemaRelativePath = schemaRelativePath,
|
||||
Comparer = comparer
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用选项对象注册一个 YAML 配置表定义。
|
||||
/// 该入口集中承载配置目录、schema 路径、主键提取器和比较器,
|
||||
/// 以避免未来继续为新增开关叠加更多重载。
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">配置主键类型。</typeparam>
|
||||
/// <typeparam name="TValue">配置值类型。</typeparam>
|
||||
/// <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
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return RegisterTableCore(
|
||||
options.TableName,
|
||||
options.RelativePath,
|
||||
options.SchemaRelativePath,
|
||||
options.KeySelector,
|
||||
options.Comparer);
|
||||
}
|
||||
|
||||
private YamlConfigLoader RegisterTableCore<TKey, TValue>(
|
||||
|
||||
73
GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs
Normal file
73
GFramework.Game/Config/YamlConfigTableRegistrationOptions.cs
Normal file
@ -0,0 +1,73 @@
|
||||
namespace GFramework.Game.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 描述一个 YAML 配置表注册项的参数集合。
|
||||
/// 该选项对象用于替代不断增加的位置参数重载,
|
||||
/// 让消费者在启用 schema 校验、主键比较器或未来扩展项时仍能保持调用点可读。
|
||||
/// </summary>
|
||||
/// <typeparam name="TKey">配置主键类型。</typeparam>
|
||||
/// <typeparam name="TValue">配置值类型。</typeparam>
|
||||
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;
|
||||
RelativePath = relativePath;
|
||||
KeySelector = keySelector;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取运行时配置表名称。
|
||||
/// </summary>
|
||||
public string TableName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取相对配置根目录的子目录。
|
||||
/// </summary>
|
||||
public string RelativePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取相对配置根目录的 schema 文件路径。
|
||||
/// 当该值为空时,当前注册项不会启用 schema 校验。
|
||||
/// </summary>
|
||||
public string? SchemaRelativePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取配置项主键提取器。
|
||||
/// </summary>
|
||||
public Func<TValue, TKey> KeySelector { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取可选的主键比较器。
|
||||
/// </summary>
|
||||
public IEqualityComparer<TKey>? Comparer { get; init; }
|
||||
}
|
||||
@ -132,7 +132,8 @@ public class SchemaConfigGeneratorSnapshotTests
|
||||
"type": "string",
|
||||
"description": "Monster reference id.",
|
||||
"minLength": 2,
|
||||
"maxLength": 32
|
||||
"maxLength": 32,
|
||||
"x-gframework-ref-table": "monster"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,4 +91,149 @@ public class SchemaConfigGeneratorTests
|
||||
Assert.That(diagnostic.GetMessage(), Does.Contain("array<array>"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 schema 字段名无法映射为合法 C# 标识符时会直接给出诊断,而不是生成不可编译代码。
|
||||
/// </summary>
|
||||
/// <param name="schemaKey">会映射为非法 C# 标识符的 schema key。</param>
|
||||
/// <param name="generatedIdentifier">当前命名规范化逻辑生成出的非法标识符。</param>
|
||||
[TestCase("drop$item", "Drop$item")]
|
||||
[TestCase("1-phase", "1Phase")]
|
||||
public void Run_Should_Report_Diagnostic_When_Schema_Key_Maps_To_Invalid_CSharp_Identifier(
|
||||
string schemaKey,
|
||||
string generatedIdentifier)
|
||||
{
|
||||
const string source = """
|
||||
namespace TestApp
|
||||
{
|
||||
public sealed class Dummy
|
||||
{
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var schema = $$"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "{{schemaKey}}"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"{{schemaKey}}": { "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(schemaKey));
|
||||
Assert.That(diagnostic.GetMessage(), Does.Contain(generatedIdentifier));
|
||||
});
|
||||
}
|
||||
|
||||
/// <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 ="));
|
||||
}
|
||||
}
|
||||
@ -117,6 +117,7 @@ public sealed partial class MonsterConfig
|
||||
/// <remarks>
|
||||
/// Schema property path: 'phases[].monsterId'.
|
||||
/// Constraints: minLength = 2, maxLength = 32.
|
||||
/// References config table: 'monster'.
|
||||
/// Generated default initializer: = string.Empty;
|
||||
/// </remarks>
|
||||
public string MonsterId { get; set; } = string.Empty;
|
||||
|
||||
@ -9,20 +9,157 @@ namespace GFramework.Game.Config.Generated;
|
||||
/// </summary>
|
||||
public static class MonsterConfigBindings
|
||||
{
|
||||
/// <summary>
|
||||
/// Describes one schema property that declares <c>x-gframework-ref-table</c> metadata.
|
||||
/// </summary>
|
||||
public readonly struct ReferenceMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes one generated cross-table reference descriptor.
|
||||
/// </summary>
|
||||
/// <param name="displayPath">Schema property path.</param>
|
||||
/// <param name="referencedTableName">Referenced runtime table name.</param>
|
||||
/// <param name="valueSchemaType">Schema scalar type used by the reference value.</param>
|
||||
/// <param name="isCollection">Whether the property stores multiple reference keys.</param>
|
||||
public ReferenceMetadata(
|
||||
string displayPath,
|
||||
string referencedTableName,
|
||||
string valueSchemaType,
|
||||
bool isCollection)
|
||||
{
|
||||
DisplayPath = displayPath ?? throw new global::System.ArgumentNullException(nameof(displayPath));
|
||||
ReferencedTableName = referencedTableName ?? throw new global::System.ArgumentNullException(nameof(referencedTableName));
|
||||
ValueSchemaType = valueSchemaType ?? throw new global::System.ArgumentNullException(nameof(valueSchemaType));
|
||||
IsCollection = isCollection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema property path such as <c>dropItems</c> or <c>phases[].monsterId</c>.
|
||||
/// </summary>
|
||||
public string DisplayPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the runtime registration name of the referenced config table.
|
||||
/// </summary>
|
||||
public string ReferencedTableName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema scalar type used by the referenced key value.
|
||||
/// </summary>
|
||||
public string ValueSchemaType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the property stores multiple reference keys.
|
||||
/// </summary>
|
||||
public bool IsCollection { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups the schema-derived metadata constants so consumer code can reuse one stable entry point.
|
||||
/// </summary>
|
||||
public static class Metadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the logical config domain derived from the schema base name. The current runtime convention keeps this value aligned with the generated table name.
|
||||
/// </summary>
|
||||
public const string ConfigDomain = "monster";
|
||||
|
||||
/// <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>
|
||||
/// Gets the logical config domain derived from the schema base name. The current runtime convention keeps this value aligned with the generated table name.
|
||||
/// </summary>
|
||||
public const string ConfigDomain = Metadata.ConfigDomain;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the runtime registration name of the generated config table.
|
||||
/// </summary>
|
||||
public const string TableName = "monster";
|
||||
public const string TableName = Metadata.TableName;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the config directory path expected by the generated registration helper.
|
||||
/// </summary>
|
||||
public const string ConfigRelativePath = "monster";
|
||||
public const string ConfigRelativePath = Metadata.ConfigRelativePath;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema file path expected by the generated registration helper.
|
||||
/// </summary>
|
||||
public const string SchemaRelativePath = "schemas/monster.schema.json";
|
||||
public const string SchemaRelativePath = Metadata.SchemaRelativePath;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes generated metadata for schema properties that declare <c>x-gframework-ref-table</c>.
|
||||
/// </summary>
|
||||
public static class References
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets generated reference metadata for schema property path 'dropItems'.
|
||||
/// </summary>
|
||||
public static readonly ReferenceMetadata DropItems = new(
|
||||
"dropItems",
|
||||
"item",
|
||||
"string",
|
||||
true);
|
||||
|
||||
/// <summary>
|
||||
/// Gets generated reference metadata for schema property path 'phases[].monsterId'.
|
||||
/// </summary>
|
||||
public static readonly ReferenceMetadata PhasesItemsMonsterId = new(
|
||||
"phases[].monsterId",
|
||||
"monster",
|
||||
"string",
|
||||
false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all generated cross-table reference descriptors for the current schema.
|
||||
/// </summary>
|
||||
public static global::System.Collections.Generic.IReadOnlyList<ReferenceMetadata> All { get; } = global::System.Array.AsReadOnly(new ReferenceMetadata[]
|
||||
{
|
||||
DropItems,
|
||||
PhasesItemsMonsterId,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Tries to resolve generated reference metadata by schema property path.
|
||||
/// </summary>
|
||||
/// <param name="displayPath">Schema property path.</param>
|
||||
/// <param name="metadata">Resolved generated reference metadata when the path is known; otherwise the default value.</param>
|
||||
/// <returns>True when the schema property path has generated cross-table metadata; otherwise false.</returns>
|
||||
public static bool TryGetByDisplayPath(string displayPath, out ReferenceMetadata metadata)
|
||||
{
|
||||
if (displayPath is null)
|
||||
{
|
||||
throw new global::System.ArgumentNullException(nameof(displayPath));
|
||||
}
|
||||
|
||||
if (string.Equals(displayPath, "dropItems", global::System.StringComparison.Ordinal))
|
||||
{
|
||||
metadata = DropItems;
|
||||
return true;
|
||||
}
|
||||
if (string.Equals(displayPath, "phases[].monsterId", global::System.StringComparison.Ordinal))
|
||||
{
|
||||
metadata = PhasesItemsMonsterId;
|
||||
return true;
|
||||
}
|
||||
|
||||
metadata = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the generated config table using the schema-derived runtime conventions.
|
||||
@ -40,9 +177,9 @@ public static class MonsterConfigBindings
|
||||
}
|
||||
|
||||
return loader.RegisterTable<int, MonsterConfig>(
|
||||
TableName,
|
||||
ConfigRelativePath,
|
||||
SchemaRelativePath,
|
||||
Metadata.TableName,
|
||||
Metadata.ConfigRelativePath,
|
||||
Metadata.SchemaRelativePath,
|
||||
static config => config.Id,
|
||||
comparer);
|
||||
}
|
||||
@ -60,7 +197,7 @@ public static class MonsterConfigBindings
|
||||
throw new global::System.ArgumentNullException(nameof(registry));
|
||||
}
|
||||
|
||||
return new MonsterTable(registry.GetTable<int, MonsterConfig>(TableName));
|
||||
return new MonsterTable(registry.GetTable<int, MonsterConfig>(Metadata.TableName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -77,7 +214,7 @@ public static class MonsterConfigBindings
|
||||
throw new global::System.ArgumentNullException(nameof(registry));
|
||||
}
|
||||
|
||||
if (registry.TryGetTable<int, MonsterConfig>(TableName, out var innerTable) && innerTable is not null)
|
||||
if (registry.TryGetTable<int, MonsterConfig>(Metadata.TableName, out var innerTable) && innerTable is not null)
|
||||
{
|
||||
table = new MonsterTable(innerTable);
|
||||
return true;
|
||||
|
||||
@ -23,6 +23,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)
|
||||
{
|
||||
@ -634,6 +637,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
var getMethodName = $"Get{schema.EntityName}Table";
|
||||
var tryGetMethodName = $"TryGet{schema.EntityName}Table";
|
||||
var bindingsClassName = $"{schema.EntityName}ConfigBindings";
|
||||
var referenceSpecs = CollectReferenceSpecs(schema.RootObject).ToArray();
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("// <auto-generated />");
|
||||
@ -650,22 +654,202 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
builder.AppendLine($"public static class {bindingsClassName}");
|
||||
builder.AppendLine("{");
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
" /// Describes one schema property that declares <c>x-gframework-ref-table</c> metadata.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public readonly struct ReferenceMetadata");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(" /// Initializes one generated cross-table reference descriptor.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" /// <param name=\"displayPath\">Schema property path.</param>");
|
||||
builder.AppendLine(" /// <param name=\"referencedTableName\">Referenced runtime table name.</param>");
|
||||
builder.AppendLine(
|
||||
" /// <param name=\"valueSchemaType\">Schema scalar type used by the reference value.</param>");
|
||||
builder.AppendLine(
|
||||
" /// <param name=\"isCollection\">Whether the property stores multiple reference keys.</param>");
|
||||
builder.AppendLine(" public ReferenceMetadata(");
|
||||
builder.AppendLine(" string displayPath,");
|
||||
builder.AppendLine(" string referencedTableName,");
|
||||
builder.AppendLine(" string valueSchemaType,");
|
||||
builder.AppendLine(" bool isCollection)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(
|
||||
" DisplayPath = displayPath ?? throw new global::System.ArgumentNullException(nameof(displayPath));");
|
||||
builder.AppendLine(
|
||||
" ReferencedTableName = referencedTableName ?? throw new global::System.ArgumentNullException(nameof(referencedTableName));");
|
||||
builder.AppendLine(
|
||||
" ValueSchemaType = valueSchemaType ?? throw new global::System.ArgumentNullException(nameof(valueSchemaType));");
|
||||
builder.AppendLine(" IsCollection = isCollection;");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
" /// Gets the schema property path such as <c>dropItems</c> or <c>phases[].monsterId</c>.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public string DisplayPath { get; }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(" /// Gets the runtime registration name of the referenced config table.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public string ReferencedTableName { get; }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(" /// Gets the schema scalar type used by the referenced key value.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public string ValueSchemaType { get; }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
" /// Gets a value indicating whether the property stores multiple reference keys.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public bool IsCollection { get; }");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
" /// Groups the schema-derived metadata constants so consumer code can reuse one stable entry point.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public static class Metadata");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
" /// Gets the logical config domain derived from the schema base name. The current runtime convention keeps this value aligned with the generated table name.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(
|
||||
$" public const string ConfigDomain = {SymbolDisplay.FormatLiteral(schema.TableRegistrationName, true)};");
|
||||
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();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
" /// Gets the logical config domain derived from the schema base name. The current runtime convention keeps this value aligned with the generated table name.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public const string ConfigDomain = Metadata.ConfigDomain;");
|
||||
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(" public const string TableName = Metadata.TableName;");
|
||||
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(" public const string ConfigRelativePath = Metadata.ConfigRelativePath;");
|
||||
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 = Metadata.SchemaRelativePath;");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
$" public const string SchemaRelativePath = {SymbolDisplay.FormatLiteral(schema.SchemaRelativePath, true)};");
|
||||
" /// Exposes generated metadata for schema properties that declare <c>x-gframework-ref-table</c>.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" public static class References");
|
||||
builder.AppendLine(" {");
|
||||
|
||||
foreach (var referenceSpec in referenceSpecs)
|
||||
{
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
$" /// Gets generated reference metadata for schema property path '{EscapeXmlDocumentation(referenceSpec.DisplayPath)}'.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(
|
||||
$" public static readonly ReferenceMetadata {referenceSpec.MemberName} = new(");
|
||||
builder.AppendLine(
|
||||
$" {SymbolDisplay.FormatLiteral(referenceSpec.DisplayPath, true)},");
|
||||
builder.AppendLine(
|
||||
$" {SymbolDisplay.FormatLiteral(referenceSpec.ReferencedTableName, true)},");
|
||||
builder.AppendLine(
|
||||
$" {SymbolDisplay.FormatLiteral(referenceSpec.ValueSchemaType, true)},");
|
||||
builder.AppendLine(
|
||||
$" {(referenceSpec.IsCollection ? "true" : "false")});");
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
" /// Gets all generated cross-table reference descriptors for the current schema.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
if (referenceSpecs.Length == 0)
|
||||
{
|
||||
builder.AppendLine(
|
||||
" public static global::System.Collections.Generic.IReadOnlyList<ReferenceMetadata> All { get; } = global::System.Array.Empty<ReferenceMetadata>();");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.AppendLine(
|
||||
" public static global::System.Collections.Generic.IReadOnlyList<ReferenceMetadata> All { get; } = global::System.Array.AsReadOnly(new ReferenceMetadata[]");
|
||||
builder.AppendLine(" {");
|
||||
foreach (var referenceSpec in referenceSpecs)
|
||||
{
|
||||
builder.AppendLine($" {referenceSpec.MemberName},");
|
||||
}
|
||||
|
||||
builder.AppendLine(" });");
|
||||
}
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(" /// Tries to resolve generated reference metadata by schema property path.");
|
||||
builder.AppendLine(" /// </summary>");
|
||||
builder.AppendLine(" /// <param name=\"displayPath\">Schema property path.</param>");
|
||||
builder.AppendLine(
|
||||
" /// <param name=\"metadata\">Resolved generated reference metadata when the path is known; otherwise the default value.</param>");
|
||||
builder.AppendLine(
|
||||
" /// <returns>True when the schema property path has generated cross-table metadata; otherwise false.</returns>");
|
||||
builder.AppendLine(
|
||||
" public static bool TryGetByDisplayPath(string displayPath, out ReferenceMetadata metadata)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" if (displayPath is null)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(displayPath));");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
|
||||
if (referenceSpecs.Length == 0)
|
||||
{
|
||||
builder.AppendLine(" metadata = default;");
|
||||
builder.AppendLine(" return false;");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var referenceSpec in referenceSpecs)
|
||||
{
|
||||
builder.AppendLine(
|
||||
$" if (string.Equals(displayPath, {SymbolDisplay.FormatLiteral(referenceSpec.DisplayPath, true)}, global::System.StringComparison.Ordinal))");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine($" metadata = {referenceSpec.MemberName};");
|
||||
builder.AppendLine(" return true;");
|
||||
builder.AppendLine(" }");
|
||||
}
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" metadata = default;");
|
||||
builder.AppendLine(" return false;");
|
||||
}
|
||||
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
builder.AppendLine(
|
||||
@ -688,9 +872,9 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(
|
||||
$" return loader.RegisterTable<{schema.KeyClrType}, {schema.ClassName}>(");
|
||||
builder.AppendLine(" TableName,");
|
||||
builder.AppendLine(" ConfigRelativePath,");
|
||||
builder.AppendLine(" SchemaRelativePath,");
|
||||
builder.AppendLine(" Metadata.TableName,");
|
||||
builder.AppendLine(" Metadata.ConfigRelativePath,");
|
||||
builder.AppendLine(" Metadata.SchemaRelativePath,");
|
||||
builder.AppendLine($" static config => config.{schema.KeyPropertyName},");
|
||||
builder.AppendLine(" comparer);");
|
||||
builder.AppendLine(" }");
|
||||
@ -711,7 +895,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(
|
||||
$" return new {schema.TableName}(registry.GetTable<{schema.KeyClrType}, {schema.ClassName}>(TableName));");
|
||||
$" return new {schema.TableName}(registry.GetTable<{schema.KeyClrType}, {schema.ClassName}>(Metadata.TableName));");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" /// <summary>");
|
||||
@ -733,7 +917,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(
|
||||
$" if (registry.TryGetTable<{schema.KeyClrType}, {schema.ClassName}>(TableName, out var innerTable) && innerTable is not null)");
|
||||
$" if (registry.TryGetTable<{schema.KeyClrType}, {schema.ClassName}>(Metadata.TableName, out var innerTable) && innerTable is not null)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine($" table = new {schema.TableName}(innerTable);");
|
||||
builder.AppendLine(" return true;");
|
||||
@ -746,6 +930,91 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
return builder.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收集 schema 中声明的跨表引用元数据,并为生成代码分配稳定成员名。
|
||||
/// </summary>
|
||||
/// <param name="rootObject">根对象模型。</param>
|
||||
/// <returns>生成期引用元数据集合。</returns>
|
||||
private static IEnumerable<GeneratedReferenceSpec> CollectReferenceSpecs(SchemaObjectSpec rootObject)
|
||||
{
|
||||
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);
|
||||
var memberName = baseMemberName;
|
||||
if (!allocatedMemberNames.Add(memberName))
|
||||
{
|
||||
// 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
|
||||
{
|
||||
nextSuffixByBaseMemberName[baseMemberName] = 0;
|
||||
}
|
||||
|
||||
yield return new GeneratedReferenceSpec(
|
||||
memberName,
|
||||
referenceSeed.DisplayPath,
|
||||
referenceSeed.ReferencedTableName,
|
||||
referenceSeed.ValueSchemaType,
|
||||
referenceSeed.IsCollection);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 递归枚举对象树中所有带 ref-table 元数据的字段。
|
||||
/// </summary>
|
||||
/// <param name="properties">对象属性集合。</param>
|
||||
/// <returns>原始引用字段信息。</returns>
|
||||
private static IEnumerable<GeneratedReferenceSeed> EnumerateReferenceSeeds(
|
||||
IEnumerable<SchemaPropertySpec> properties)
|
||||
{
|
||||
foreach (var property in properties)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(property.TypeSpec.RefTableName))
|
||||
{
|
||||
yield return new GeneratedReferenceSeed(
|
||||
property.DisplayPath,
|
||||
property.TypeSpec.RefTableName!,
|
||||
property.TypeSpec.Kind == SchemaNodeKind.Array
|
||||
? property.TypeSpec.ItemTypeSpec?.SchemaType ?? property.TypeSpec.SchemaType
|
||||
: property.TypeSpec.SchemaType,
|
||||
property.TypeSpec.Kind == SchemaNodeKind.Array);
|
||||
}
|
||||
|
||||
if (property.TypeSpec.NestedObject is not null)
|
||||
{
|
||||
foreach (var nestedReference in EnumerateReferenceSeeds(property.TypeSpec.NestedObject.Properties))
|
||||
{
|
||||
yield return nestedReference;
|
||||
}
|
||||
}
|
||||
|
||||
if (property.TypeSpec.ItemTypeSpec?.NestedObject is not null)
|
||||
{
|
||||
foreach (var nestedReference in EnumerateReferenceSeeds(property.TypeSpec.ItemTypeSpec.NestedObject
|
||||
.Properties))
|
||||
{
|
||||
yield return nestedReference;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 递归生成配置对象类型。
|
||||
/// </summary>
|
||||
@ -910,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>
|
||||
@ -968,6 +1271,28 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
return tokens.Length == 0 ? "Config" : string.Concat(tokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 schema 字段路径转换为可用于生成引用元数据成员的 PascalCase 标识符。
|
||||
/// </summary>
|
||||
/// <param name="displayPath">Schema 字段路径。</param>
|
||||
/// <returns>稳定的成员名。</returns>
|
||||
private static string BuildReferenceMemberName(string displayPath)
|
||||
{
|
||||
var segments = displayPath.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var builder = new StringBuilder();
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var normalizedSegment = segment
|
||||
.Replace("[]", "Items")
|
||||
.Replace("[", " ")
|
||||
.Replace("]", " ");
|
||||
builder.Append(ToPascalCase(normalizedSegment));
|
||||
}
|
||||
|
||||
return builder.Length == 0 ? "Reference" : builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为 AdditionalFiles 诊断创建文件位置。
|
||||
/// </summary>
|
||||
@ -1335,6 +1660,34 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
SchemaObjectSpec? NestedObject,
|
||||
SchemaTypeSpec? ItemTypeSpec);
|
||||
|
||||
/// <summary>
|
||||
/// 生成代码前的跨表引用字段种子信息。
|
||||
/// </summary>
|
||||
/// <param name="DisplayPath">Schema 字段路径。</param>
|
||||
/// <param name="ReferencedTableName">目标表名称。</param>
|
||||
/// <param name="ValueSchemaType">引用值的标量 schema 类型。</param>
|
||||
/// <param name="IsCollection">是否为数组引用。</param>
|
||||
private sealed record GeneratedReferenceSeed(
|
||||
string DisplayPath,
|
||||
string ReferencedTableName,
|
||||
string ValueSchemaType,
|
||||
bool IsCollection);
|
||||
|
||||
/// <summary>
|
||||
/// 已分配稳定成员名的生成期跨表引用信息。
|
||||
/// </summary>
|
||||
/// <param name="MemberName">生成到绑定类中的成员名。</param>
|
||||
/// <param name="DisplayPath">Schema 字段路径。</param>
|
||||
/// <param name="ReferencedTableName">目标表名称。</param>
|
||||
/// <param name="ValueSchemaType">引用值的标量 schema 类型。</param>
|
||||
/// <param name="IsCollection">是否为数组引用。</param>
|
||||
private sealed record GeneratedReferenceSpec(
|
||||
string MemberName,
|
||||
string DisplayPath,
|
||||
string ReferencedTableName,
|
||||
string ValueSchemaType,
|
||||
bool IsCollection);
|
||||
|
||||
/// <summary>
|
||||
/// 属性解析结果包装。
|
||||
/// </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);
|
||||
}
|
||||
@ -82,6 +82,245 @@ dropItems:
|
||||
- slime_gel
|
||||
```
|
||||
|
||||
## 推荐接入模板
|
||||
|
||||
如果你准备在一个真实游戏项目里首次接入这套配置系统,建议直接采用下面这套目录与启动模板,而不是零散拼装。
|
||||
|
||||
### 目录模板
|
||||
|
||||
```text
|
||||
GameProject/
|
||||
├─ GameProject.csproj
|
||||
├─ Config/
|
||||
│ ├─ GameConfigBootstrap.cs
|
||||
│ └─ GameConfigRuntime.cs
|
||||
├─ config/
|
||||
│ ├─ monster/
|
||||
│ │ ├─ slime.yaml
|
||||
│ │ └─ goblin.yaml
|
||||
│ └─ item/
|
||||
│ └─ potion.yaml
|
||||
└─ schemas/
|
||||
├─ monster.schema.json
|
||||
└─ item.schema.json
|
||||
```
|
||||
|
||||
推荐约定如下:
|
||||
|
||||
- `schemas/` 放所有 `*.schema.json`,由 Source Generator 自动拾取
|
||||
- `config/` 放运行时加载的 YAML 数据,一对象一文件
|
||||
- `Config/` 放你自己的接入代码,例如启动注册、热重载句柄和对外读取入口
|
||||
|
||||
### `csproj` 模板
|
||||
|
||||
如果你在仓库内直接用项目引用,最小模板可以写成下面这样:
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GFramework.Game\GFramework.Game.csproj" />
|
||||
<ProjectReference Include="..\GFramework.SourceGenerators.Abstractions\GFramework.SourceGenerators.Abstractions.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
<ProjectReference Include="..\GFramework.SourceGenerators.Common\GFramework.SourceGenerators.Common.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
<ProjectReference Include="..\GFramework.SourceGenerators\GFramework.SourceGenerators.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="..\GFramework.SourceGenerators\GeWuYou.GFramework.SourceGenerators.targets" />
|
||||
</Project>
|
||||
```
|
||||
|
||||
这段配置的作用:
|
||||
|
||||
- `GFramework.Game` 提供运行时 `YamlConfigLoader`、`ConfigRegistry` 和只读表实现
|
||||
- 三个 `ProjectReference(... OutputItemType="Analyzer")` 把生成器接进当前消费者项目
|
||||
- `GeWuYou.GFramework.SourceGenerators.targets` 自动把 `schemas/**/*.schema.json` 加入 `AdditionalFiles`
|
||||
|
||||
如果你使用打包后的 NuGet,而不是仓库内项目引用,原则保持不变:
|
||||
|
||||
- 运行时项目需要引用 `GeWuYou.GFramework.Game`
|
||||
- 生成器项目需要引用 `GeWuYou.GFramework.SourceGenerators`
|
||||
- schema 目录默认仍然是 `schemas/`
|
||||
|
||||
如果你的 schema 不放在默认目录,可以在项目文件里覆盖:
|
||||
|
||||
```xml
|
||||
<PropertyGroup>
|
||||
<GFrameworkConfigSchemaDirectory>GameSchemas</GFrameworkConfigSchemaDirectory>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
### 启动引导模板
|
||||
|
||||
推荐把配置系统的初始化收敛到一个单独入口,避免把 `YamlConfigLoader` 注册逻辑散落到多个启动脚本中:
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Events;
|
||||
using GFramework.Game.Abstractions.Config;
|
||||
using GFramework.Game.Config;
|
||||
using GFramework.Game.Config.Generated;
|
||||
|
||||
namespace GameProject.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 负责初始化游戏内容配置运行时入口。
|
||||
/// </summary>
|
||||
public sealed class GameConfigBootstrap : IDisposable
|
||||
{
|
||||
private readonly ConfigRegistry _registry = new();
|
||||
private IUnRegister? _hotReload;
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前游戏进程共享的配置注册表。
|
||||
/// </summary>
|
||||
public IConfigRegistry Registry => _registry;
|
||||
|
||||
/// <summary>
|
||||
/// 从指定配置根目录加载所有已注册配置表。
|
||||
/// </summary>
|
||||
/// <param name="configRootPath">配置根目录。</param>
|
||||
/// <param name="enableHotReload">是否启用开发期热重载。</param>
|
||||
public async Task InitializeAsync(string configRootPath, bool enableHotReload = false)
|
||||
{
|
||||
var loader = new YamlConfigLoader(configRootPath)
|
||||
.RegisterMonsterTable()
|
||||
.RegisterItemTable();
|
||||
|
||||
await loader.LoadAsync(_registry);
|
||||
|
||||
if (enableHotReload)
|
||||
{
|
||||
_hotReload = loader.EnableHotReload(
|
||||
_registry,
|
||||
onTableReloaded: tableName => Console.WriteLine($"Reloaded config table: {tableName}"),
|
||||
onTableReloadFailed: static (_, exception) =>
|
||||
{
|
||||
var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
|
||||
Console.WriteLine($"Config reload failed: {diagnostic?.FailureKind}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止开发期热重载并释放相关资源。
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_hotReload?.UnRegister();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这段模板刻意遵循几个约定:
|
||||
|
||||
- 优先使用生成器产出的 `Register*Table()`,避免手写表名、路径和 key selector
|
||||
- 由一个长生命周期对象持有 `ConfigRegistry`
|
||||
- 热重载句柄和配置生命周期绑在一起,避免监听器泄漏
|
||||
|
||||
### 运行时读取模板
|
||||
|
||||
推荐不要在业务代码里直接散落字符串表名查询,而是统一依赖生成的强类型入口:
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Config.Generated;
|
||||
|
||||
namespace GameProject.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 封装游戏内容配置读取入口。
|
||||
/// </summary>
|
||||
public sealed class GameConfigRuntime
|
||||
{
|
||||
private readonly IConfigRegistry _registry;
|
||||
|
||||
/// <summary>
|
||||
/// 使用已初始化的配置注册表创建读取入口。
|
||||
/// </summary>
|
||||
/// <param name="registry">配置注册表。</param>
|
||||
public GameConfigRuntime(IConfigRegistry registry)
|
||||
{
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定怪物配置。
|
||||
/// </summary>
|
||||
/// <param name="monsterId">怪物主键。</param>
|
||||
/// <returns>强类型怪物配置。</returns>
|
||||
public MonsterConfig GetMonster(int monsterId)
|
||||
{
|
||||
return _registry.GetMonsterTable().Get(monsterId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取怪物配置表。
|
||||
/// </summary>
|
||||
/// <returns>生成的强类型表包装。</returns>
|
||||
public MonsterTable GetMonsterTable()
|
||||
{
|
||||
return _registry.GetMonsterTable();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这样做的收益:
|
||||
|
||||
- 配置系统对业务层暴露的是强类型表,而不是 `"monster"` 这类 magic string
|
||||
- 后续如果你要复用配置域、schema 路径或引用元数据,可以继续依赖 `MonsterConfigBindings.Metadata` 和
|
||||
`MonsterConfigBindings.References`
|
||||
- 如果未来把配置初始化接入 `Architecture` 或 `Module`,迁移成本也更低
|
||||
|
||||
### 热重载模板
|
||||
|
||||
如果你希望把开发期热重载显式收敛为一个可选能力,建议把失败诊断一起写进模板,而不是只打印异常文本:
|
||||
|
||||
```csharp
|
||||
var hotReload = loader.EnableHotReload(
|
||||
registry,
|
||||
onTableReloaded: tableName => Console.WriteLine($"Reloaded: {tableName}"),
|
||||
onTableReloadFailed: (tableName, exception) =>
|
||||
{
|
||||
var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
|
||||
Console.WriteLine($"Reload failed: {tableName}");
|
||||
Console.WriteLine($"Failure kind: {diagnostic?.FailureKind}");
|
||||
Console.WriteLine($"Yaml path: {diagnostic?.YamlPath}");
|
||||
Console.WriteLine($"Display path: {diagnostic?.DisplayPath}");
|
||||
});
|
||||
```
|
||||
|
||||
建议只在开发期启用这项能力:
|
||||
|
||||
- 生产环境默认更适合静态加载和固定生命周期
|
||||
- 热重载失败时应优先依赖 `ConfigLoadException.Diagnostic` 做稳定日志或 UI 提示
|
||||
- 如果你的项目已经有统一日志系统,建议在这里把诊断字段转成结构化日志,而不是拼接一整段字符串
|
||||
|
||||
如果你后续还需要为热重载增加更多开关,推荐优先使用选项对象入口,而不是继续叠加位置参数:
|
||||
|
||||
```csharp
|
||||
var hotReload = loader.EnableHotReload(
|
||||
registry,
|
||||
new YamlConfigHotReloadOptions
|
||||
{
|
||||
OnTableReloaded = tableName => Console.WriteLine($"Reloaded: {tableName}"),
|
||||
OnTableReloadFailed = (tableName, exception) =>
|
||||
{
|
||||
var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
|
||||
Console.WriteLine($"{tableName}: {diagnostic?.FailureKind}");
|
||||
},
|
||||
DebounceDelay = TimeSpan.FromMilliseconds(150)
|
||||
});
|
||||
```
|
||||
|
||||
## 运行时接入
|
||||
|
||||
当你希望加载后的配置在运行时以只读表形式暴露时,优先使用生成器产出的注册与访问辅助:
|
||||
@ -103,13 +342,37 @@ var slime = monsterTable.Get(1);
|
||||
|
||||
这组辅助会把以下约定固化到生成代码里:
|
||||
|
||||
- 配置域常量,例如 `MonsterConfigBindings.ConfigDomain`
|
||||
- 表注册名,例如 `monster`
|
||||
- 配置目录相对路径,例如 `monster`
|
||||
- schema 相对路径,例如 `schemas/monster.schema.json`
|
||||
- 主键提取逻辑,例如 `config => config.Id`
|
||||
|
||||
如果你希望把这些约定作为一个统一入口传递或复用,也可以优先读取 `MonsterConfigBindings.Metadata` 下的常量:
|
||||
|
||||
```csharp
|
||||
var domain = MonsterConfigBindings.Metadata.ConfigDomain;
|
||||
var tableName = MonsterConfigBindings.Metadata.TableName;
|
||||
var configPath = MonsterConfigBindings.Metadata.ConfigRelativePath;
|
||||
var schemaPath = MonsterConfigBindings.Metadata.SchemaRelativePath;
|
||||
```
|
||||
|
||||
如果你需要自定义目录、表名或 key selector,仍然可以直接调用 `YamlConfigLoader.RegisterTable(...)` 原始重载。
|
||||
|
||||
如果你希望把 schema 路径、比较器以及未来扩展开关集中到一个对象里,推荐改用选项对象入口:
|
||||
|
||||
```csharp
|
||||
var loader = new YamlConfigLoader("config-root")
|
||||
.RegisterTable(
|
||||
new YamlConfigTableRegistrationOptions<int, MonsterConfig>(
|
||||
"monster",
|
||||
"monster",
|
||||
static config => config.Id)
|
||||
{
|
||||
SchemaRelativePath = "schemas/monster.schema.json"
|
||||
});
|
||||
```
|
||||
|
||||
## 运行时校验行为
|
||||
|
||||
绑定 schema 的表在加载时会拒绝以下问题:
|
||||
@ -148,6 +411,21 @@ var slime = monsterTable.Get(1);
|
||||
- 引用目标表需要由同一个 `YamlConfigLoader` 注册,或已存在于当前 `IConfigRegistry`
|
||||
- 热重载中若目标表变更导致依赖表引用失效,会整体回滚受影响表,避免注册表进入不一致状态
|
||||
|
||||
如果你希望在消费者代码里复用这些跨表约定,而不是继续手写字段路径或目标表名,生成的 `*ConfigBindings` 还会暴露引用元数据:
|
||||
|
||||
```csharp
|
||||
var allReferences = MonsterConfigBindings.References.All;
|
||||
|
||||
if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var reference))
|
||||
{
|
||||
Console.WriteLine(reference.ReferencedTableName);
|
||||
Console.WriteLine(reference.ValueSchemaType);
|
||||
Console.WriteLine(reference.IsCollection);
|
||||
}
|
||||
```
|
||||
|
||||
当 schema 中存在具体引用字段时,还可以直接通过生成成员访问,例如 `MonsterConfigBindings.References.DropItems`。
|
||||
|
||||
当前还支持以下“轻量元数据”:
|
||||
|
||||
- `title`:供 VS Code 插件表单和批量编辑入口显示更友好的字段标题
|
||||
@ -195,14 +473,11 @@ catch (ConfigLoadException exception)
|
||||
```csharp
|
||||
using GFramework.Game.Abstractions.Config;
|
||||
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);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user