diff --git a/GFramework.Game.Abstractions/Config/IConfigRegistry.cs b/GFramework.Game.Abstractions/Config/IConfigRegistry.cs index d40c918..07d98d6 100644 --- a/GFramework.Game.Abstractions/Config/IConfigRegistry.cs +++ b/GFramework.Game.Abstractions/Config/IConfigRegistry.cs @@ -54,6 +54,15 @@ public interface IConfigRegistry : IUtility bool TryGetTable(string name, out IConfigTable? table) where TKey : notnull; + /// + /// 尝试获取指定名称的原始配置表。 + /// 该入口用于跨表校验或诊断场景,以便在不知道泛型参数时仍能访问表元数据。 + /// + /// 配置表名称。 + /// 匹配的原始配置表;未找到时返回空。 + /// 找到配置表时返回 true,否则返回 false + bool TryGetTable(string name, out IConfigTable? table); + /// /// 检查指定名称的配置表是否存在。 /// diff --git a/GFramework.Game.Tests/Config/ConfigRegistryTests.cs b/GFramework.Game.Tests/Config/ConfigRegistryTests.cs index 437e968..8ae8eef 100644 --- a/GFramework.Game.Tests/Config/ConfigRegistryTests.cs +++ b/GFramework.Game.Tests/Config/ConfigRegistryTests.cs @@ -61,6 +61,26 @@ public class ConfigRegistryTests Assert.Throws(() => registry.GetTable("monster")); } + /// + /// 验证弱类型查询入口可以在不知道泛型参数时返回原始配置表。 + /// + [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))); + }); + } + /// /// 验证移除和清空操作会更新注册表状态。 /// diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 236bf34..cf6d68c 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -331,6 +331,212 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证绑定跨表引用 schema 时,存在的目标行可以通过加载校验。 + /// + [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("item", "item", "schemas/item.schema.json", + static config => config.Id) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + Assert.Multiple(() => + { + Assert.That(registry.GetTable("item").ContainsKey("potion"), Is.True); + Assert.That(registry.GetTable("monster").Get(1).DropItemId, + Is.EqualTo("potion")); + }); + } + + /// + /// 验证缺失的跨表引用会阻止整批配置写入注册表。 + /// + [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("item", "item", "schemas/item.schema.json", + static config => config.Id) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(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)); + }); + } + + /// + /// 验证跨表引用同样支持标量数组中的每个元素。 + /// + [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("item", "item", "schemas/item.schema.json", + static config => config.Id) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(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)); + }); + } + /// /// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。 /// @@ -468,6 +674,97 @@ public class YamlConfigLoaderTests } } + /// + /// 验证当被引用表变更导致依赖表引用失效时,热重载会整体回滚受影响表。 + /// + [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("item", "item", "schemas/item.schema.json", + static config => config.Id) + .RegisterTable("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("item").ContainsKey("potion"), Is.True); + Assert.That(registry.GetTable("monster").Get(1).DropItemId, + Is.EqualTo("potion")); + }); + } + finally + { + hotReload.UnRegister(); + } + } + /// /// 创建测试用配置文件。 /// @@ -555,6 +852,64 @@ public class YamlConfigLoaderTests public IReadOnlyList DropRates { get; set; } = Array.Empty(); } + /// + /// 用于跨表引用测试的最小物品配置类型。 + /// + private sealed class ItemConfigStub + { + /// + /// 获取或设置主键。 + /// + public string Id { get; set; } = string.Empty; + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + } + + /// + /// 用于单值跨表引用测试的怪物配置类型。 + /// + private sealed class MonsterDropConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 获取或设置掉落物品主键。 + /// + public string DropItemId { get; set; } = string.Empty; + } + + /// + /// 用于数组跨表引用测试的怪物配置类型。 + /// + private sealed class MonsterDropArrayConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 获取或设置掉落物品主键列表。 + /// + public List DropItemIds { get; set; } = new(); + } + /// /// 用于验证注册表一致性的现有配置类型。 /// diff --git a/GFramework.Game/Config/ConfigRegistry.cs b/GFramework.Game/Config/ConfigRegistry.cs index db80be1..a637dff 100644 --- a/GFramework.Game/Config/ConfigRegistry.cs +++ b/GFramework.Game/Config/ConfigRegistry.cs @@ -120,6 +120,25 @@ public sealed class ConfigRegistry : IConfigRegistry return true; } + /// + /// 尝试根据名称获取原始配置表。 + /// + /// 要查找的配置表名称。 + /// 输出参数,如果查找成功则返回原始配置表实例,否则为 null。 + /// 如果找到指定名称的配置表则返回 true,否则返回 false。 + /// 为 null、空或仅包含空白字符时抛出。 + 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); + } + /// /// 检查指定名称的配置表是否已注册。 /// diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index 6570035..3ea6cdb 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -20,6 +20,10 @@ public sealed class YamlConfigLoader : IConfigLoader "Schema relative path cannot be null or whitespace."; private readonly IDeserializer _deserializer; + + private readonly Dictionary> _lastSuccessfulDependencies = + new(StringComparer.Ordinal); + private readonly List _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(_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); } /// @@ -96,11 +104,22 @@ public sealed class YamlConfigLoader : IConfigLoader _deserializer, registry, _registrations, + _lastSuccessfulDependencies, onTableReloaded, onTableReloadFailed, debounceDelay ?? TimeSpan.FromMilliseconds(200)); } + private void UpdateLastSuccessfulDependencies(IEnumerable loadedTables) + { + _lastSuccessfulDependencies.Clear(); + + foreach (var loadedTable in loadedTables) + { + _lastSuccessfulDependencies[loadedTable.Name] = loadedTable.ReferencedTableNames; + } + } + /// /// 注册一个 YAML 配置表定义。 /// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。 @@ -244,8 +263,8 @@ public sealed class YamlConfigLoader : IConfigLoader /// 配置根目录。 /// YAML 反序列化器。 /// 取消令牌。 - /// 已加载的配置表名称与配置表实例。 - Task<(string name, IConfigTable table)> LoadAsync( + /// 已加载的配置表结果。 + Task LoadAsync( string rootPath, IDeserializer deserializer, CancellationToken cancellationToken); @@ -300,7 +319,7 @@ public sealed class YamlConfigLoader : IConfigLoader public string? SchemaRelativePath { get; } /// - public async Task<(string name, IConfigTable table)> LoadAsync( + public async Task LoadAsync( string rootPath, IDeserializer deserializer, CancellationToken cancellationToken) @@ -313,12 +332,15 @@ public sealed class YamlConfigLoader : IConfigLoader } YamlConfigSchema? schema = null; + IReadOnlyCollection referencedTableNames = Array.Empty(); if (!string.IsNullOrEmpty(SchemaRelativePath)) { var schemaPath = Path.Combine(rootPath, SchemaRelativePath); schema = await YamlConfigSchemaValidator.LoadAsync(schemaPath, cancellationToken); + referencedTableNames = schema.ReferencedTableNames; } + var referenceUsages = new List(); var values = new List(); 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(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 } } + /// + /// 表示单个注册项加载完成后的中间结果。 + /// 该结果同时携带配置表实例、schema 声明的依赖关系和 YAML 中提取出的实际引用,以便在批量提交前完成跨表一致性校验。 + /// + private sealed class YamlTableLoadResult + { + /// + /// 初始化一个表加载结果。 + /// + /// 配置表名称。 + /// 已构建好的配置表。 + /// schema 声明的依赖表名称集合。 + /// YAML 中提取出的实际引用集合。 + public YamlTableLoadResult( + string name, + IConfigTable table, + IReadOnlyCollection referencedTableNames, + IReadOnlyCollection referenceUsages) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(table); + ArgumentNullException.ThrowIfNull(referencedTableNames); + ArgumentNullException.ThrowIfNull(referenceUsages); + + Name = name; + Table = table; + ReferencedTableNames = referencedTableNames; + ReferenceUsages = referenceUsages; + } + + /// + /// 获取配置表名称。 + /// + public string Name { get; } + + /// + /// 获取已构建好的配置表。 + /// + public IConfigTable Table { get; } + + /// + /// 获取 schema 声明的依赖表名称集合。 + /// + public IReadOnlyCollection ReferencedTableNames { get; } + + /// + /// 获取 YAML 中提取出的实际引用集合。 + /// + public IReadOnlyCollection ReferenceUsages { get; } + } + + /// + /// 负责在所有注册项加载完成后执行跨表引用校验。 + /// 该阶段在真正写入注册表之前运行,确保任何缺失目标表、主键类型不兼容或目标行不存在的情况都会整体回滚。 + /// + private static class CrossTableReferenceValidator + { + /// + /// 使用本轮新加载结果与注册表中保留的旧表,一起验证跨表引用是否全部有效。 + /// + /// 当前配置注册表。 + /// 本轮加载出的配置表集合。 + public static void Validate(IConfigRegistry registry, IReadOnlyCollection 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 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.ContainsKey))!; + return (bool)containsKeyMethod.Invoke(table, new[] { key })!; + } + } + /// /// 封装开发期热重载所需的文件监听与按表重载逻辑。 /// 该会话只影响通过当前加载器注册的表,不尝试接管注册表中的其他来源数据。 @@ -390,6 +644,10 @@ public sealed class YamlConfigLoader : IConfigLoader private sealed class HotReloadSession : IUnRegister, IDisposable { private readonly TimeSpan _debounceDelay; + + private readonly Dictionary> _dependenciesByTable = + new(StringComparer.Ordinal); + private readonly IDeserializer _deserializer; private readonly object _gate = new(); private readonly Action? _onTableReloaded; @@ -409,6 +667,7 @@ public sealed class YamlConfigLoader : IConfigLoader /// YAML 反序列化器。 /// 要更新的配置注册表。 /// 已注册的配置表定义。 + /// 最近一次成功加载后记录下来的跨表依赖图。 /// 单表重载成功回调。 /// 单表重载失败回调。 /// 监听事件防抖延迟。 @@ -417,6 +676,7 @@ public sealed class YamlConfigLoader : IConfigLoader IDeserializer deserializer, IConfigRegistry registry, IEnumerable registrations, + IReadOnlyDictionary> initialDependencies, Action? onTableReloaded, Action? 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(); 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(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 GetAffectedTableNames(string changedTableName) + { + var affectedTableNames = new HashSet(StringComparer.Ordinal) + { + changedTableName + }; + var pendingTableNames = new Queue(); + 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) diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index bc16016..3519325 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -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() + .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); + } + + /// + /// 使用已解析的 schema 校验 YAML 文本,并提取声明过的跨表引用。 + /// 该方法让结构校验与引用采集共享同一份 YAML 解析结果,避免加载器重复解析同一文件。 + /// + /// 已解析的 schema 模型。 + /// YAML 文件路径,仅用于诊断信息。 + /// YAML 文本内容。 + /// 当前 YAML 文件中声明的跨表引用集合。 + /// 当参数为空时抛出。 + /// 当 YAML 内容与 schema 不匹配时抛出。 + internal static IReadOnlyList 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(); var seenProperties = new HashSet(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 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 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 /// Schema 文件路径。 /// Schema 属性定义。 /// 必填属性集合。 + /// Schema 声明的目标引用表名称集合。 public YamlConfigSchema( string schemaPath, IReadOnlyDictionary properties, - IReadOnlyCollection requiredProperties) + IReadOnlyCollection requiredProperties, + IReadOnlyCollection referencedTableNames) { ArgumentNullException.ThrowIfNull(schemaPath); ArgumentNullException.ThrowIfNull(properties); ArgumentNullException.ThrowIfNull(requiredProperties); + ArgumentNullException.ThrowIfNull(referencedTableNames); SchemaPath = schemaPath; Properties = properties; RequiredProperties = requiredProperties; + ReferencedTableNames = referencedTableNames; } /// @@ -364,6 +475,12 @@ internal sealed class YamlConfigSchema /// 获取 schema 声明的必填属性集合。 /// public IReadOnlyCollection RequiredProperties { get; } + + /// + /// 获取 schema 声明的目标引用表名称集合。 + /// 该信息用于热重载时推导受影响的依赖表闭包。 + /// + public IReadOnlyCollection ReferencedTableNames { get; } } /// @@ -377,16 +494,19 @@ internal sealed class YamlConfigSchemaProperty /// 属性名称。 /// 属性类型。 /// 数组元素类型;仅当属性类型为数组时有效。 + /// 目标引用表名称;未声明跨表引用时为空。 public YamlConfigSchemaProperty( string name, YamlConfigSchemaPropertyType propertyType, - YamlConfigSchemaPropertyType? itemType) + YamlConfigSchemaPropertyType? itemType, + string? referenceTableName) { ArgumentNullException.ThrowIfNull(name); Name = name; PropertyType = propertyType; ItemType = itemType; + ReferenceTableName = referenceTableName; } /// @@ -403,6 +523,83 @@ internal sealed class YamlConfigSchemaProperty /// 获取数组元素类型;非数组属性时返回空。 /// public YamlConfigSchemaPropertyType? ItemType { get; } + + /// + /// 获取目标引用表名称;未声明跨表引用时返回空。 + /// + public string? ReferenceTableName { get; } +} + +/// +/// 表示单个 YAML 文件中提取出的跨表引用。 +/// 该模型保留源文件、字段路径和目标表等诊断信息,以便加载器在批量校验失败时给出可定位的错误。 +/// +internal sealed class YamlConfigReferenceUsage +{ + /// + /// 初始化一个跨表引用使用记录。 + /// + /// 源 YAML 文件路径。 + /// 声明引用的属性名。 + /// 数组元素索引;标量属性时为空。 + /// YAML 中的原始标量值。 + /// 目标配置表名称。 + /// 引用值的 schema 标量类型。 + 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; + } + + /// + /// 获取源 YAML 文件路径。 + /// + public string YamlPath { get; } + + /// + /// 获取声明引用的属性名。 + /// + public string PropertyName { get; } + + /// + /// 获取数组元素索引;标量属性时返回空。 + /// + public int? ItemIndex { get; } + + /// + /// 获取 YAML 中的原始标量值。 + /// + public string RawValue { get; } + + /// + /// 获取目标配置表名称。 + /// + public string ReferencedTableName { get; } + + /// + /// 获取引用值的 schema 标量类型。 + /// + public YamlConfigSchemaPropertyType ValueType { get; } + + /// + /// 获取便于诊断显示的字段路径。 + /// + public string DisplayPath => ItemIndex.HasValue ? $"{PropertyName}[{ItemIndex.Value}]" : PropertyName; } /// diff --git a/GFramework.csproj b/GFramework.csproj index e1d069a..6221359 100644 --- a/GFramework.csproj +++ b/GFramework.csproj @@ -134,6 +134,7 @@ + diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 83a5567..f327480 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -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 嵌套对象与复杂数组编辑器