mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-04-02 11:44:28 +08:00
feat(game): 添加基于YAML的游戏内容配置系统
- 实现YamlConfigLoader支持从YAML文件加载配置数据 - 提供ConfigRegistry用于统一管理命名的配置表 - 支持JSON Schema校验配置结构和类型匹配 - 实现跨表引用校验避免无效引用和缺失依赖 - 提供开发期热重载功能监听文件变更自动刷新 - 支持一对象一文件的目录组织方式 - 集成VS Code插件提供配置浏览和轻量校验 - 生成器支持从schema自动生成配置类型定义 - 文档说明配置系统的使用方法和推荐目录结构
This commit is contained in:
parent
0c662ced2a
commit
15761c6677
@ -54,6 +54,15 @@ public interface IConfigRegistry : IUtility
|
||||
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
|
||||
where TKey : notnull;
|
||||
|
||||
/// <summary>
|
||||
/// 尝试获取指定名称的原始配置表。
|
||||
/// 该入口用于跨表校验或诊断场景,以便在不知道泛型参数时仍能访问表元数据。
|
||||
/// </summary>
|
||||
/// <param name="name">配置表名称。</param>
|
||||
/// <param name="table">匹配的原始配置表;未找到时返回空。</param>
|
||||
/// <returns>找到配置表时返回 <c>true</c>,否则返回 <c>false</c>。</returns>
|
||||
bool TryGetTable(string name, out IConfigTable? table);
|
||||
|
||||
/// <summary>
|
||||
/// 检查指定名称的配置表是否存在。
|
||||
/// </summary>
|
||||
|
||||
@ -61,6 +61,26 @@ public class ConfigRegistryTests
|
||||
Assert.Throws<InvalidOperationException>(() => registry.GetTable<string, MonsterConfigStub>("monster"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证弱类型查询入口可以在不知道泛型参数时返回原始配置表。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void TryGetTable_Should_Return_Raw_Table_When_Name_Exists()
|
||||
{
|
||||
var registry = new ConfigRegistry();
|
||||
var table = CreateMonsterTable();
|
||||
registry.RegisterTable("monster", table);
|
||||
|
||||
var found = registry.TryGetTable("monster", out var rawTable);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(found, Is.True);
|
||||
Assert.That(rawTable, Is.SameAs(table));
|
||||
Assert.That(rawTable!.KeyType, Is.EqualTo(typeof(int)));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证移除和清空操作会更新注册表状态。
|
||||
/// </summary>
|
||||
|
||||
@ -331,6 +331,212 @@ public class YamlConfigLoaderTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证绑定跨表引用 schema 时,存在的目标行可以通过加载校验。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task LoadAsync_Should_Accept_Existing_Cross_Table_Reference()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"item/potion.yaml",
|
||||
"""
|
||||
id: potion
|
||||
name: Potion
|
||||
""");
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
dropItemId: potion
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/item.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" }
|
||||
}
|
||||
}
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name", "dropItemId"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"dropItemId": {
|
||||
"type": "string",
|
||||
"x-gframework-ref-table": "item"
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable<string, ItemConfigStub>("item", "item", "schemas/item.schema.json",
|
||||
static config => config.Id)
|
||||
.RegisterTable<int, MonsterDropConfigStub>("monster", "monster", "schemas/monster.schema.json",
|
||||
static config => config.Id);
|
||||
var registry = new ConfigRegistry();
|
||||
|
||||
await loader.LoadAsync(registry);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(registry.GetTable<string, ItemConfigStub>("item").ContainsKey("potion"), Is.True);
|
||||
Assert.That(registry.GetTable<int, MonsterDropConfigStub>("monster").Get(1).DropItemId,
|
||||
Is.EqualTo("potion"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证缺失的跨表引用会阻止整批配置写入注册表。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void LoadAsync_Should_Throw_When_Cross_Table_Reference_Target_Is_Missing()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"item/slime-gel.yaml",
|
||||
"""
|
||||
id: slime_gel
|
||||
name: Slime Gel
|
||||
""");
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
dropItemId: potion
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/item.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" }
|
||||
}
|
||||
}
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name", "dropItemId"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"dropItemId": {
|
||||
"type": "string",
|
||||
"x-gframework-ref-table": "item"
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable<string, ItemConfigStub>("item", "item", "schemas/item.schema.json",
|
||||
static config => config.Id)
|
||||
.RegisterTable<int, MonsterDropConfigStub>("monster", "monster", "schemas/monster.schema.json",
|
||||
static config => config.Id);
|
||||
var registry = new ConfigRegistry();
|
||||
|
||||
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(exception, Is.Not.Null);
|
||||
Assert.That(exception!.Message, Does.Contain("dropItemId"));
|
||||
Assert.That(exception!.Message, Does.Contain("potion"));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证跨表引用同样支持标量数组中的每个元素。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void LoadAsync_Should_Throw_When_Array_Reference_Item_Is_Missing()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"item/potion.yaml",
|
||||
"""
|
||||
id: potion
|
||||
name: Potion
|
||||
""");
|
||||
CreateConfigFile(
|
||||
"item/slime-gel.yaml",
|
||||
"""
|
||||
id: slime_gel
|
||||
name: Slime Gel
|
||||
""");
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
dropItemIds:
|
||||
- potion
|
||||
- missing_item
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/item.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" }
|
||||
}
|
||||
}
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name", "dropItemIds"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"dropItemIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"x-gframework-ref-table": "item"
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable<string, ItemConfigStub>("item", "item", "schemas/item.schema.json",
|
||||
static config => config.Id)
|
||||
.RegisterTable<int, MonsterDropArrayConfigStub>("monster", "monster", "schemas/monster.schema.json",
|
||||
static config => config.Id);
|
||||
var registry = new ConfigRegistry();
|
||||
|
||||
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(exception, Is.Not.Null);
|
||||
Assert.That(exception!.Message, Does.Contain("dropItemIds[1]"));
|
||||
Assert.That(exception!.Message, Does.Contain("missing_item"));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。
|
||||
/// </summary>
|
||||
@ -468,6 +674,97 @@ public class YamlConfigLoaderTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当被引用表变更导致依赖表引用失效时,热重载会整体回滚受影响表。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task EnableHotReload_Should_Keep_Previous_State_When_Dependency_Table_Breaks_Cross_Table_Reference()
|
||||
{
|
||||
CreateConfigFile(
|
||||
"item/potion.yaml",
|
||||
"""
|
||||
id: potion
|
||||
name: Potion
|
||||
""");
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
dropItemId: potion
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/item.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" }
|
||||
}
|
||||
}
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name", "dropItemId"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"dropItemId": {
|
||||
"type": "string",
|
||||
"x-gframework-ref-table": "item"
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable<string, ItemConfigStub>("item", "item", "schemas/item.schema.json",
|
||||
static config => config.Id)
|
||||
.RegisterTable<int, MonsterDropConfigStub>("monster", "monster", "schemas/monster.schema.json",
|
||||
static config => config.Id);
|
||||
var registry = new ConfigRegistry();
|
||||
await loader.LoadAsync(registry);
|
||||
|
||||
var reloadFailureTaskSource =
|
||||
new TaskCompletionSource<(string TableName, Exception Exception)>(TaskCreationOptions
|
||||
.RunContinuationsAsynchronously);
|
||||
var hotReload = loader.EnableHotReload(
|
||||
registry,
|
||||
onTableReloadFailed: (tableName, exception) =>
|
||||
reloadFailureTaskSource.TrySetResult((tableName, exception)),
|
||||
debounceDelay: TimeSpan.FromMilliseconds(150));
|
||||
|
||||
try
|
||||
{
|
||||
CreateConfigFile(
|
||||
"item/potion.yaml",
|
||||
"""
|
||||
id: elixir
|
||||
name: Elixir
|
||||
""");
|
||||
|
||||
var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(failure.TableName, Is.EqualTo("item"));
|
||||
Assert.That(failure.Exception.Message, Does.Contain("dropItemId"));
|
||||
Assert.That(registry.GetTable<string, ItemConfigStub>("item").ContainsKey("potion"), Is.True);
|
||||
Assert.That(registry.GetTable<int, MonsterDropConfigStub>("monster").Get(1).DropItemId,
|
||||
Is.EqualTo("potion"));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
hotReload.UnRegister();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建测试用配置文件。
|
||||
/// </summary>
|
||||
@ -555,6 +852,64 @@ public class YamlConfigLoaderTests
|
||||
public IReadOnlyList<int> DropRates { get; set; } = Array.Empty<int>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于跨表引用测试的最小物品配置类型。
|
||||
/// </summary>
|
||||
private sealed class ItemConfigStub
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置主键。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于单值跨表引用测试的怪物配置类型。
|
||||
/// </summary>
|
||||
private sealed class MonsterDropConfigStub
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置主键。
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置掉落物品主键。
|
||||
/// </summary>
|
||||
public string DropItemId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于数组跨表引用测试的怪物配置类型。
|
||||
/// </summary>
|
||||
private sealed class MonsterDropArrayConfigStub
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置主键。
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置掉落物品主键列表。
|
||||
/// </summary>
|
||||
public List<string> DropItemIds { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于验证注册表一致性的现有配置类型。
|
||||
/// </summary>
|
||||
|
||||
@ -120,6 +120,25 @@ public sealed class ConfigRegistry : IConfigRegistry
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试根据名称获取原始配置表。
|
||||
/// </summary>
|
||||
/// <param name="name">要查找的配置表名称。</param>
|
||||
/// <param name="table">输出参数,如果查找成功则返回原始配置表实例,否则为 null。</param>
|
||||
/// <returns>如果找到指定名称的配置表则返回 true,否则返回 false。</returns>
|
||||
/// <exception cref="ArgumentException">当 <paramref name="name" /> 为 null、空或仅包含空白字符时抛出。</exception>
|
||||
public bool TryGetTable(string name, out IConfigTable? table)
|
||||
{
|
||||
table = default;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name));
|
||||
}
|
||||
|
||||
return _tables.TryGetValue(name, out table);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查指定名称的配置表是否已注册。
|
||||
/// </summary>
|
||||
|
||||
@ -20,6 +20,10 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
"Schema relative path cannot be null or whitespace.";
|
||||
|
||||
private readonly IDeserializer _deserializer;
|
||||
|
||||
private readonly Dictionary<string, IReadOnlyCollection<string>> _lastSuccessfulDependencies =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
private readonly List<IYamlTableRegistration> _registrations = new();
|
||||
private readonly string _rootPath;
|
||||
|
||||
@ -57,7 +61,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
|
||||
var loadedTables = new List<(string name, IConfigTable table)>(_registrations.Count);
|
||||
var loadedTables = new List<YamlTableLoadResult>(_registrations.Count);
|
||||
|
||||
foreach (var registration in _registrations)
|
||||
{
|
||||
@ -65,11 +69,15 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
loadedTables.Add(await registration.LoadAsync(_rootPath, _deserializer, cancellationToken));
|
||||
}
|
||||
|
||||
CrossTableReferenceValidator.Validate(registry, loadedTables);
|
||||
|
||||
// 仅当本轮所有配置表都成功加载后才写入注册表,避免暴露部分成功的中间状态。
|
||||
foreach (var (name, table) in loadedTables)
|
||||
foreach (var loadedTable in loadedTables)
|
||||
{
|
||||
RegistrationDispatcher.Register(registry, name, table);
|
||||
RegistrationDispatcher.Register(registry, loadedTable.Name, loadedTable.Table);
|
||||
}
|
||||
|
||||
UpdateLastSuccessfulDependencies(loadedTables);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -96,11 +104,22 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
_deserializer,
|
||||
registry,
|
||||
_registrations,
|
||||
_lastSuccessfulDependencies,
|
||||
onTableReloaded,
|
||||
onTableReloadFailed,
|
||||
debounceDelay ?? TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
private void UpdateLastSuccessfulDependencies(IEnumerable<YamlTableLoadResult> loadedTables)
|
||||
{
|
||||
_lastSuccessfulDependencies.Clear();
|
||||
|
||||
foreach (var loadedTable in loadedTables)
|
||||
{
|
||||
_lastSuccessfulDependencies[loadedTable.Name] = loadedTable.ReferencedTableNames;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册一个 YAML 配置表定义。
|
||||
/// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。
|
||||
@ -244,8 +263,8 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
/// <param name="rootPath">配置根目录。</param>
|
||||
/// <param name="deserializer">YAML 反序列化器。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>已加载的配置表名称与配置表实例。</returns>
|
||||
Task<(string name, IConfigTable table)> LoadAsync(
|
||||
/// <returns>已加载的配置表结果。</returns>
|
||||
Task<YamlTableLoadResult> LoadAsync(
|
||||
string rootPath,
|
||||
IDeserializer deserializer,
|
||||
CancellationToken cancellationToken);
|
||||
@ -300,7 +319,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
public string? SchemaRelativePath { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(string name, IConfigTable table)> LoadAsync(
|
||||
public async Task<YamlTableLoadResult> LoadAsync(
|
||||
string rootPath,
|
||||
IDeserializer deserializer,
|
||||
CancellationToken cancellationToken)
|
||||
@ -313,12 +332,15 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
}
|
||||
|
||||
YamlConfigSchema? schema = null;
|
||||
IReadOnlyCollection<string> referencedTableNames = Array.Empty<string>();
|
||||
if (!string.IsNullOrEmpty(SchemaRelativePath))
|
||||
{
|
||||
var schemaPath = Path.Combine(rootPath, SchemaRelativePath);
|
||||
schema = await YamlConfigSchemaValidator.LoadAsync(schemaPath, cancellationToken);
|
||||
referencedTableNames = schema.ReferencedTableNames;
|
||||
}
|
||||
|
||||
var referenceUsages = new List<YamlConfigReferenceUsage>();
|
||||
var values = new List<TValue>();
|
||||
var files = Directory
|
||||
.EnumerateFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly)
|
||||
@ -346,8 +368,9 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
|
||||
if (schema != null)
|
||||
{
|
||||
// 先按 schema 拒绝结构问题,避免被 IgnoreUnmatchedProperties 或默认值掩盖配置错误。
|
||||
YamlConfigSchemaValidator.Validate(schema, file, yaml);
|
||||
// 先按 schema 拒绝结构问题并提取跨表引用,避免被 IgnoreUnmatchedProperties 或默认值掩盖配置错误。
|
||||
referenceUsages.AddRange(
|
||||
YamlConfigSchemaValidator.ValidateAndCollectReferences(schema, file, yaml));
|
||||
}
|
||||
|
||||
try
|
||||
@ -372,7 +395,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
try
|
||||
{
|
||||
var table = new InMemoryConfigTable<TKey, TValue>(values, _keySelector, _comparer);
|
||||
return (Name, table);
|
||||
return new YamlTableLoadResult(Name, table, referencedTableNames, referenceUsages);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@ -383,6 +406,237 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示单个注册项加载完成后的中间结果。
|
||||
/// 该结果同时携带配置表实例、schema 声明的依赖关系和 YAML 中提取出的实际引用,以便在批量提交前完成跨表一致性校验。
|
||||
/// </summary>
|
||||
private sealed class YamlTableLoadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一个表加载结果。
|
||||
/// </summary>
|
||||
/// <param name="name">配置表名称。</param>
|
||||
/// <param name="table">已构建好的配置表。</param>
|
||||
/// <param name="referencedTableNames">schema 声明的依赖表名称集合。</param>
|
||||
/// <param name="referenceUsages">YAML 中提取出的实际引用集合。</param>
|
||||
public YamlTableLoadResult(
|
||||
string name,
|
||||
IConfigTable table,
|
||||
IReadOnlyCollection<string> referencedTableNames,
|
||||
IReadOnlyCollection<YamlConfigReferenceUsage> referenceUsages)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
ArgumentNullException.ThrowIfNull(table);
|
||||
ArgumentNullException.ThrowIfNull(referencedTableNames);
|
||||
ArgumentNullException.ThrowIfNull(referenceUsages);
|
||||
|
||||
Name = name;
|
||||
Table = table;
|
||||
ReferencedTableNames = referencedTableNames;
|
||||
ReferenceUsages = referenceUsages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取配置表名称。
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取已构建好的配置表。
|
||||
/// </summary>
|
||||
public IConfigTable Table { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取 schema 声明的依赖表名称集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> ReferencedTableNames { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取 YAML 中提取出的实际引用集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<YamlConfigReferenceUsage> ReferenceUsages { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 负责在所有注册项加载完成后执行跨表引用校验。
|
||||
/// 该阶段在真正写入注册表之前运行,确保任何缺失目标表、主键类型不兼容或目标行不存在的情况都会整体回滚。
|
||||
/// </summary>
|
||||
private static class CrossTableReferenceValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用本轮新加载结果与注册表中保留的旧表,一起验证跨表引用是否全部有效。
|
||||
/// </summary>
|
||||
/// <param name="registry">当前配置注册表。</param>
|
||||
/// <param name="loadedTables">本轮加载出的配置表集合。</param>
|
||||
public static void Validate(IConfigRegistry registry, IReadOnlyCollection<YamlTableLoadResult> loadedTables)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
ArgumentNullException.ThrowIfNull(loadedTables);
|
||||
|
||||
var loadedTableLookup = loadedTables.ToDictionary(static table => table.Name, StringComparer.Ordinal);
|
||||
|
||||
foreach (var loadedTable in loadedTables)
|
||||
{
|
||||
foreach (var referenceUsage in loadedTable.ReferenceUsages)
|
||||
{
|
||||
if (!TryResolveTargetTable(registry, loadedTableLookup, referenceUsage.ReferencedTableName,
|
||||
out var targetTable))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' references table '{referenceUsage.ReferencedTableName}', but that table is not available in the current loader batch or registry.");
|
||||
}
|
||||
|
||||
if (!TryConvertReferenceKey(referenceUsage, targetTable.KeyType, out var convertedKey,
|
||||
out var conversionError))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' cannot target table '{referenceUsage.ReferencedTableName}' with key type '{targetTable.KeyType.Name}'. {conversionError}");
|
||||
}
|
||||
|
||||
if (!ContainsKey(targetTable, convertedKey!))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' references missing key '{referenceUsage.RawValue}' in table '{referenceUsage.ReferencedTableName}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryResolveTargetTable(
|
||||
IConfigRegistry registry,
|
||||
IReadOnlyDictionary<string, YamlTableLoadResult> loadedTableLookup,
|
||||
string tableName,
|
||||
out IConfigTable table)
|
||||
{
|
||||
if (loadedTableLookup.TryGetValue(tableName, out var loadedTable))
|
||||
{
|
||||
table = loadedTable.Table;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (registry.TryGetTable(tableName, out var registeredTable) && registeredTable != null)
|
||||
{
|
||||
table = registeredTable;
|
||||
return true;
|
||||
}
|
||||
|
||||
table = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryConvertReferenceKey(
|
||||
YamlConfigReferenceUsage referenceUsage,
|
||||
Type targetKeyType,
|
||||
out object? convertedKey,
|
||||
out string errorMessage)
|
||||
{
|
||||
convertedKey = null;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (referenceUsage.ValueType == YamlConfigSchemaPropertyType.String)
|
||||
{
|
||||
if (targetKeyType != typeof(string))
|
||||
{
|
||||
errorMessage =
|
||||
$"Reference values declared as schema type 'string' can currently only target string-key tables, but the target key type is '{targetKeyType.Name}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
convertedKey = referenceUsage.RawValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (referenceUsage.ValueType != YamlConfigSchemaPropertyType.Integer)
|
||||
{
|
||||
errorMessage =
|
||||
$"Reference values currently only support schema scalar types 'string' and 'integer', but the actual type is '{referenceUsage.ValueType}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryConvertIntegerKey(referenceUsage.RawValue, targetKeyType, out convertedKey, out errorMessage);
|
||||
}
|
||||
|
||||
private static bool TryConvertIntegerKey(
|
||||
string rawValue,
|
||||
Type targetKeyType,
|
||||
out object? convertedKey,
|
||||
out string errorMessage)
|
||||
{
|
||||
convertedKey = null;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (targetKeyType == typeof(int) &&
|
||||
int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
|
||||
{
|
||||
convertedKey = intValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (targetKeyType == typeof(long) &&
|
||||
long.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longValue))
|
||||
{
|
||||
convertedKey = longValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (targetKeyType == typeof(short) &&
|
||||
short.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var shortValue))
|
||||
{
|
||||
convertedKey = shortValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (targetKeyType == typeof(byte) &&
|
||||
byte.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var byteValue))
|
||||
{
|
||||
convertedKey = byteValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (targetKeyType == typeof(uint) &&
|
||||
uint.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var uintValue))
|
||||
{
|
||||
convertedKey = uintValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (targetKeyType == typeof(ulong) &&
|
||||
ulong.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ulongValue))
|
||||
{
|
||||
convertedKey = ulongValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (targetKeyType == typeof(ushort) &&
|
||||
ushort.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ushortValue))
|
||||
{
|
||||
convertedKey = ushortValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (targetKeyType == typeof(sbyte) &&
|
||||
sbyte.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var sbyteValue))
|
||||
{
|
||||
convertedKey = sbyteValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
errorMessage =
|
||||
$"Reference value '{rawValue}' cannot be converted to supported target key type '{targetKeyType.Name}'. Integer references currently support the standard signed and unsigned integer CLR key types.";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool ContainsKey(IConfigTable table, object key)
|
||||
{
|
||||
var tableInterface = table.GetType()
|
||||
.GetInterfaces()
|
||||
.First(static type =>
|
||||
type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IConfigTable<,>));
|
||||
var containsKeyMethod = tableInterface.GetMethod(nameof(IConfigTable<int, int>.ContainsKey))!;
|
||||
return (bool)containsKeyMethod.Invoke(table, new[] { key })!;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 封装开发期热重载所需的文件监听与按表重载逻辑。
|
||||
/// 该会话只影响通过当前加载器注册的表,不尝试接管注册表中的其他来源数据。
|
||||
@ -390,6 +644,10 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
private sealed class HotReloadSession : IUnRegister, IDisposable
|
||||
{
|
||||
private readonly TimeSpan _debounceDelay;
|
||||
|
||||
private readonly Dictionary<string, IReadOnlyCollection<string>> _dependenciesByTable =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
private readonly IDeserializer _deserializer;
|
||||
private readonly object _gate = new();
|
||||
private readonly Action<string>? _onTableReloaded;
|
||||
@ -409,6 +667,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
/// <param name="deserializer">YAML 反序列化器。</param>
|
||||
/// <param name="registry">要更新的配置注册表。</param>
|
||||
/// <param name="registrations">已注册的配置表定义。</param>
|
||||
/// <param name="initialDependencies">最近一次成功加载后记录下来的跨表依赖图。</param>
|
||||
/// <param name="onTableReloaded">单表重载成功回调。</param>
|
||||
/// <param name="onTableReloadFailed">单表重载失败回调。</param>
|
||||
/// <param name="debounceDelay">监听事件防抖延迟。</param>
|
||||
@ -417,6 +676,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
IDeserializer deserializer,
|
||||
IConfigRegistry registry,
|
||||
IEnumerable<IYamlTableRegistration> registrations,
|
||||
IReadOnlyDictionary<string, IReadOnlyCollection<string>> initialDependencies,
|
||||
Action<string>? onTableReloaded,
|
||||
Action<string, Exception>? onTableReloadFailed,
|
||||
TimeSpan debounceDelay)
|
||||
@ -425,6 +685,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
ArgumentNullException.ThrowIfNull(deserializer);
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
ArgumentNullException.ThrowIfNull(registrations);
|
||||
ArgumentNullException.ThrowIfNull(initialDependencies);
|
||||
|
||||
_rootPath = rootPath;
|
||||
_deserializer = deserializer;
|
||||
@ -437,6 +698,10 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
{
|
||||
_registrations.Add(registration.Name, registration);
|
||||
_reloadLocks.Add(registration.Name, new SemaphoreSlim(1, 1));
|
||||
_dependenciesByTable[registration.Name] =
|
||||
initialDependencies.TryGetValue(registration.Name, out var dependencies)
|
||||
? dependencies
|
||||
: Array.Empty<string>();
|
||||
CreateWatchersForRegistration(registration);
|
||||
}
|
||||
}
|
||||
@ -604,7 +869,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
|
||||
private async Task ReloadTableAsync(string tableName, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_registrations.TryGetValue(tableName, out var registration))
|
||||
if (!_registrations.ContainsKey(tableName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@ -616,9 +881,26 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var (name, table) = await registration.LoadAsync(_rootPath, _deserializer, cancellationToken);
|
||||
RegistrationDispatcher.Register(_registry, name, table);
|
||||
InvokeReloaded(name);
|
||||
var affectedTableNames = GetAffectedTableNames(tableName);
|
||||
var loadedTables = new List<YamlTableLoadResult>(affectedTableNames.Count);
|
||||
|
||||
// 目标表变更可能让依赖它的表立即失效,因此热重载需要按受影响闭包整体重验并整体提交。
|
||||
foreach (var affectedTableName in affectedTableNames)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
loadedTables.Add(await _registrations[affectedTableName].LoadAsync(_rootPath, _deserializer,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
CrossTableReferenceValidator.Validate(_registry, loadedTables);
|
||||
|
||||
foreach (var loadedTable in loadedTables)
|
||||
{
|
||||
RegistrationDispatcher.Register(_registry, loadedTable.Name, loadedTable.Table);
|
||||
_dependenciesByTable[loadedTable.Name] = loadedTable.ReferencedTableNames;
|
||||
}
|
||||
|
||||
InvokeReloaded(tableName);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@ -634,6 +916,38 @@ public sealed class YamlConfigLoader : IConfigLoader
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyCollection<string> GetAffectedTableNames(string changedTableName)
|
||||
{
|
||||
var affectedTableNames = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
changedTableName
|
||||
};
|
||||
var pendingTableNames = new Queue<string>();
|
||||
pendingTableNames.Enqueue(changedTableName);
|
||||
|
||||
while (pendingTableNames.Count > 0)
|
||||
{
|
||||
var currentTableName = pendingTableNames.Dequeue();
|
||||
|
||||
foreach (var dependency in _dependenciesByTable)
|
||||
{
|
||||
if (!dependency.Value.Contains(currentTableName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (affectedTableNames.Add(dependency.Key))
|
||||
{
|
||||
pendingTableNames.Enqueue(dependency.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return affectedTableNames
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private void InvokeReloaded(string tableName)
|
||||
{
|
||||
if (_onTableReloaded == null)
|
||||
|
||||
@ -86,7 +86,14 @@ internal static class YamlConfigSchemaValidator
|
||||
properties.Add(property.Name, ParseProperty(schemaPath, property));
|
||||
}
|
||||
|
||||
return new YamlConfigSchema(schemaPath, properties, requiredProperties);
|
||||
var referencedTableNames = properties.Values
|
||||
.Select(static property => property.ReferenceTableName)
|
||||
.Where(static tableName => !string.IsNullOrWhiteSpace(tableName))
|
||||
.Cast<string>()
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new YamlConfigSchema(schemaPath, properties, requiredProperties, referencedTableNames);
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
@ -106,6 +113,24 @@ internal static class YamlConfigSchemaValidator
|
||||
YamlConfigSchema schema,
|
||||
string yamlPath,
|
||||
string yamlText)
|
||||
{
|
||||
ValidateAndCollectReferences(schema, yamlPath, yamlText);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用已解析的 schema 校验 YAML 文本,并提取声明过的跨表引用。
|
||||
/// 该方法让结构校验与引用采集共享同一份 YAML 解析结果,避免加载器重复解析同一文件。
|
||||
/// </summary>
|
||||
/// <param name="schema">已解析的 schema 模型。</param>
|
||||
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
|
||||
/// <param name="yamlText">YAML 文本内容。</param>
|
||||
/// <returns>当前 YAML 文件中声明的跨表引用集合。</returns>
|
||||
/// <exception cref="ArgumentNullException">当参数为空时抛出。</exception>
|
||||
/// <exception cref="InvalidOperationException">当 YAML 内容与 schema 不匹配时抛出。</exception>
|
||||
internal static IReadOnlyList<YamlConfigReferenceUsage> ValidateAndCollectReferences(
|
||||
YamlConfigSchema schema,
|
||||
string yamlPath,
|
||||
string yamlText)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(schema);
|
||||
ArgumentNullException.ThrowIfNull(yamlPath);
|
||||
@ -131,6 +156,7 @@ internal static class YamlConfigSchemaValidator
|
||||
$"Config file '{yamlPath}' must contain a single root mapping object.");
|
||||
}
|
||||
|
||||
var references = new List<YamlConfigReferenceUsage>();
|
||||
var seenProperties = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var entry in rootMapping.Children)
|
||||
{
|
||||
@ -154,7 +180,7 @@ internal static class YamlConfigSchemaValidator
|
||||
$"Config file '{yamlPath}' contains unknown property '{propertyName}' that is not declared in schema '{schema.SchemaPath}'.");
|
||||
}
|
||||
|
||||
ValidateNode(yamlPath, propertyName, entry.Value, property);
|
||||
ValidateNode(yamlPath, propertyName, entry.Value, property, references);
|
||||
}
|
||||
|
||||
foreach (var requiredProperty in schema.RequiredProperties)
|
||||
@ -165,6 +191,8 @@ internal static class YamlConfigSchemaValidator
|
||||
$"Config file '{yamlPath}' is missing required property '{requiredProperty}' defined by schema '{schema.SchemaPath}'.");
|
||||
}
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
private static YamlConfigSchemaProperty ParseProperty(string schemaPath, JsonProperty property)
|
||||
@ -188,9 +216,27 @@ internal static class YamlConfigSchemaValidator
|
||||
$"Property '{property.Name}' in schema file '{schemaPath}' uses unsupported type '{typeName}'.")
|
||||
};
|
||||
|
||||
string? referenceTableName = null;
|
||||
if (property.Value.TryGetProperty("x-gframework-ref-table", out var referenceTableElement))
|
||||
{
|
||||
if (referenceTableElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Property '{property.Name}' in schema file '{schemaPath}' must declare a string 'x-gframework-ref-table' value.");
|
||||
}
|
||||
|
||||
referenceTableName = referenceTableElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(referenceTableName))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Property '{property.Name}' in schema file '{schemaPath}' must declare a non-empty 'x-gframework-ref-table' value.");
|
||||
}
|
||||
}
|
||||
|
||||
if (propertyType != YamlConfigSchemaPropertyType.Array)
|
||||
{
|
||||
return new YamlConfigSchemaProperty(property.Name, propertyType, null);
|
||||
EnsureReferenceKeywordIsSupported(schemaPath, property.Name, propertyType, null, referenceTableName);
|
||||
return new YamlConfigSchemaProperty(property.Name, propertyType, null, referenceTableName);
|
||||
}
|
||||
|
||||
if (!property.Value.TryGetProperty("items", out var itemsElement) ||
|
||||
@ -213,14 +259,44 @@ internal static class YamlConfigSchemaValidator
|
||||
$"Array property '{property.Name}' in schema file '{schemaPath}' uses unsupported item type '{itemTypeName}'.")
|
||||
};
|
||||
|
||||
return new YamlConfigSchemaProperty(property.Name, propertyType, itemType);
|
||||
EnsureReferenceKeywordIsSupported(schemaPath, property.Name, propertyType, itemType, referenceTableName);
|
||||
return new YamlConfigSchemaProperty(property.Name, propertyType, itemType, referenceTableName);
|
||||
}
|
||||
|
||||
private static void EnsureReferenceKeywordIsSupported(
|
||||
string schemaPath,
|
||||
string propertyName,
|
||||
YamlConfigSchemaPropertyType propertyType,
|
||||
YamlConfigSchemaPropertyType? itemType,
|
||||
string? referenceTableName)
|
||||
{
|
||||
if (referenceTableName == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (propertyType == YamlConfigSchemaPropertyType.String ||
|
||||
propertyType == YamlConfigSchemaPropertyType.Integer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (propertyType == YamlConfigSchemaPropertyType.Array &&
|
||||
(itemType == YamlConfigSchemaPropertyType.String || itemType == YamlConfigSchemaPropertyType.Integer))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Property '{propertyName}' in schema file '{schemaPath}' uses 'x-gframework-ref-table', but only string, integer, or arrays of those scalar types can declare cross-table references.");
|
||||
}
|
||||
|
||||
private static void ValidateNode(
|
||||
string yamlPath,
|
||||
string propertyName,
|
||||
YamlNode node,
|
||||
YamlConfigSchemaProperty property)
|
||||
YamlConfigSchemaProperty property,
|
||||
ICollection<YamlConfigReferenceUsage> references)
|
||||
{
|
||||
if (property.PropertyType == YamlConfigSchemaPropertyType.Array)
|
||||
{
|
||||
@ -230,15 +306,31 @@ internal static class YamlConfigSchemaValidator
|
||||
$"Property '{propertyName}' in config file '{yamlPath}' must be an array.");
|
||||
}
|
||||
|
||||
foreach (var item in sequenceNode.Children)
|
||||
for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++)
|
||||
{
|
||||
ValidateScalarNode(yamlPath, propertyName, item, property.ItemType!.Value, isArrayItem: true);
|
||||
ValidateScalarNode(
|
||||
yamlPath,
|
||||
propertyName,
|
||||
sequenceNode.Children[itemIndex],
|
||||
property.ItemType!.Value,
|
||||
property.ReferenceTableName,
|
||||
references,
|
||||
isArrayItem: true,
|
||||
itemIndex);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
ValidateScalarNode(yamlPath, propertyName, node, property.PropertyType, isArrayItem: false);
|
||||
ValidateScalarNode(
|
||||
yamlPath,
|
||||
propertyName,
|
||||
node,
|
||||
property.PropertyType,
|
||||
property.ReferenceTableName,
|
||||
references,
|
||||
isArrayItem: false,
|
||||
itemIndex: null);
|
||||
}
|
||||
|
||||
private static void ValidateScalarNode(
|
||||
@ -246,7 +338,10 @@ internal static class YamlConfigSchemaValidator
|
||||
string propertyName,
|
||||
YamlNode node,
|
||||
YamlConfigSchemaPropertyType expectedType,
|
||||
bool isArrayItem)
|
||||
string? referenceTableName,
|
||||
ICollection<YamlConfigReferenceUsage> references,
|
||||
bool isArrayItem,
|
||||
int? itemIndex)
|
||||
{
|
||||
if (node is not YamlScalarNode scalarNode)
|
||||
{
|
||||
@ -287,6 +382,18 @@ internal static class YamlConfigSchemaValidator
|
||||
|
||||
if (isValid)
|
||||
{
|
||||
if (referenceTableName != null)
|
||||
{
|
||||
references.Add(
|
||||
new YamlConfigReferenceUsage(
|
||||
yamlPath,
|
||||
propertyName,
|
||||
itemIndex,
|
||||
value,
|
||||
referenceTableName,
|
||||
expectedType));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -336,18 +443,22 @@ internal sealed class YamlConfigSchema
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="properties">Schema 属性定义。</param>
|
||||
/// <param name="requiredProperties">必填属性集合。</param>
|
||||
/// <param name="referencedTableNames">Schema 声明的目标引用表名称集合。</param>
|
||||
public YamlConfigSchema(
|
||||
string schemaPath,
|
||||
IReadOnlyDictionary<string, YamlConfigSchemaProperty> properties,
|
||||
IReadOnlyCollection<string> requiredProperties)
|
||||
IReadOnlyCollection<string> requiredProperties,
|
||||
IReadOnlyCollection<string> referencedTableNames)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(schemaPath);
|
||||
ArgumentNullException.ThrowIfNull(properties);
|
||||
ArgumentNullException.ThrowIfNull(requiredProperties);
|
||||
ArgumentNullException.ThrowIfNull(referencedTableNames);
|
||||
|
||||
SchemaPath = schemaPath;
|
||||
Properties = properties;
|
||||
RequiredProperties = requiredProperties;
|
||||
ReferencedTableNames = referencedTableNames;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -364,6 +475,12 @@ internal sealed class YamlConfigSchema
|
||||
/// 获取 schema 声明的必填属性集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> RequiredProperties { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取 schema 声明的目标引用表名称集合。
|
||||
/// 该信息用于热重载时推导受影响的依赖表闭包。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> ReferencedTableNames { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -377,16 +494,19 @@ internal sealed class YamlConfigSchemaProperty
|
||||
/// <param name="name">属性名称。</param>
|
||||
/// <param name="propertyType">属性类型。</param>
|
||||
/// <param name="itemType">数组元素类型;仅当属性类型为数组时有效。</param>
|
||||
/// <param name="referenceTableName">目标引用表名称;未声明跨表引用时为空。</param>
|
||||
public YamlConfigSchemaProperty(
|
||||
string name,
|
||||
YamlConfigSchemaPropertyType propertyType,
|
||||
YamlConfigSchemaPropertyType? itemType)
|
||||
YamlConfigSchemaPropertyType? itemType,
|
||||
string? referenceTableName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
Name = name;
|
||||
PropertyType = propertyType;
|
||||
ItemType = itemType;
|
||||
ReferenceTableName = referenceTableName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -403,6 +523,83 @@ internal sealed class YamlConfigSchemaProperty
|
||||
/// 获取数组元素类型;非数组属性时返回空。
|
||||
/// </summary>
|
||||
public YamlConfigSchemaPropertyType? ItemType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取目标引用表名称;未声明跨表引用时返回空。
|
||||
/// </summary>
|
||||
public string? ReferenceTableName { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示单个 YAML 文件中提取出的跨表引用。
|
||||
/// 该模型保留源文件、字段路径和目标表等诊断信息,以便加载器在批量校验失败时给出可定位的错误。
|
||||
/// </summary>
|
||||
internal sealed class YamlConfigReferenceUsage
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一个跨表引用使用记录。
|
||||
/// </summary>
|
||||
/// <param name="yamlPath">源 YAML 文件路径。</param>
|
||||
/// <param name="propertyName">声明引用的属性名。</param>
|
||||
/// <param name="itemIndex">数组元素索引;标量属性时为空。</param>
|
||||
/// <param name="rawValue">YAML 中的原始标量值。</param>
|
||||
/// <param name="referencedTableName">目标配置表名称。</param>
|
||||
/// <param name="valueType">引用值的 schema 标量类型。</param>
|
||||
public YamlConfigReferenceUsage(
|
||||
string yamlPath,
|
||||
string propertyName,
|
||||
int? itemIndex,
|
||||
string rawValue,
|
||||
string referencedTableName,
|
||||
YamlConfigSchemaPropertyType valueType)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(yamlPath);
|
||||
ArgumentNullException.ThrowIfNull(propertyName);
|
||||
ArgumentNullException.ThrowIfNull(rawValue);
|
||||
ArgumentNullException.ThrowIfNull(referencedTableName);
|
||||
|
||||
YamlPath = yamlPath;
|
||||
PropertyName = propertyName;
|
||||
ItemIndex = itemIndex;
|
||||
RawValue = rawValue;
|
||||
ReferencedTableName = referencedTableName;
|
||||
ValueType = valueType;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取源 YAML 文件路径。
|
||||
/// </summary>
|
||||
public string YamlPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取声明引用的属性名。
|
||||
/// </summary>
|
||||
public string PropertyName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取数组元素索引;标量属性时返回空。
|
||||
/// </summary>
|
||||
public int? ItemIndex { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取 YAML 中的原始标量值。
|
||||
/// </summary>
|
||||
public string RawValue { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取目标配置表名称。
|
||||
/// </summary>
|
||||
public string ReferencedTableName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取引用值的 schema 标量类型。
|
||||
/// </summary>
|
||||
public YamlConfigSchemaPropertyType ValueType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取便于诊断显示的字段路径。
|
||||
/// </summary>
|
||||
public string DisplayPath => ItemIndex.HasValue ? $"{PropertyName}[{ItemIndex.Value}]" : PropertyName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -134,6 +134,7 @@
|
||||
<AdditionalFiles Remove="AnalyzerReleases.Unshipped.md"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="local-plan\docs\"/>
|
||||
<Folder Include="local-plan\todos\"/>
|
||||
<Folder Include="local-plan\评估\"/>
|
||||
</ItemGroup>
|
||||
|
||||
@ -91,6 +91,29 @@ var slime = monsterTable.Get(1);
|
||||
- 未在 schema 中声明的未知字段
|
||||
- 标量类型不匹配
|
||||
- 数组元素类型不匹配
|
||||
- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行
|
||||
|
||||
跨表引用当前使用最小扩展关键字:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "dropItemId"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"dropItemId": {
|
||||
"type": "string",
|
||||
"x-gframework-ref-table": "item"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
约束如下:
|
||||
|
||||
- 仅支持 `string`、`integer` 及其标量数组声明跨表引用
|
||||
- 引用目标表需要由同一个 `YamlConfigLoader` 注册,或已存在于当前 `IConfigRegistry`
|
||||
- 热重载中若目标表变更导致依赖表引用失效,会整体回滚受影响表,避免注册表进入不一致状态
|
||||
|
||||
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
|
||||
|
||||
@ -123,6 +146,7 @@ var hotReload = loader.EnableHotReload(
|
||||
- 监听已注册表对应的配置目录
|
||||
- 监听该表绑定的 schema 文件
|
||||
- 检测到变更后按表粒度重载
|
||||
- 若变更表被其他表通过跨表引用依赖,会联动重验受影响表
|
||||
- 重载成功后替换该表在 `IConfigRegistry` 中的注册
|
||||
- 重载失败时保留旧表,并通过失败回调提供诊断
|
||||
|
||||
@ -152,7 +176,6 @@ var hotReload = loader.EnableHotReload(
|
||||
|
||||
以下能力尚未完全完成:
|
||||
|
||||
- 跨表引用校验
|
||||
- 更完整的 JSON Schema 支持
|
||||
- 更强的 VS Code 嵌套对象与复杂数组编辑器
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user