mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-04-02 20:09:00 +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)
|
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
|
||||||
where TKey : notnull;
|
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>
|
||||||
/// 检查指定名称的配置表是否存在。
|
/// 检查指定名称的配置表是否存在。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -61,6 +61,26 @@ public class ConfigRegistryTests
|
|||||||
Assert.Throws<InvalidOperationException>(() => registry.GetTable<string, MonsterConfigStub>("monster"));
|
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>
|
||||||
/// 验证移除和清空操作会更新注册表状态。
|
/// 验证移除和清空操作会更新注册表状态。
|
||||||
/// </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>
|
||||||
/// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。
|
/// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。
|
||||||
/// </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>
|
||||||
/// 创建测试用配置文件。
|
/// 创建测试用配置文件。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -555,6 +852,64 @@ public class YamlConfigLoaderTests
|
|||||||
public IReadOnlyList<int> DropRates { get; set; } = Array.Empty<int>();
|
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>
|
||||||
/// 用于验证注册表一致性的现有配置类型。
|
/// 用于验证注册表一致性的现有配置类型。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -120,6 +120,25 @@ public sealed class ConfigRegistry : IConfigRegistry
|
|||||||
return true;
|
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>
|
||||||
/// 检查指定名称的配置表是否已注册。
|
/// 检查指定名称的配置表是否已注册。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -20,6 +20,10 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
"Schema relative path cannot be null or whitespace.";
|
"Schema relative path cannot be null or whitespace.";
|
||||||
|
|
||||||
private readonly IDeserializer _deserializer;
|
private readonly IDeserializer _deserializer;
|
||||||
|
|
||||||
|
private readonly Dictionary<string, IReadOnlyCollection<string>> _lastSuccessfulDependencies =
|
||||||
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
private readonly List<IYamlTableRegistration> _registrations = new();
|
private readonly List<IYamlTableRegistration> _registrations = new();
|
||||||
private readonly string _rootPath;
|
private readonly string _rootPath;
|
||||||
|
|
||||||
@ -57,7 +61,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(registry);
|
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)
|
foreach (var registration in _registrations)
|
||||||
{
|
{
|
||||||
@ -65,11 +69,15 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
loadedTables.Add(await registration.LoadAsync(_rootPath, _deserializer, cancellationToken));
|
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>
|
/// <summary>
|
||||||
@ -96,11 +104,22 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
_deserializer,
|
_deserializer,
|
||||||
registry,
|
registry,
|
||||||
_registrations,
|
_registrations,
|
||||||
|
_lastSuccessfulDependencies,
|
||||||
onTableReloaded,
|
onTableReloaded,
|
||||||
onTableReloadFailed,
|
onTableReloadFailed,
|
||||||
debounceDelay ?? TimeSpan.FromMilliseconds(200));
|
debounceDelay ?? TimeSpan.FromMilliseconds(200));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdateLastSuccessfulDependencies(IEnumerable<YamlTableLoadResult> loadedTables)
|
||||||
|
{
|
||||||
|
_lastSuccessfulDependencies.Clear();
|
||||||
|
|
||||||
|
foreach (var loadedTable in loadedTables)
|
||||||
|
{
|
||||||
|
_lastSuccessfulDependencies[loadedTable.Name] = loadedTable.ReferencedTableNames;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 注册一个 YAML 配置表定义。
|
/// 注册一个 YAML 配置表定义。
|
||||||
/// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。
|
/// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。
|
||||||
@ -244,8 +263,8 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
/// <param name="rootPath">配置根目录。</param>
|
/// <param name="rootPath">配置根目录。</param>
|
||||||
/// <param name="deserializer">YAML 反序列化器。</param>
|
/// <param name="deserializer">YAML 反序列化器。</param>
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
/// <returns>已加载的配置表名称与配置表实例。</returns>
|
/// <returns>已加载的配置表结果。</returns>
|
||||||
Task<(string name, IConfigTable table)> LoadAsync(
|
Task<YamlTableLoadResult> LoadAsync(
|
||||||
string rootPath,
|
string rootPath,
|
||||||
IDeserializer deserializer,
|
IDeserializer deserializer,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
@ -300,7 +319,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
public string? SchemaRelativePath { get; }
|
public string? SchemaRelativePath { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<(string name, IConfigTable table)> LoadAsync(
|
public async Task<YamlTableLoadResult> LoadAsync(
|
||||||
string rootPath,
|
string rootPath,
|
||||||
IDeserializer deserializer,
|
IDeserializer deserializer,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@ -313,12 +332,15 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
}
|
}
|
||||||
|
|
||||||
YamlConfigSchema? schema = null;
|
YamlConfigSchema? schema = null;
|
||||||
|
IReadOnlyCollection<string> referencedTableNames = Array.Empty<string>();
|
||||||
if (!string.IsNullOrEmpty(SchemaRelativePath))
|
if (!string.IsNullOrEmpty(SchemaRelativePath))
|
||||||
{
|
{
|
||||||
var schemaPath = Path.Combine(rootPath, SchemaRelativePath);
|
var schemaPath = Path.Combine(rootPath, SchemaRelativePath);
|
||||||
schema = await YamlConfigSchemaValidator.LoadAsync(schemaPath, cancellationToken);
|
schema = await YamlConfigSchemaValidator.LoadAsync(schemaPath, cancellationToken);
|
||||||
|
referencedTableNames = schema.ReferencedTableNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var referenceUsages = new List<YamlConfigReferenceUsage>();
|
||||||
var values = new List<TValue>();
|
var values = new List<TValue>();
|
||||||
var files = Directory
|
var files = Directory
|
||||||
.EnumerateFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly)
|
.EnumerateFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly)
|
||||||
@ -346,8 +368,9 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
|
|
||||||
if (schema != null)
|
if (schema != null)
|
||||||
{
|
{
|
||||||
// 先按 schema 拒绝结构问题,避免被 IgnoreUnmatchedProperties 或默认值掩盖配置错误。
|
// 先按 schema 拒绝结构问题并提取跨表引用,避免被 IgnoreUnmatchedProperties 或默认值掩盖配置错误。
|
||||||
YamlConfigSchemaValidator.Validate(schema, file, yaml);
|
referenceUsages.AddRange(
|
||||||
|
YamlConfigSchemaValidator.ValidateAndCollectReferences(schema, file, yaml));
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@ -372,7 +395,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var table = new InMemoryConfigTable<TKey, TValue>(values, _keySelector, _comparer);
|
var table = new InMemoryConfigTable<TKey, TValue>(values, _keySelector, _comparer);
|
||||||
return (Name, table);
|
return new YamlTableLoadResult(Name, table, referencedTableNames, referenceUsages);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
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>
|
/// <summary>
|
||||||
/// 封装开发期热重载所需的文件监听与按表重载逻辑。
|
/// 封装开发期热重载所需的文件监听与按表重载逻辑。
|
||||||
/// 该会话只影响通过当前加载器注册的表,不尝试接管注册表中的其他来源数据。
|
/// 该会话只影响通过当前加载器注册的表,不尝试接管注册表中的其他来源数据。
|
||||||
@ -390,6 +644,10 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
private sealed class HotReloadSession : IUnRegister, IDisposable
|
private sealed class HotReloadSession : IUnRegister, IDisposable
|
||||||
{
|
{
|
||||||
private readonly TimeSpan _debounceDelay;
|
private readonly TimeSpan _debounceDelay;
|
||||||
|
|
||||||
|
private readonly Dictionary<string, IReadOnlyCollection<string>> _dependenciesByTable =
|
||||||
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
private readonly IDeserializer _deserializer;
|
private readonly IDeserializer _deserializer;
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly Action<string>? _onTableReloaded;
|
private readonly Action<string>? _onTableReloaded;
|
||||||
@ -409,6 +667,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
/// <param name="deserializer">YAML 反序列化器。</param>
|
/// <param name="deserializer">YAML 反序列化器。</param>
|
||||||
/// <param name="registry">要更新的配置注册表。</param>
|
/// <param name="registry">要更新的配置注册表。</param>
|
||||||
/// <param name="registrations">已注册的配置表定义。</param>
|
/// <param name="registrations">已注册的配置表定义。</param>
|
||||||
|
/// <param name="initialDependencies">最近一次成功加载后记录下来的跨表依赖图。</param>
|
||||||
/// <param name="onTableReloaded">单表重载成功回调。</param>
|
/// <param name="onTableReloaded">单表重载成功回调。</param>
|
||||||
/// <param name="onTableReloadFailed">单表重载失败回调。</param>
|
/// <param name="onTableReloadFailed">单表重载失败回调。</param>
|
||||||
/// <param name="debounceDelay">监听事件防抖延迟。</param>
|
/// <param name="debounceDelay">监听事件防抖延迟。</param>
|
||||||
@ -417,6 +676,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
IDeserializer deserializer,
|
IDeserializer deserializer,
|
||||||
IConfigRegistry registry,
|
IConfigRegistry registry,
|
||||||
IEnumerable<IYamlTableRegistration> registrations,
|
IEnumerable<IYamlTableRegistration> registrations,
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyCollection<string>> initialDependencies,
|
||||||
Action<string>? onTableReloaded,
|
Action<string>? onTableReloaded,
|
||||||
Action<string, Exception>? onTableReloadFailed,
|
Action<string, Exception>? onTableReloadFailed,
|
||||||
TimeSpan debounceDelay)
|
TimeSpan debounceDelay)
|
||||||
@ -425,6 +685,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
ArgumentNullException.ThrowIfNull(deserializer);
|
ArgumentNullException.ThrowIfNull(deserializer);
|
||||||
ArgumentNullException.ThrowIfNull(registry);
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
ArgumentNullException.ThrowIfNull(registrations);
|
ArgumentNullException.ThrowIfNull(registrations);
|
||||||
|
ArgumentNullException.ThrowIfNull(initialDependencies);
|
||||||
|
|
||||||
_rootPath = rootPath;
|
_rootPath = rootPath;
|
||||||
_deserializer = deserializer;
|
_deserializer = deserializer;
|
||||||
@ -437,6 +698,10 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
{
|
{
|
||||||
_registrations.Add(registration.Name, registration);
|
_registrations.Add(registration.Name, registration);
|
||||||
_reloadLocks.Add(registration.Name, new SemaphoreSlim(1, 1));
|
_reloadLocks.Add(registration.Name, new SemaphoreSlim(1, 1));
|
||||||
|
_dependenciesByTable[registration.Name] =
|
||||||
|
initialDependencies.TryGetValue(registration.Name, out var dependencies)
|
||||||
|
? dependencies
|
||||||
|
: Array.Empty<string>();
|
||||||
CreateWatchersForRegistration(registration);
|
CreateWatchersForRegistration(registration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -604,7 +869,7 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
|
|
||||||
private async Task ReloadTableAsync(string tableName, CancellationToken cancellationToken)
|
private async Task ReloadTableAsync(string tableName, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!_registrations.TryGetValue(tableName, out var registration))
|
if (!_registrations.ContainsKey(tableName))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -616,9 +881,26 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var (name, table) = await registration.LoadAsync(_rootPath, _deserializer, cancellationToken);
|
var affectedTableNames = GetAffectedTableNames(tableName);
|
||||||
RegistrationDispatcher.Register(_registry, name, table);
|
var loadedTables = new List<YamlTableLoadResult>(affectedTableNames.Count);
|
||||||
InvokeReloaded(name);
|
|
||||||
|
// 目标表变更可能让依赖它的表立即失效,因此热重载需要按受影响闭包整体重验并整体提交。
|
||||||
|
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)
|
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)
|
private void InvokeReloaded(string tableName)
|
||||||
{
|
{
|
||||||
if (_onTableReloaded == null)
|
if (_onTableReloaded == null)
|
||||||
|
|||||||
@ -86,7 +86,14 @@ internal static class YamlConfigSchemaValidator
|
|||||||
properties.Add(property.Name, ParseProperty(schemaPath, property));
|
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)
|
catch (JsonException exception)
|
||||||
{
|
{
|
||||||
@ -106,6 +113,24 @@ internal static class YamlConfigSchemaValidator
|
|||||||
YamlConfigSchema schema,
|
YamlConfigSchema schema,
|
||||||
string yamlPath,
|
string yamlPath,
|
||||||
string yamlText)
|
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(schema);
|
||||||
ArgumentNullException.ThrowIfNull(yamlPath);
|
ArgumentNullException.ThrowIfNull(yamlPath);
|
||||||
@ -131,6 +156,7 @@ internal static class YamlConfigSchemaValidator
|
|||||||
$"Config file '{yamlPath}' must contain a single root mapping object.");
|
$"Config file '{yamlPath}' must contain a single root mapping object.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var references = new List<YamlConfigReferenceUsage>();
|
||||||
var seenProperties = new HashSet<string>(StringComparer.Ordinal);
|
var seenProperties = new HashSet<string>(StringComparer.Ordinal);
|
||||||
foreach (var entry in rootMapping.Children)
|
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}'.");
|
$"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)
|
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}'.");
|
$"Config file '{yamlPath}' is missing required property '{requiredProperty}' defined by schema '{schema.SchemaPath}'.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return references;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static YamlConfigSchemaProperty ParseProperty(string schemaPath, JsonProperty property)
|
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}'.")
|
$"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)
|
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) ||
|
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}'.")
|
$"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(
|
private static void ValidateNode(
|
||||||
string yamlPath,
|
string yamlPath,
|
||||||
string propertyName,
|
string propertyName,
|
||||||
YamlNode node,
|
YamlNode node,
|
||||||
YamlConfigSchemaProperty property)
|
YamlConfigSchemaProperty property,
|
||||||
|
ICollection<YamlConfigReferenceUsage> references)
|
||||||
{
|
{
|
||||||
if (property.PropertyType == YamlConfigSchemaPropertyType.Array)
|
if (property.PropertyType == YamlConfigSchemaPropertyType.Array)
|
||||||
{
|
{
|
||||||
@ -230,15 +306,31 @@ internal static class YamlConfigSchemaValidator
|
|||||||
$"Property '{propertyName}' in config file '{yamlPath}' must be an array.");
|
$"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;
|
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(
|
private static void ValidateScalarNode(
|
||||||
@ -246,7 +338,10 @@ internal static class YamlConfigSchemaValidator
|
|||||||
string propertyName,
|
string propertyName,
|
||||||
YamlNode node,
|
YamlNode node,
|
||||||
YamlConfigSchemaPropertyType expectedType,
|
YamlConfigSchemaPropertyType expectedType,
|
||||||
bool isArrayItem)
|
string? referenceTableName,
|
||||||
|
ICollection<YamlConfigReferenceUsage> references,
|
||||||
|
bool isArrayItem,
|
||||||
|
int? itemIndex)
|
||||||
{
|
{
|
||||||
if (node is not YamlScalarNode scalarNode)
|
if (node is not YamlScalarNode scalarNode)
|
||||||
{
|
{
|
||||||
@ -287,6 +382,18 @@ internal static class YamlConfigSchemaValidator
|
|||||||
|
|
||||||
if (isValid)
|
if (isValid)
|
||||||
{
|
{
|
||||||
|
if (referenceTableName != null)
|
||||||
|
{
|
||||||
|
references.Add(
|
||||||
|
new YamlConfigReferenceUsage(
|
||||||
|
yamlPath,
|
||||||
|
propertyName,
|
||||||
|
itemIndex,
|
||||||
|
value,
|
||||||
|
referenceTableName,
|
||||||
|
expectedType));
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -336,18 +443,22 @@ internal sealed class YamlConfigSchema
|
|||||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||||
/// <param name="properties">Schema 属性定义。</param>
|
/// <param name="properties">Schema 属性定义。</param>
|
||||||
/// <param name="requiredProperties">必填属性集合。</param>
|
/// <param name="requiredProperties">必填属性集合。</param>
|
||||||
|
/// <param name="referencedTableNames">Schema 声明的目标引用表名称集合。</param>
|
||||||
public YamlConfigSchema(
|
public YamlConfigSchema(
|
||||||
string schemaPath,
|
string schemaPath,
|
||||||
IReadOnlyDictionary<string, YamlConfigSchemaProperty> properties,
|
IReadOnlyDictionary<string, YamlConfigSchemaProperty> properties,
|
||||||
IReadOnlyCollection<string> requiredProperties)
|
IReadOnlyCollection<string> requiredProperties,
|
||||||
|
IReadOnlyCollection<string> referencedTableNames)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(schemaPath);
|
ArgumentNullException.ThrowIfNull(schemaPath);
|
||||||
ArgumentNullException.ThrowIfNull(properties);
|
ArgumentNullException.ThrowIfNull(properties);
|
||||||
ArgumentNullException.ThrowIfNull(requiredProperties);
|
ArgumentNullException.ThrowIfNull(requiredProperties);
|
||||||
|
ArgumentNullException.ThrowIfNull(referencedTableNames);
|
||||||
|
|
||||||
SchemaPath = schemaPath;
|
SchemaPath = schemaPath;
|
||||||
Properties = properties;
|
Properties = properties;
|
||||||
RequiredProperties = requiredProperties;
|
RequiredProperties = requiredProperties;
|
||||||
|
ReferencedTableNames = referencedTableNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -364,6 +475,12 @@ internal sealed class YamlConfigSchema
|
|||||||
/// 获取 schema 声明的必填属性集合。
|
/// 获取 schema 声明的必填属性集合。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyCollection<string> RequiredProperties { get; }
|
public IReadOnlyCollection<string> RequiredProperties { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取 schema 声明的目标引用表名称集合。
|
||||||
|
/// 该信息用于热重载时推导受影响的依赖表闭包。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<string> ReferencedTableNames { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -377,16 +494,19 @@ internal sealed class YamlConfigSchemaProperty
|
|||||||
/// <param name="name">属性名称。</param>
|
/// <param name="name">属性名称。</param>
|
||||||
/// <param name="propertyType">属性类型。</param>
|
/// <param name="propertyType">属性类型。</param>
|
||||||
/// <param name="itemType">数组元素类型;仅当属性类型为数组时有效。</param>
|
/// <param name="itemType">数组元素类型;仅当属性类型为数组时有效。</param>
|
||||||
|
/// <param name="referenceTableName">目标引用表名称;未声明跨表引用时为空。</param>
|
||||||
public YamlConfigSchemaProperty(
|
public YamlConfigSchemaProperty(
|
||||||
string name,
|
string name,
|
||||||
YamlConfigSchemaPropertyType propertyType,
|
YamlConfigSchemaPropertyType propertyType,
|
||||||
YamlConfigSchemaPropertyType? itemType)
|
YamlConfigSchemaPropertyType? itemType,
|
||||||
|
string? referenceTableName)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(name);
|
ArgumentNullException.ThrowIfNull(name);
|
||||||
|
|
||||||
Name = name;
|
Name = name;
|
||||||
PropertyType = propertyType;
|
PropertyType = propertyType;
|
||||||
ItemType = itemType;
|
ItemType = itemType;
|
||||||
|
ReferenceTableName = referenceTableName;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -403,6 +523,83 @@ internal sealed class YamlConfigSchemaProperty
|
|||||||
/// 获取数组元素类型;非数组属性时返回空。
|
/// 获取数组元素类型;非数组属性时返回空。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public YamlConfigSchemaPropertyType? ItemType { get; }
|
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>
|
/// <summary>
|
||||||
|
|||||||
@ -134,6 +134,7 @@
|
|||||||
<AdditionalFiles Remove="AnalyzerReleases.Unshipped.md"/>
|
<AdditionalFiles Remove="AnalyzerReleases.Unshipped.md"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Folder Include="local-plan\docs\"/>
|
||||||
<Folder Include="local-plan\todos\"/>
|
<Folder Include="local-plan\todos\"/>
|
||||||
<Folder Include="local-plan\评估\"/>
|
<Folder Include="local-plan\评估\"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@ -91,6 +91,29 @@ var slime = monsterTable.Get(1);
|
|||||||
- 未在 schema 中声明的未知字段
|
- 未在 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` 静默吞掉。
|
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
|
||||||
|
|
||||||
@ -123,6 +146,7 @@ var hotReload = loader.EnableHotReload(
|
|||||||
- 监听已注册表对应的配置目录
|
- 监听已注册表对应的配置目录
|
||||||
- 监听该表绑定的 schema 文件
|
- 监听该表绑定的 schema 文件
|
||||||
- 检测到变更后按表粒度重载
|
- 检测到变更后按表粒度重载
|
||||||
|
- 若变更表被其他表通过跨表引用依赖,会联动重验受影响表
|
||||||
- 重载成功后替换该表在 `IConfigRegistry` 中的注册
|
- 重载成功后替换该表在 `IConfigRegistry` 中的注册
|
||||||
- 重载失败时保留旧表,并通过失败回调提供诊断
|
- 重载失败时保留旧表,并通过失败回调提供诊断
|
||||||
|
|
||||||
@ -152,7 +176,6 @@ var hotReload = loader.EnableHotReload(
|
|||||||
|
|
||||||
以下能力尚未完全完成:
|
以下能力尚未完全完成:
|
||||||
|
|
||||||
- 跨表引用校验
|
|
||||||
- 更完整的 JSON Schema 支持
|
- 更完整的 JSON Schema 支持
|
||||||
- 更强的 VS Code 嵌套对象与复杂数组编辑器
|
- 更强的 VS Code 嵌套对象与复杂数组编辑器
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user