feat(game): 添加基于YAML的游戏内容配置系统

- 实现YamlConfigLoader支持从YAML文件加载配置数据
- 提供ConfigRegistry用于统一管理命名的配置表
- 支持JSON Schema校验配置结构和类型匹配
- 实现跨表引用校验避免无效引用和缺失依赖
- 提供开发期热重载功能监听文件变更自动刷新
- 支持一对象一文件的目录组织方式
- 集成VS Code插件提供配置浏览和轻量校验
- 生成器支持从schema自动生成配置类型定义
- 文档说明配置系统的使用方法和推荐目录结构
This commit is contained in:
GeWuYou 2026-04-01 09:04:28 +08:00
parent 0c662ced2a
commit 15761c6677
8 changed files with 963 additions and 25 deletions

View File

@ -54,6 +54,15 @@ public interface IConfigRegistry : IUtility
bool TryGetTable<TKey, TValue>(string name, out IConfigTable<TKey, TValue>? table)
where TKey : notnull;
/// <summary>
/// 尝试获取指定名称的原始配置表。
/// 该入口用于跨表校验或诊断场景,以便在不知道泛型参数时仍能访问表元数据。
/// </summary>
/// <param name="name">配置表名称。</param>
/// <param name="table">匹配的原始配置表;未找到时返回空。</param>
/// <returns>找到配置表时返回 <c>true</c>,否则返回 <c>false</c>。</returns>
bool TryGetTable(string name, out IConfigTable? table);
/// <summary>
/// 检查指定名称的配置表是否存在。
/// </summary>

View File

@ -61,6 +61,26 @@ public class ConfigRegistryTests
Assert.Throws<InvalidOperationException>(() => registry.GetTable<string, MonsterConfigStub>("monster"));
}
/// <summary>
/// 验证弱类型查询入口可以在不知道泛型参数时返回原始配置表。
/// </summary>
[Test]
public void TryGetTable_Should_Return_Raw_Table_When_Name_Exists()
{
var registry = new ConfigRegistry();
var table = CreateMonsterTable();
registry.RegisterTable("monster", table);
var found = registry.TryGetTable("monster", out var rawTable);
Assert.Multiple(() =>
{
Assert.That(found, Is.True);
Assert.That(rawTable, Is.SameAs(table));
Assert.That(rawTable!.KeyType, Is.EqualTo(typeof(int)));
});
}
/// <summary>
/// 验证移除和清空操作会更新注册表状态。
/// </summary>

View File

@ -331,6 +331,212 @@ public class YamlConfigLoaderTests
});
}
/// <summary>
/// 验证绑定跨表引用 schema 时,存在的目标行可以通过加载校验。
/// </summary>
[Test]
public async Task LoadAsync_Should_Accept_Existing_Cross_Table_Reference()
{
CreateConfigFile(
"item/potion.yaml",
"""
id: potion
name: Potion
""");
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
dropItemId: potion
""");
CreateSchemaFile(
"schemas/item.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
}
}
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "dropItemId"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"dropItemId": {
"type": "string",
"x-gframework-ref-table": "item"
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<string, ItemConfigStub>("item", "item", "schemas/item.schema.json",
static config => config.Id)
.RegisterTable<int, MonsterDropConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
await loader.LoadAsync(registry);
Assert.Multiple(() =>
{
Assert.That(registry.GetTable<string, ItemConfigStub>("item").ContainsKey("potion"), Is.True);
Assert.That(registry.GetTable<int, MonsterDropConfigStub>("monster").Get(1).DropItemId,
Is.EqualTo("potion"));
});
}
/// <summary>
/// 验证缺失的跨表引用会阻止整批配置写入注册表。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Cross_Table_Reference_Target_Is_Missing()
{
CreateConfigFile(
"item/slime-gel.yaml",
"""
id: slime_gel
name: Slime Gel
""");
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
dropItemId: potion
""");
CreateSchemaFile(
"schemas/item.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
}
}
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "dropItemId"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"dropItemId": {
"type": "string",
"x-gframework-ref-table": "item"
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<string, ItemConfigStub>("item", "item", "schemas/item.schema.json",
static config => config.Id)
.RegisterTable<int, MonsterDropConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("dropItemId"));
Assert.That(exception!.Message, Does.Contain("potion"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证跨表引用同样支持标量数组中的每个元素。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Array_Reference_Item_Is_Missing()
{
CreateConfigFile(
"item/potion.yaml",
"""
id: potion
name: Potion
""");
CreateConfigFile(
"item/slime-gel.yaml",
"""
id: slime_gel
name: Slime Gel
""");
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
dropItemIds:
- potion
- missing_item
""");
CreateSchemaFile(
"schemas/item.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
}
}
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "dropItemIds"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"dropItemIds": {
"type": "array",
"items": { "type": "string" },
"x-gframework-ref-table": "item"
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<string, ItemConfigStub>("item", "item", "schemas/item.schema.json",
static config => config.Id)
.RegisterTable<int, MonsterDropArrayConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("dropItemIds[1]"));
Assert.That(exception!.Message, Does.Contain("missing_item"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。
/// </summary>
@ -468,6 +674,97 @@ public class YamlConfigLoaderTests
}
}
/// <summary>
/// 验证当被引用表变更导致依赖表引用失效时,热重载会整体回滚受影响表。
/// </summary>
[Test]
public async Task EnableHotReload_Should_Keep_Previous_State_When_Dependency_Table_Breaks_Cross_Table_Reference()
{
CreateConfigFile(
"item/potion.yaml",
"""
id: potion
name: Potion
""");
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
dropItemId: potion
""");
CreateSchemaFile(
"schemas/item.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
}
}
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "dropItemId"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"dropItemId": {
"type": "string",
"x-gframework-ref-table": "item"
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<string, ItemConfigStub>("item", "item", "schemas/item.schema.json",
static config => config.Id)
.RegisterTable<int, MonsterDropConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
await loader.LoadAsync(registry);
var reloadFailureTaskSource =
new TaskCompletionSource<(string TableName, Exception Exception)>(TaskCreationOptions
.RunContinuationsAsynchronously);
var hotReload = loader.EnableHotReload(
registry,
onTableReloadFailed: (tableName, exception) =>
reloadFailureTaskSource.TrySetResult((tableName, exception)),
debounceDelay: TimeSpan.FromMilliseconds(150));
try
{
CreateConfigFile(
"item/potion.yaml",
"""
id: elixir
name: Elixir
""");
var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5));
Assert.Multiple(() =>
{
Assert.That(failure.TableName, Is.EqualTo("item"));
Assert.That(failure.Exception.Message, Does.Contain("dropItemId"));
Assert.That(registry.GetTable<string, ItemConfigStub>("item").ContainsKey("potion"), Is.True);
Assert.That(registry.GetTable<int, MonsterDropConfigStub>("monster").Get(1).DropItemId,
Is.EqualTo("potion"));
});
}
finally
{
hotReload.UnRegister();
}
}
/// <summary>
/// 创建测试用配置文件。
/// </summary>
@ -555,6 +852,64 @@ public class YamlConfigLoaderTests
public IReadOnlyList<int> DropRates { get; set; } = Array.Empty<int>();
}
/// <summary>
/// 用于跨表引用测试的最小物品配置类型。
/// </summary>
private sealed class ItemConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// 获取或设置名称。
/// </summary>
public string Name { get; set; } = string.Empty;
}
/// <summary>
/// 用于单值跨表引用测试的怪物配置类型。
/// </summary>
private sealed class MonsterDropConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 获取或设置掉落物品主键。
/// </summary>
public string DropItemId { get; set; } = string.Empty;
}
/// <summary>
/// 用于数组跨表引用测试的怪物配置类型。
/// </summary>
private sealed class MonsterDropArrayConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 获取或设置掉落物品主键列表。
/// </summary>
public List<string> DropItemIds { get; set; } = new();
}
/// <summary>
/// 用于验证注册表一致性的现有配置类型。
/// </summary>

View File

@ -120,6 +120,25 @@ public sealed class ConfigRegistry : IConfigRegistry
return true;
}
/// <summary>
/// 尝试根据名称获取原始配置表。
/// </summary>
/// <param name="name">要查找的配置表名称。</param>
/// <param name="table">输出参数,如果查找成功则返回原始配置表实例,否则为 null。</param>
/// <returns>如果找到指定名称的配置表则返回 true否则返回 false。</returns>
/// <exception cref="ArgumentException">当 <paramref name="name" /> 为 null、空或仅包含空白字符时抛出。</exception>
public bool TryGetTable(string name, out IConfigTable? table)
{
table = default;
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException(NameCannotBeNullOrWhiteSpaceMessage, nameof(name));
}
return _tables.TryGetValue(name, out table);
}
/// <summary>
/// 检查指定名称的配置表是否已注册。
/// </summary>

View File

@ -20,6 +20,10 @@ public sealed class YamlConfigLoader : IConfigLoader
"Schema relative path cannot be null or whitespace.";
private readonly IDeserializer _deserializer;
private readonly Dictionary<string, IReadOnlyCollection<string>> _lastSuccessfulDependencies =
new(StringComparer.Ordinal);
private readonly List<IYamlTableRegistration> _registrations = new();
private readonly string _rootPath;
@ -57,7 +61,7 @@ public sealed class YamlConfigLoader : IConfigLoader
{
ArgumentNullException.ThrowIfNull(registry);
var loadedTables = new List<(string name, IConfigTable table)>(_registrations.Count);
var loadedTables = new List<YamlTableLoadResult>(_registrations.Count);
foreach (var registration in _registrations)
{
@ -65,11 +69,15 @@ public sealed class YamlConfigLoader : IConfigLoader
loadedTables.Add(await registration.LoadAsync(_rootPath, _deserializer, cancellationToken));
}
CrossTableReferenceValidator.Validate(registry, loadedTables);
// 仅当本轮所有配置表都成功加载后才写入注册表,避免暴露部分成功的中间状态。
foreach (var (name, table) in loadedTables)
foreach (var loadedTable in loadedTables)
{
RegistrationDispatcher.Register(registry, name, table);
RegistrationDispatcher.Register(registry, loadedTable.Name, loadedTable.Table);
}
UpdateLastSuccessfulDependencies(loadedTables);
}
/// <summary>
@ -96,11 +104,22 @@ public sealed class YamlConfigLoader : IConfigLoader
_deserializer,
registry,
_registrations,
_lastSuccessfulDependencies,
onTableReloaded,
onTableReloadFailed,
debounceDelay ?? TimeSpan.FromMilliseconds(200));
}
private void UpdateLastSuccessfulDependencies(IEnumerable<YamlTableLoadResult> loadedTables)
{
_lastSuccessfulDependencies.Clear();
foreach (var loadedTable in loadedTables)
{
_lastSuccessfulDependencies[loadedTable.Name] = loadedTable.ReferencedTableNames;
}
}
/// <summary>
/// 注册一个 YAML 配置表定义。
/// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。
@ -244,8 +263,8 @@ public sealed class YamlConfigLoader : IConfigLoader
/// <param name="rootPath">配置根目录。</param>
/// <param name="deserializer">YAML 反序列化器。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>已加载的配置表名称与配置表实例。</returns>
Task<(string name, IConfigTable table)> LoadAsync(
/// <returns>已加载的配置表结果。</returns>
Task<YamlTableLoadResult> LoadAsync(
string rootPath,
IDeserializer deserializer,
CancellationToken cancellationToken);
@ -300,7 +319,7 @@ public sealed class YamlConfigLoader : IConfigLoader
public string? SchemaRelativePath { get; }
/// <inheritdoc />
public async Task<(string name, IConfigTable table)> LoadAsync(
public async Task<YamlTableLoadResult> LoadAsync(
string rootPath,
IDeserializer deserializer,
CancellationToken cancellationToken)
@ -313,12 +332,15 @@ public sealed class YamlConfigLoader : IConfigLoader
}
YamlConfigSchema? schema = null;
IReadOnlyCollection<string> referencedTableNames = Array.Empty<string>();
if (!string.IsNullOrEmpty(SchemaRelativePath))
{
var schemaPath = Path.Combine(rootPath, SchemaRelativePath);
schema = await YamlConfigSchemaValidator.LoadAsync(schemaPath, cancellationToken);
referencedTableNames = schema.ReferencedTableNames;
}
var referenceUsages = new List<YamlConfigReferenceUsage>();
var values = new List<TValue>();
var files = Directory
.EnumerateFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly)
@ -346,8 +368,9 @@ public sealed class YamlConfigLoader : IConfigLoader
if (schema != null)
{
// 先按 schema 拒绝结构问题,避免被 IgnoreUnmatchedProperties 或默认值掩盖配置错误。
YamlConfigSchemaValidator.Validate(schema, file, yaml);
// 先按 schema 拒绝结构问题并提取跨表引用,避免被 IgnoreUnmatchedProperties 或默认值掩盖配置错误。
referenceUsages.AddRange(
YamlConfigSchemaValidator.ValidateAndCollectReferences(schema, file, yaml));
}
try
@ -372,7 +395,7 @@ public sealed class YamlConfigLoader : IConfigLoader
try
{
var table = new InMemoryConfigTable<TKey, TValue>(values, _keySelector, _comparer);
return (Name, table);
return new YamlTableLoadResult(Name, table, referencedTableNames, referenceUsages);
}
catch (Exception exception)
{
@ -383,6 +406,237 @@ public sealed class YamlConfigLoader : IConfigLoader
}
}
/// <summary>
/// 表示单个注册项加载完成后的中间结果。
/// 该结果同时携带配置表实例、schema 声明的依赖关系和 YAML 中提取出的实际引用,以便在批量提交前完成跨表一致性校验。
/// </summary>
private sealed class YamlTableLoadResult
{
/// <summary>
/// 初始化一个表加载结果。
/// </summary>
/// <param name="name">配置表名称。</param>
/// <param name="table">已构建好的配置表。</param>
/// <param name="referencedTableNames">schema 声明的依赖表名称集合。</param>
/// <param name="referenceUsages">YAML 中提取出的实际引用集合。</param>
public YamlTableLoadResult(
string name,
IConfigTable table,
IReadOnlyCollection<string> referencedTableNames,
IReadOnlyCollection<YamlConfigReferenceUsage> referenceUsages)
{
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(table);
ArgumentNullException.ThrowIfNull(referencedTableNames);
ArgumentNullException.ThrowIfNull(referenceUsages);
Name = name;
Table = table;
ReferencedTableNames = referencedTableNames;
ReferenceUsages = referenceUsages;
}
/// <summary>
/// 获取配置表名称。
/// </summary>
public string Name { get; }
/// <summary>
/// 获取已构建好的配置表。
/// </summary>
public IConfigTable Table { get; }
/// <summary>
/// 获取 schema 声明的依赖表名称集合。
/// </summary>
public IReadOnlyCollection<string> ReferencedTableNames { get; }
/// <summary>
/// 获取 YAML 中提取出的实际引用集合。
/// </summary>
public IReadOnlyCollection<YamlConfigReferenceUsage> ReferenceUsages { get; }
}
/// <summary>
/// 负责在所有注册项加载完成后执行跨表引用校验。
/// 该阶段在真正写入注册表之前运行,确保任何缺失目标表、主键类型不兼容或目标行不存在的情况都会整体回滚。
/// </summary>
private static class CrossTableReferenceValidator
{
/// <summary>
/// 使用本轮新加载结果与注册表中保留的旧表,一起验证跨表引用是否全部有效。
/// </summary>
/// <param name="registry">当前配置注册表。</param>
/// <param name="loadedTables">本轮加载出的配置表集合。</param>
public static void Validate(IConfigRegistry registry, IReadOnlyCollection<YamlTableLoadResult> loadedTables)
{
ArgumentNullException.ThrowIfNull(registry);
ArgumentNullException.ThrowIfNull(loadedTables);
var loadedTableLookup = loadedTables.ToDictionary(static table => table.Name, StringComparer.Ordinal);
foreach (var loadedTable in loadedTables)
{
foreach (var referenceUsage in loadedTable.ReferenceUsages)
{
if (!TryResolveTargetTable(registry, loadedTableLookup, referenceUsage.ReferencedTableName,
out var targetTable))
{
throw new InvalidOperationException(
$"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' references table '{referenceUsage.ReferencedTableName}', but that table is not available in the current loader batch or registry.");
}
if (!TryConvertReferenceKey(referenceUsage, targetTable.KeyType, out var convertedKey,
out var conversionError))
{
throw new InvalidOperationException(
$"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' cannot target table '{referenceUsage.ReferencedTableName}' with key type '{targetTable.KeyType.Name}'. {conversionError}");
}
if (!ContainsKey(targetTable, convertedKey!))
{
throw new InvalidOperationException(
$"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' references missing key '{referenceUsage.RawValue}' in table '{referenceUsage.ReferencedTableName}'.");
}
}
}
}
private static bool TryResolveTargetTable(
IConfigRegistry registry,
IReadOnlyDictionary<string, YamlTableLoadResult> loadedTableLookup,
string tableName,
out IConfigTable table)
{
if (loadedTableLookup.TryGetValue(tableName, out var loadedTable))
{
table = loadedTable.Table;
return true;
}
if (registry.TryGetTable(tableName, out var registeredTable) && registeredTable != null)
{
table = registeredTable;
return true;
}
table = null!;
return false;
}
private static bool TryConvertReferenceKey(
YamlConfigReferenceUsage referenceUsage,
Type targetKeyType,
out object? convertedKey,
out string errorMessage)
{
convertedKey = null;
errorMessage = string.Empty;
if (referenceUsage.ValueType == YamlConfigSchemaPropertyType.String)
{
if (targetKeyType != typeof(string))
{
errorMessage =
$"Reference values declared as schema type 'string' can currently only target string-key tables, but the target key type is '{targetKeyType.Name}'.";
return false;
}
convertedKey = referenceUsage.RawValue;
return true;
}
if (referenceUsage.ValueType != YamlConfigSchemaPropertyType.Integer)
{
errorMessage =
$"Reference values currently only support schema scalar types 'string' and 'integer', but the actual type is '{referenceUsage.ValueType}'.";
return false;
}
return TryConvertIntegerKey(referenceUsage.RawValue, targetKeyType, out convertedKey, out errorMessage);
}
private static bool TryConvertIntegerKey(
string rawValue,
Type targetKeyType,
out object? convertedKey,
out string errorMessage)
{
convertedKey = null;
errorMessage = string.Empty;
if (targetKeyType == typeof(int) &&
int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
{
convertedKey = intValue;
return true;
}
if (targetKeyType == typeof(long) &&
long.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longValue))
{
convertedKey = longValue;
return true;
}
if (targetKeyType == typeof(short) &&
short.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var shortValue))
{
convertedKey = shortValue;
return true;
}
if (targetKeyType == typeof(byte) &&
byte.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var byteValue))
{
convertedKey = byteValue;
return true;
}
if (targetKeyType == typeof(uint) &&
uint.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var uintValue))
{
convertedKey = uintValue;
return true;
}
if (targetKeyType == typeof(ulong) &&
ulong.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ulongValue))
{
convertedKey = ulongValue;
return true;
}
if (targetKeyType == typeof(ushort) &&
ushort.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ushortValue))
{
convertedKey = ushortValue;
return true;
}
if (targetKeyType == typeof(sbyte) &&
sbyte.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var sbyteValue))
{
convertedKey = sbyteValue;
return true;
}
errorMessage =
$"Reference value '{rawValue}' cannot be converted to supported target key type '{targetKeyType.Name}'. Integer references currently support the standard signed and unsigned integer CLR key types.";
return false;
}
private static bool ContainsKey(IConfigTable table, object key)
{
var tableInterface = table.GetType()
.GetInterfaces()
.First(static type =>
type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IConfigTable<,>));
var containsKeyMethod = tableInterface.GetMethod(nameof(IConfigTable<int, int>.ContainsKey))!;
return (bool)containsKeyMethod.Invoke(table, new[] { key })!;
}
}
/// <summary>
/// 封装开发期热重载所需的文件监听与按表重载逻辑。
/// 该会话只影响通过当前加载器注册的表,不尝试接管注册表中的其他来源数据。
@ -390,6 +644,10 @@ public sealed class YamlConfigLoader : IConfigLoader
private sealed class HotReloadSession : IUnRegister, IDisposable
{
private readonly TimeSpan _debounceDelay;
private readonly Dictionary<string, IReadOnlyCollection<string>> _dependenciesByTable =
new(StringComparer.Ordinal);
private readonly IDeserializer _deserializer;
private readonly object _gate = new();
private readonly Action<string>? _onTableReloaded;
@ -409,6 +667,7 @@ public sealed class YamlConfigLoader : IConfigLoader
/// <param name="deserializer">YAML 反序列化器。</param>
/// <param name="registry">要更新的配置注册表。</param>
/// <param name="registrations">已注册的配置表定义。</param>
/// <param name="initialDependencies">最近一次成功加载后记录下来的跨表依赖图。</param>
/// <param name="onTableReloaded">单表重载成功回调。</param>
/// <param name="onTableReloadFailed">单表重载失败回调。</param>
/// <param name="debounceDelay">监听事件防抖延迟。</param>
@ -417,6 +676,7 @@ public sealed class YamlConfigLoader : IConfigLoader
IDeserializer deserializer,
IConfigRegistry registry,
IEnumerable<IYamlTableRegistration> registrations,
IReadOnlyDictionary<string, IReadOnlyCollection<string>> initialDependencies,
Action<string>? onTableReloaded,
Action<string, Exception>? onTableReloadFailed,
TimeSpan debounceDelay)
@ -425,6 +685,7 @@ public sealed class YamlConfigLoader : IConfigLoader
ArgumentNullException.ThrowIfNull(deserializer);
ArgumentNullException.ThrowIfNull(registry);
ArgumentNullException.ThrowIfNull(registrations);
ArgumentNullException.ThrowIfNull(initialDependencies);
_rootPath = rootPath;
_deserializer = deserializer;
@ -437,6 +698,10 @@ public sealed class YamlConfigLoader : IConfigLoader
{
_registrations.Add(registration.Name, registration);
_reloadLocks.Add(registration.Name, new SemaphoreSlim(1, 1));
_dependenciesByTable[registration.Name] =
initialDependencies.TryGetValue(registration.Name, out var dependencies)
? dependencies
: Array.Empty<string>();
CreateWatchersForRegistration(registration);
}
}
@ -604,7 +869,7 @@ public sealed class YamlConfigLoader : IConfigLoader
private async Task ReloadTableAsync(string tableName, CancellationToken cancellationToken)
{
if (!_registrations.TryGetValue(tableName, out var registration))
if (!_registrations.ContainsKey(tableName))
{
return;
}
@ -616,9 +881,26 @@ public sealed class YamlConfigLoader : IConfigLoader
{
cancellationToken.ThrowIfCancellationRequested();
var (name, table) = await registration.LoadAsync(_rootPath, _deserializer, cancellationToken);
RegistrationDispatcher.Register(_registry, name, table);
InvokeReloaded(name);
var affectedTableNames = GetAffectedTableNames(tableName);
var loadedTables = new List<YamlTableLoadResult>(affectedTableNames.Count);
// 目标表变更可能让依赖它的表立即失效,因此热重载需要按受影响闭包整体重验并整体提交。
foreach (var affectedTableName in affectedTableNames)
{
cancellationToken.ThrowIfCancellationRequested();
loadedTables.Add(await _registrations[affectedTableName].LoadAsync(_rootPath, _deserializer,
cancellationToken));
}
CrossTableReferenceValidator.Validate(_registry, loadedTables);
foreach (var loadedTable in loadedTables)
{
RegistrationDispatcher.Register(_registry, loadedTable.Name, loadedTable.Table);
_dependenciesByTable[loadedTable.Name] = loadedTable.ReferencedTableNames;
}
InvokeReloaded(tableName);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@ -634,6 +916,38 @@ public sealed class YamlConfigLoader : IConfigLoader
}
}
private IReadOnlyCollection<string> GetAffectedTableNames(string changedTableName)
{
var affectedTableNames = new HashSet<string>(StringComparer.Ordinal)
{
changedTableName
};
var pendingTableNames = new Queue<string>();
pendingTableNames.Enqueue(changedTableName);
while (pendingTableNames.Count > 0)
{
var currentTableName = pendingTableNames.Dequeue();
foreach (var dependency in _dependenciesByTable)
{
if (!dependency.Value.Contains(currentTableName))
{
continue;
}
if (affectedTableNames.Add(dependency.Key))
{
pendingTableNames.Enqueue(dependency.Key);
}
}
}
return affectedTableNames
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
}
private void InvokeReloaded(string tableName)
{
if (_onTableReloaded == null)

View File

@ -86,7 +86,14 @@ internal static class YamlConfigSchemaValidator
properties.Add(property.Name, ParseProperty(schemaPath, property));
}
return new YamlConfigSchema(schemaPath, properties, requiredProperties);
var referencedTableNames = properties.Values
.Select(static property => property.ReferenceTableName)
.Where(static tableName => !string.IsNullOrWhiteSpace(tableName))
.Cast<string>()
.Distinct(StringComparer.Ordinal)
.ToArray();
return new YamlConfigSchema(schemaPath, properties, requiredProperties, referencedTableNames);
}
catch (JsonException exception)
{
@ -106,6 +113,24 @@ internal static class YamlConfigSchemaValidator
YamlConfigSchema schema,
string yamlPath,
string yamlText)
{
ValidateAndCollectReferences(schema, yamlPath, yamlText);
}
/// <summary>
/// 使用已解析的 schema 校验 YAML 文本,并提取声明过的跨表引用。
/// 该方法让结构校验与引用采集共享同一份 YAML 解析结果,避免加载器重复解析同一文件。
/// </summary>
/// <param name="schema">已解析的 schema 模型。</param>
/// <param name="yamlPath">YAML 文件路径,仅用于诊断信息。</param>
/// <param name="yamlText">YAML 文本内容。</param>
/// <returns>当前 YAML 文件中声明的跨表引用集合。</returns>
/// <exception cref="ArgumentNullException">当参数为空时抛出。</exception>
/// <exception cref="InvalidOperationException">当 YAML 内容与 schema 不匹配时抛出。</exception>
internal static IReadOnlyList<YamlConfigReferenceUsage> ValidateAndCollectReferences(
YamlConfigSchema schema,
string yamlPath,
string yamlText)
{
ArgumentNullException.ThrowIfNull(schema);
ArgumentNullException.ThrowIfNull(yamlPath);
@ -131,6 +156,7 @@ internal static class YamlConfigSchemaValidator
$"Config file '{yamlPath}' must contain a single root mapping object.");
}
var references = new List<YamlConfigReferenceUsage>();
var seenProperties = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in rootMapping.Children)
{
@ -154,7 +180,7 @@ internal static class YamlConfigSchemaValidator
$"Config file '{yamlPath}' contains unknown property '{propertyName}' that is not declared in schema '{schema.SchemaPath}'.");
}
ValidateNode(yamlPath, propertyName, entry.Value, property);
ValidateNode(yamlPath, propertyName, entry.Value, property, references);
}
foreach (var requiredProperty in schema.RequiredProperties)
@ -165,6 +191,8 @@ internal static class YamlConfigSchemaValidator
$"Config file '{yamlPath}' is missing required property '{requiredProperty}' defined by schema '{schema.SchemaPath}'.");
}
}
return references;
}
private static YamlConfigSchemaProperty ParseProperty(string schemaPath, JsonProperty property)
@ -188,9 +216,27 @@ internal static class YamlConfigSchemaValidator
$"Property '{property.Name}' in schema file '{schemaPath}' uses unsupported type '{typeName}'.")
};
string? referenceTableName = null;
if (property.Value.TryGetProperty("x-gframework-ref-table", out var referenceTableElement))
{
if (referenceTableElement.ValueKind != JsonValueKind.String)
{
throw new InvalidOperationException(
$"Property '{property.Name}' in schema file '{schemaPath}' must declare a string 'x-gframework-ref-table' value.");
}
referenceTableName = referenceTableElement.GetString();
if (string.IsNullOrWhiteSpace(referenceTableName))
{
throw new InvalidOperationException(
$"Property '{property.Name}' in schema file '{schemaPath}' must declare a non-empty 'x-gframework-ref-table' value.");
}
}
if (propertyType != YamlConfigSchemaPropertyType.Array)
{
return new YamlConfigSchemaProperty(property.Name, propertyType, null);
EnsureReferenceKeywordIsSupported(schemaPath, property.Name, propertyType, null, referenceTableName);
return new YamlConfigSchemaProperty(property.Name, propertyType, null, referenceTableName);
}
if (!property.Value.TryGetProperty("items", out var itemsElement) ||
@ -213,14 +259,44 @@ internal static class YamlConfigSchemaValidator
$"Array property '{property.Name}' in schema file '{schemaPath}' uses unsupported item type '{itemTypeName}'.")
};
return new YamlConfigSchemaProperty(property.Name, propertyType, itemType);
EnsureReferenceKeywordIsSupported(schemaPath, property.Name, propertyType, itemType, referenceTableName);
return new YamlConfigSchemaProperty(property.Name, propertyType, itemType, referenceTableName);
}
private static void EnsureReferenceKeywordIsSupported(
string schemaPath,
string propertyName,
YamlConfigSchemaPropertyType propertyType,
YamlConfigSchemaPropertyType? itemType,
string? referenceTableName)
{
if (referenceTableName == null)
{
return;
}
if (propertyType == YamlConfigSchemaPropertyType.String ||
propertyType == YamlConfigSchemaPropertyType.Integer)
{
return;
}
if (propertyType == YamlConfigSchemaPropertyType.Array &&
(itemType == YamlConfigSchemaPropertyType.String || itemType == YamlConfigSchemaPropertyType.Integer))
{
return;
}
throw new InvalidOperationException(
$"Property '{propertyName}' in schema file '{schemaPath}' uses 'x-gframework-ref-table', but only string, integer, or arrays of those scalar types can declare cross-table references.");
}
private static void ValidateNode(
string yamlPath,
string propertyName,
YamlNode node,
YamlConfigSchemaProperty property)
YamlConfigSchemaProperty property,
ICollection<YamlConfigReferenceUsage> references)
{
if (property.PropertyType == YamlConfigSchemaPropertyType.Array)
{
@ -230,15 +306,31 @@ internal static class YamlConfigSchemaValidator
$"Property '{propertyName}' in config file '{yamlPath}' must be an array.");
}
foreach (var item in sequenceNode.Children)
for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++)
{
ValidateScalarNode(yamlPath, propertyName, item, property.ItemType!.Value, isArrayItem: true);
ValidateScalarNode(
yamlPath,
propertyName,
sequenceNode.Children[itemIndex],
property.ItemType!.Value,
property.ReferenceTableName,
references,
isArrayItem: true,
itemIndex);
}
return;
}
ValidateScalarNode(yamlPath, propertyName, node, property.PropertyType, isArrayItem: false);
ValidateScalarNode(
yamlPath,
propertyName,
node,
property.PropertyType,
property.ReferenceTableName,
references,
isArrayItem: false,
itemIndex: null);
}
private static void ValidateScalarNode(
@ -246,7 +338,10 @@ internal static class YamlConfigSchemaValidator
string propertyName,
YamlNode node,
YamlConfigSchemaPropertyType expectedType,
bool isArrayItem)
string? referenceTableName,
ICollection<YamlConfigReferenceUsage> references,
bool isArrayItem,
int? itemIndex)
{
if (node is not YamlScalarNode scalarNode)
{
@ -287,6 +382,18 @@ internal static class YamlConfigSchemaValidator
if (isValid)
{
if (referenceTableName != null)
{
references.Add(
new YamlConfigReferenceUsage(
yamlPath,
propertyName,
itemIndex,
value,
referenceTableName,
expectedType));
}
return;
}
@ -336,18 +443,22 @@ internal sealed class YamlConfigSchema
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="properties">Schema 属性定义。</param>
/// <param name="requiredProperties">必填属性集合。</param>
/// <param name="referencedTableNames">Schema 声明的目标引用表名称集合。</param>
public YamlConfigSchema(
string schemaPath,
IReadOnlyDictionary<string, YamlConfigSchemaProperty> properties,
IReadOnlyCollection<string> requiredProperties)
IReadOnlyCollection<string> requiredProperties,
IReadOnlyCollection<string> referencedTableNames)
{
ArgumentNullException.ThrowIfNull(schemaPath);
ArgumentNullException.ThrowIfNull(properties);
ArgumentNullException.ThrowIfNull(requiredProperties);
ArgumentNullException.ThrowIfNull(referencedTableNames);
SchemaPath = schemaPath;
Properties = properties;
RequiredProperties = requiredProperties;
ReferencedTableNames = referencedTableNames;
}
/// <summary>
@ -364,6 +475,12 @@ internal sealed class YamlConfigSchema
/// 获取 schema 声明的必填属性集合。
/// </summary>
public IReadOnlyCollection<string> RequiredProperties { get; }
/// <summary>
/// 获取 schema 声明的目标引用表名称集合。
/// 该信息用于热重载时推导受影响的依赖表闭包。
/// </summary>
public IReadOnlyCollection<string> ReferencedTableNames { get; }
}
/// <summary>
@ -377,16 +494,19 @@ internal sealed class YamlConfigSchemaProperty
/// <param name="name">属性名称。</param>
/// <param name="propertyType">属性类型。</param>
/// <param name="itemType">数组元素类型;仅当属性类型为数组时有效。</param>
/// <param name="referenceTableName">目标引用表名称;未声明跨表引用时为空。</param>
public YamlConfigSchemaProperty(
string name,
YamlConfigSchemaPropertyType propertyType,
YamlConfigSchemaPropertyType? itemType)
YamlConfigSchemaPropertyType? itemType,
string? referenceTableName)
{
ArgumentNullException.ThrowIfNull(name);
Name = name;
PropertyType = propertyType;
ItemType = itemType;
ReferenceTableName = referenceTableName;
}
/// <summary>
@ -403,6 +523,83 @@ internal sealed class YamlConfigSchemaProperty
/// 获取数组元素类型;非数组属性时返回空。
/// </summary>
public YamlConfigSchemaPropertyType? ItemType { get; }
/// <summary>
/// 获取目标引用表名称;未声明跨表引用时返回空。
/// </summary>
public string? ReferenceTableName { get; }
}
/// <summary>
/// 表示单个 YAML 文件中提取出的跨表引用。
/// 该模型保留源文件、字段路径和目标表等诊断信息,以便加载器在批量校验失败时给出可定位的错误。
/// </summary>
internal sealed class YamlConfigReferenceUsage
{
/// <summary>
/// 初始化一个跨表引用使用记录。
/// </summary>
/// <param name="yamlPath">源 YAML 文件路径。</param>
/// <param name="propertyName">声明引用的属性名。</param>
/// <param name="itemIndex">数组元素索引;标量属性时为空。</param>
/// <param name="rawValue">YAML 中的原始标量值。</param>
/// <param name="referencedTableName">目标配置表名称。</param>
/// <param name="valueType">引用值的 schema 标量类型。</param>
public YamlConfigReferenceUsage(
string yamlPath,
string propertyName,
int? itemIndex,
string rawValue,
string referencedTableName,
YamlConfigSchemaPropertyType valueType)
{
ArgumentNullException.ThrowIfNull(yamlPath);
ArgumentNullException.ThrowIfNull(propertyName);
ArgumentNullException.ThrowIfNull(rawValue);
ArgumentNullException.ThrowIfNull(referencedTableName);
YamlPath = yamlPath;
PropertyName = propertyName;
ItemIndex = itemIndex;
RawValue = rawValue;
ReferencedTableName = referencedTableName;
ValueType = valueType;
}
/// <summary>
/// 获取源 YAML 文件路径。
/// </summary>
public string YamlPath { get; }
/// <summary>
/// 获取声明引用的属性名。
/// </summary>
public string PropertyName { get; }
/// <summary>
/// 获取数组元素索引;标量属性时返回空。
/// </summary>
public int? ItemIndex { get; }
/// <summary>
/// 获取 YAML 中的原始标量值。
/// </summary>
public string RawValue { get; }
/// <summary>
/// 获取目标配置表名称。
/// </summary>
public string ReferencedTableName { get; }
/// <summary>
/// 获取引用值的 schema 标量类型。
/// </summary>
public YamlConfigSchemaPropertyType ValueType { get; }
/// <summary>
/// 获取便于诊断显示的字段路径。
/// </summary>
public string DisplayPath => ItemIndex.HasValue ? $"{PropertyName}[{ItemIndex.Value}]" : PropertyName;
}
/// <summary>

View File

@ -134,6 +134,7 @@
<AdditionalFiles Remove="AnalyzerReleases.Unshipped.md"/>
</ItemGroup>
<ItemGroup>
<Folder Include="local-plan\docs\"/>
<Folder Include="local-plan\todos\"/>
<Folder Include="local-plan\评估\"/>
</ItemGroup>

View File

@ -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 嵌套对象与复杂数组编辑器