mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 08:44:29 +08:00
- 修复 YamlConfigLoader 的超长方法、依赖比较与热重载同步原语告警 - 拆分 MicrosoftDiContainerTests 与 AbstractAsyncQueryTests 的辅助类型文件以消除 MA0048 - 更新 analyzer warning reduction 跟踪文档并记录 non-incremental 构建基线变化
1239 lines
50 KiB
C#
1239 lines
50 KiB
C#
using System.Diagnostics;
|
||
using System.Globalization;
|
||
using System.Threading;
|
||
using GFramework.Core.Abstractions.Events;
|
||
using GFramework.Game.Abstractions.Config;
|
||
using YamlDotNet.Serialization;
|
||
using YamlDotNet.Serialization.NamingConventions;
|
||
|
||
namespace GFramework.Game.Config;
|
||
|
||
/// <summary>
|
||
/// 基于文件目录的 YAML 配置加载器。
|
||
/// 该实现用于 Runtime MVP 的文本配置接入阶段,通过显式注册表定义描述要加载的配置域,
|
||
/// 再在一次加载流程中统一解析并写入配置注册表。
|
||
/// </summary>
|
||
public sealed class YamlConfigLoader : IConfigLoader
|
||
{
|
||
private const string DefaultHotReloadUnavailableMessage =
|
||
"Hot reload is not available for the current loader configuration.";
|
||
|
||
private const string RootPathCannotBeNullOrWhiteSpaceMessage = "Root path cannot be null or whitespace.";
|
||
private const string TableNameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace.";
|
||
private const string RelativePathCannotBeNullOrWhiteSpaceMessage = "Relative path cannot be null or whitespace.";
|
||
|
||
private const string SchemaRelativePathCannotBeNullOrWhiteSpaceMessage =
|
||
"Schema relative path cannot be null or whitespace.";
|
||
|
||
private static readonly TimeSpan DefaultHotReloadDebounceDelay = TimeSpan.FromMilliseconds(200);
|
||
|
||
private readonly Func<bool> _canEnableHotReload;
|
||
private readonly IDeserializer _deserializer;
|
||
private readonly string _hotReloadUnavailableMessage;
|
||
|
||
private readonly Dictionary<string, IReadOnlyCollection<string>> _lastSuccessfulDependencies =
|
||
new(StringComparer.Ordinal);
|
||
|
||
private readonly List<IYamlTableRegistration> _registrations = new();
|
||
private readonly string _rootPath;
|
||
|
||
/// <summary>
|
||
/// 使用指定配置根目录创建 YAML 配置加载器。
|
||
/// </summary>
|
||
/// <param name="rootPath">配置根目录。</param>
|
||
/// <exception cref="ArgumentException">当 <paramref name="rootPath" /> 为空时抛出。</exception>
|
||
public YamlConfigLoader(string rootPath)
|
||
: this(rootPath, null, null)
|
||
{
|
||
}
|
||
|
||
/// <summary>
|
||
/// 使用指定配置根目录与热重载可用性守卫创建 YAML 配置加载器。
|
||
/// </summary>
|
||
/// <param name="rootPath">配置根目录。</param>
|
||
/// <param name="canEnableHotReload">
|
||
/// 用于判断当前实例是否允许启用热重载的委托。
|
||
/// 宿主适配层可借此把额外的文件系统前置条件下沉到底层加载器,避免公开实例被绕过时启用错误监听目标。
|
||
/// </param>
|
||
/// <param name="hotReloadUnavailableMessage">
|
||
/// 当 <paramref name="canEnableHotReload" /> 返回 <see langword="false" /> 时抛出的异常消息;
|
||
/// 为空时使用默认消息。
|
||
/// </param>
|
||
/// <exception cref="ArgumentException">当 <paramref name="rootPath" /> 为空时抛出。</exception>
|
||
internal YamlConfigLoader(
|
||
string rootPath,
|
||
Func<bool>? canEnableHotReload,
|
||
string? hotReloadUnavailableMessage)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(rootPath))
|
||
{
|
||
throw new ArgumentException(RootPathCannotBeNullOrWhiteSpaceMessage, nameof(rootPath));
|
||
}
|
||
|
||
_rootPath = rootPath;
|
||
_canEnableHotReload = canEnableHotReload ?? (() => true);
|
||
_hotReloadUnavailableMessage = string.IsNullOrWhiteSpace(hotReloadUnavailableMessage)
|
||
? DefaultHotReloadUnavailableMessage
|
||
: hotReloadUnavailableMessage;
|
||
_deserializer = new DeserializerBuilder()
|
||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||
.IgnoreUnmatchedProperties()
|
||
.Build();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取配置根目录。
|
||
/// </summary>
|
||
public string RootPath => _rootPath;
|
||
|
||
/// <summary>
|
||
/// 获取当前已注册的配置表定义数量。
|
||
/// </summary>
|
||
public int RegistrationCount => _registrations.Count;
|
||
|
||
/// <inheritdoc />
|
||
public async Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(registry);
|
||
|
||
var loadedTables = new List<YamlTableLoadResult>(_registrations.Count);
|
||
|
||
foreach (var registration in _registrations)
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
loadedTables.Add(
|
||
await registration.LoadAsync(_rootPath, _deserializer, cancellationToken).ConfigureAwait(false));
|
||
}
|
||
|
||
CrossTableReferenceValidator.Validate(registry, loadedTables);
|
||
|
||
// 仅当本轮所有配置表都成功加载后才写入注册表,避免暴露部分成功的中间状态。
|
||
foreach (var loadedTable in loadedTables)
|
||
{
|
||
RegistrationDispatcher.Register(registry, loadedTable.Name, loadedTable.Table);
|
||
}
|
||
|
||
UpdateLastSuccessfulDependencies(loadedTables);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 启用开发期热重载。
|
||
/// 该能力会监听已注册配置表对应的配置目录和 schema 文件,并在检测到文件变更后按表粒度重新加载。
|
||
/// 重载失败时会保留注册表中的旧表,避免开发期错误配置直接破坏当前运行时状态。
|
||
/// </summary>
|
||
/// <param name="registry">要被热重载更新的配置注册表。</param>
|
||
/// <param name="onTableReloaded">单个配置表重载成功后的可选回调。</param>
|
||
/// <param name="onTableReloadFailed">
|
||
/// 单个配置表重载失败后的可选回调。
|
||
/// 当失败来自加载器本身时,传入异常通常为 <see cref="ConfigLoadException" />,可从其诊断对象读取稳定字段。
|
||
/// </param>
|
||
/// <param name="debounceDelay">防抖延迟;为空时默认使用 200 毫秒。</param>
|
||
/// <returns>用于停止热重载监听的注销句柄。</returns>
|
||
/// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为空时抛出。</exception>
|
||
/// <exception cref="ArgumentOutOfRangeException">
|
||
/// 当显式提供的 <paramref name="debounceDelay" /> 小于 <see cref="TimeSpan.Zero" /> 时抛出。
|
||
/// </exception>
|
||
public IUnRegister EnableHotReload(
|
||
IConfigRegistry registry,
|
||
Action<string>? onTableReloaded = null,
|
||
Action<string, Exception>? onTableReloadFailed = null,
|
||
TimeSpan? debounceDelay = null)
|
||
{
|
||
return EnableHotReload(
|
||
registry,
|
||
new YamlConfigHotReloadOptions
|
||
{
|
||
OnTableReloaded = onTableReloaded,
|
||
OnTableReloadFailed = onTableReloadFailed,
|
||
DebounceDelay = debounceDelay ?? DefaultHotReloadDebounceDelay
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 启用开发期热重载,并通过选项对象集中配置回调和防抖行为。
|
||
/// 该入口用于减少继续堆叠位置参数重载的需要,
|
||
/// 也为未来扩展过滤策略或日志钩子预留稳定形态。
|
||
/// </summary>
|
||
/// <param name="registry">要被热重载更新的配置注册表。</param>
|
||
/// <param name="options">热重载配置选项;为空时使用默认选项。</param>
|
||
/// <returns>用于停止热重载监听的注销句柄。</returns>
|
||
/// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为空时抛出。</exception>
|
||
/// <exception cref="ArgumentOutOfRangeException">
|
||
/// 当 <paramref name="options" /> 的 <see cref="YamlConfigHotReloadOptions.DebounceDelay" /> 小于
|
||
/// <see cref="TimeSpan.Zero" /> 时抛出。
|
||
/// </exception>
|
||
public IUnRegister EnableHotReload(
|
||
IConfigRegistry registry,
|
||
YamlConfigHotReloadOptions? options)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(registry);
|
||
options ??= new YamlConfigHotReloadOptions();
|
||
EnsureHotReloadCanBeEnabled();
|
||
if (options.DebounceDelay < TimeSpan.Zero)
|
||
{
|
||
throw new ArgumentOutOfRangeException(
|
||
nameof(options),
|
||
"DebounceDelay must be greater than or equal to zero.");
|
||
}
|
||
|
||
return new HotReloadSession(
|
||
_rootPath,
|
||
_deserializer,
|
||
registry,
|
||
_registrations,
|
||
_lastSuccessfulDependencies,
|
||
options.OnTableReloaded,
|
||
options.OnTableReloadFailed,
|
||
options.DebounceDelay);
|
||
}
|
||
|
||
private void EnsureHotReloadCanBeEnabled()
|
||
{
|
||
if (_canEnableHotReload())
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Host adapters can attach additional filesystem constraints to the loader instance.
|
||
// Enforcing the guard here prevents callers from bypassing the adapter by invoking
|
||
// EnableHotReload directly on the exposed loader reference.
|
||
throw new InvalidOperationException(_hotReloadUnavailableMessage);
|
||
}
|
||
|
||
private void UpdateLastSuccessfulDependencies(IEnumerable<YamlTableLoadResult> loadedTables)
|
||
{
|
||
_lastSuccessfulDependencies.Clear();
|
||
|
||
foreach (var loadedTable in loadedTables)
|
||
{
|
||
_lastSuccessfulDependencies[loadedTable.Name] = loadedTable.ReferencedTableNames;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 注册一个 YAML 配置表定义。
|
||
/// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。
|
||
/// </summary>
|
||
/// <typeparam name="TKey">配置主键类型。</typeparam>
|
||
/// <typeparam name="TValue">配置值类型。</typeparam>
|
||
/// <param name="tableName">配置表名称。</param>
|
||
/// <param name="relativePath">相对配置根目录的子目录。</param>
|
||
/// <param name="keySelector">配置项主键提取器。</param>
|
||
/// <param name="comparer">可选主键比较器。</param>
|
||
/// <returns>当前加载器实例,以便链式注册。</returns>
|
||
/// <exception cref="ArgumentException">
|
||
/// 当 <paramref name="tableName" /> 或 <paramref name="relativePath" /> 为 null、空字符串或空白字符串时抛出。
|
||
/// </exception>
|
||
/// <exception cref="ArgumentNullException">当 <paramref name="keySelector" /> 为 null 时抛出。</exception>
|
||
public YamlConfigLoader RegisterTable<TKey, TValue>(
|
||
string tableName,
|
||
string relativePath,
|
||
Func<TValue, TKey> keySelector,
|
||
IEqualityComparer<TKey>? comparer = null)
|
||
where TKey : notnull
|
||
{
|
||
return RegisterTable(
|
||
new YamlConfigTableRegistrationOptions<TKey, TValue>(tableName, relativePath, keySelector)
|
||
{
|
||
Comparer = comparer
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 注册一个带 schema 校验的 YAML 配置表定义。
|
||
/// 该重载会在 YAML 反序列化之前使用指定 schema 拒绝未知字段、缺失必填字段和基础类型错误,
|
||
/// 以避免错误配置以默认值形式悄悄进入运行时。
|
||
/// </summary>
|
||
/// <typeparam name="TKey">配置主键类型。</typeparam>
|
||
/// <typeparam name="TValue">配置值类型。</typeparam>
|
||
/// <param name="tableName">配置表名称。</param>
|
||
/// <param name="relativePath">相对配置根目录的子目录。</param>
|
||
/// <param name="schemaRelativePath">相对配置根目录的 schema 文件路径。</param>
|
||
/// <param name="keySelector">配置项主键提取器。</param>
|
||
/// <param name="comparer">可选主键比较器。</param>
|
||
/// <returns>当前加载器实例,以便链式注册。</returns>
|
||
/// <exception cref="ArgumentException">
|
||
/// 当 <paramref name="tableName" />、<paramref name="relativePath" /> 或 <paramref name="schemaRelativePath" />
|
||
/// 为 null、空字符串或空白字符串时抛出。
|
||
/// </exception>
|
||
/// <exception cref="ArgumentNullException">当 <paramref name="keySelector" /> 为 null 时抛出。</exception>
|
||
public YamlConfigLoader RegisterTable<TKey, TValue>(
|
||
string tableName,
|
||
string relativePath,
|
||
string schemaRelativePath,
|
||
Func<TValue, TKey> keySelector,
|
||
IEqualityComparer<TKey>? comparer = null)
|
||
where TKey : notnull
|
||
{
|
||
return RegisterTable(
|
||
new YamlConfigTableRegistrationOptions<TKey, TValue>(tableName, relativePath, keySelector)
|
||
{
|
||
SchemaRelativePath = schemaRelativePath,
|
||
Comparer = comparer
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 使用选项对象注册一个 YAML 配置表定义。
|
||
/// 该入口集中承载配置目录、schema 路径、主键提取器和比较器,
|
||
/// 以避免未来继续为新增开关叠加更多重载。
|
||
/// </summary>
|
||
/// <typeparam name="TKey">配置主键类型。</typeparam>
|
||
/// <typeparam name="TValue">配置值类型。</typeparam>
|
||
/// <param name="options">配置表注册选项。</param>
|
||
/// <returns>当前加载器实例,以便链式注册。</returns>
|
||
/// <exception cref="ArgumentNullException">当 <paramref name="options" /> 为空时抛出。</exception>
|
||
/// <exception cref="ArgumentException">
|
||
/// 当 <paramref name="options" /> 内的 <see cref="YamlConfigTableRegistrationOptions{TKey, TValue}.TableName" />、
|
||
/// <see cref="YamlConfigTableRegistrationOptions{TKey, TValue}.RelativePath" /> 或
|
||
/// <see cref="YamlConfigTableRegistrationOptions{TKey, TValue}.SchemaRelativePath" /> 为 null、空字符串或空白字符串时抛出。
|
||
/// </exception>
|
||
public YamlConfigLoader RegisterTable<TKey, TValue>(YamlConfigTableRegistrationOptions<TKey, TValue> options)
|
||
where TKey : notnull
|
||
{
|
||
ArgumentNullException.ThrowIfNull(options);
|
||
|
||
return RegisterTableCore(
|
||
options.TableName,
|
||
options.RelativePath,
|
||
options.SchemaRelativePath,
|
||
options.KeySelector,
|
||
options.Comparer);
|
||
}
|
||
|
||
private YamlConfigLoader RegisterTableCore<TKey, TValue>(
|
||
string tableName,
|
||
string relativePath,
|
||
string? schemaRelativePath,
|
||
Func<TValue, TKey> keySelector,
|
||
IEqualityComparer<TKey>? comparer)
|
||
where TKey : notnull
|
||
{
|
||
if (string.IsNullOrWhiteSpace(tableName))
|
||
{
|
||
throw new ArgumentException(TableNameCannotBeNullOrWhiteSpaceMessage, nameof(tableName));
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(relativePath))
|
||
{
|
||
throw new ArgumentException(RelativePathCannotBeNullOrWhiteSpaceMessage, nameof(relativePath));
|
||
}
|
||
|
||
ArgumentNullException.ThrowIfNull(keySelector);
|
||
|
||
if (schemaRelativePath != null && string.IsNullOrWhiteSpace(schemaRelativePath))
|
||
{
|
||
throw new ArgumentException(
|
||
SchemaRelativePathCannotBeNullOrWhiteSpaceMessage,
|
||
nameof(schemaRelativePath));
|
||
}
|
||
|
||
_registrations.Add(
|
||
new YamlTableRegistration<TKey, TValue>(
|
||
tableName,
|
||
relativePath,
|
||
schemaRelativePath,
|
||
keySelector,
|
||
comparer));
|
||
return this;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 负责在非泛型配置表与泛型注册表方法之间做分派。
|
||
/// 该静态助手将运行时反射局部封装在加载器内部,避免向外暴露弱类型注册 API。
|
||
/// </summary>
|
||
private static class RegistrationDispatcher
|
||
{
|
||
/// <summary>
|
||
/// 将强类型配置表写入注册表。
|
||
/// </summary>
|
||
/// <param name="registry">目标配置注册表。</param>
|
||
/// <param name="name">配置表名称。</param>
|
||
/// <param name="table">已加载的配置表实例。</param>
|
||
/// <exception cref="InvalidOperationException">当传入表未实现强类型配置表契约时抛出。</exception>
|
||
public static void Register(IConfigRegistry registry, string name, IConfigTable table)
|
||
{
|
||
var tableInterface = table.GetType()
|
||
.GetInterfaces()
|
||
.FirstOrDefault(static type =>
|
||
type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IConfigTable<,>));
|
||
|
||
if (tableInterface == null)
|
||
{
|
||
throw new InvalidOperationException(
|
||
$"Loaded config table '{name}' does not implement '{typeof(IConfigTable<,>).Name}'.");
|
||
}
|
||
|
||
var genericArguments = tableInterface.GetGenericArguments();
|
||
var method = typeof(IConfigRegistry)
|
||
.GetMethod(nameof(IConfigRegistry.RegisterTable))!
|
||
.MakeGenericMethod(genericArguments[0], genericArguments[1]);
|
||
|
||
method.Invoke(registry, new object[] { name, table });
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 定义 YAML 配置表注册项的统一内部契约。
|
||
/// </summary>
|
||
private interface IYamlTableRegistration
|
||
{
|
||
/// <summary>
|
||
/// 获取配置表名称。
|
||
/// </summary>
|
||
string Name { get; }
|
||
|
||
/// <summary>
|
||
/// 获取相对配置根目录的子目录。
|
||
/// </summary>
|
||
string RelativePath { get; }
|
||
|
||
/// <summary>
|
||
/// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时返回空。
|
||
/// </summary>
|
||
string? SchemaRelativePath { get; }
|
||
|
||
/// <summary>
|
||
/// 从指定根目录加载配置表。
|
||
/// </summary>
|
||
/// <param name="rootPath">配置根目录。</param>
|
||
/// <param name="deserializer">YAML 反序列化器。</param>
|
||
/// <param name="cancellationToken">取消令牌。</param>
|
||
/// <returns>已加载的配置表结果。</returns>
|
||
Task<YamlTableLoadResult> LoadAsync(
|
||
string rootPath,
|
||
IDeserializer deserializer,
|
||
CancellationToken cancellationToken);
|
||
}
|
||
|
||
/// <summary>
|
||
/// YAML 配置表注册项。
|
||
/// </summary>
|
||
/// <typeparam name="TKey">配置主键类型。</typeparam>
|
||
/// <typeparam name="TValue">配置项值类型。</typeparam>
|
||
private sealed class YamlTableRegistration<TKey, TValue> : IYamlTableRegistration
|
||
where TKey : notnull
|
||
{
|
||
private readonly IEqualityComparer<TKey>? _comparer;
|
||
private readonly Func<TValue, TKey> _keySelector;
|
||
|
||
/// <summary>
|
||
/// 初始化 YAML 配置表注册项。
|
||
/// </summary>
|
||
/// <param name="name">配置表名称。</param>
|
||
/// <param name="relativePath">相对配置根目录的子目录。</param>
|
||
/// <param name="schemaRelativePath">相对配置根目录的 schema 文件路径;未启用 schema 校验时为空。</param>
|
||
/// <param name="keySelector">配置项主键提取器。</param>
|
||
/// <param name="comparer">可选主键比较器。</param>
|
||
public YamlTableRegistration(
|
||
string name,
|
||
string relativePath,
|
||
string? schemaRelativePath,
|
||
Func<TValue, TKey> keySelector,
|
||
IEqualityComparer<TKey>? comparer)
|
||
{
|
||
Debug.Assert(!string.IsNullOrWhiteSpace(name), "Table registrations should always have a non-empty name.");
|
||
Debug.Assert(!string.IsNullOrWhiteSpace(relativePath),
|
||
"Table registrations should always have a non-empty relative path.");
|
||
|
||
if (string.IsNullOrWhiteSpace(name))
|
||
{
|
||
throw new ArgumentException(TableNameCannotBeNullOrWhiteSpaceMessage, nameof(name));
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(relativePath))
|
||
{
|
||
throw new ArgumentException(RelativePathCannotBeNullOrWhiteSpaceMessage, nameof(relativePath));
|
||
}
|
||
|
||
Name = name;
|
||
RelativePath = relativePath;
|
||
SchemaRelativePath = schemaRelativePath;
|
||
_keySelector = keySelector;
|
||
_comparer = comparer;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取配置表名称。
|
||
/// </summary>
|
||
public string Name { get; }
|
||
|
||
/// <summary>
|
||
/// 获取相对配置根目录的子目录。
|
||
/// </summary>
|
||
public string RelativePath { get; }
|
||
|
||
/// <summary>
|
||
/// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时返回空。
|
||
/// </summary>
|
||
public string? SchemaRelativePath { get; }
|
||
|
||
/// <inheritdoc />
|
||
public async Task<YamlTableLoadResult> LoadAsync(
|
||
string rootPath,
|
||
IDeserializer deserializer,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var directoryPath = GetValidatedDirectoryPath(rootPath);
|
||
var schema = await LoadSchemaAsync(rootPath, cancellationToken).ConfigureAwait(false);
|
||
var referenceUsages = new List<YamlConfigReferenceUsage>();
|
||
var values = await LoadValuesAsync(
|
||
directoryPath,
|
||
deserializer,
|
||
schema,
|
||
referenceUsages,
|
||
cancellationToken)
|
||
.ConfigureAwait(false);
|
||
return BuildLoadResult(directoryPath, schema, values, referenceUsages);
|
||
}
|
||
|
||
private string GetValidatedDirectoryPath(string rootPath)
|
||
{
|
||
var directoryPath = Path.Combine(rootPath, RelativePath);
|
||
if (Directory.Exists(directoryPath))
|
||
{
|
||
return directoryPath;
|
||
}
|
||
|
||
throw ConfigLoadExceptionFactory.Create(
|
||
ConfigLoadFailureKind.ConfigDirectoryNotFound,
|
||
Name,
|
||
$"Config directory '{directoryPath}' was not found for table '{Name}'.",
|
||
configDirectoryPath: directoryPath);
|
||
}
|
||
|
||
private async Task<YamlConfigSchema?> LoadSchemaAsync(string rootPath, CancellationToken cancellationToken)
|
||
{
|
||
if (string.IsNullOrEmpty(SchemaRelativePath))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var schemaPath = Path.Combine(rootPath, SchemaRelativePath);
|
||
return await YamlConfigSchemaValidator.LoadAsync(Name, schemaPath, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
|
||
private async Task<List<TValue>> LoadValuesAsync(
|
||
string directoryPath,
|
||
IDeserializer deserializer,
|
||
YamlConfigSchema? schema,
|
||
List<YamlConfigReferenceUsage> referenceUsages,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var values = new List<TValue>();
|
||
foreach (var file in GetYamlFiles(directoryPath))
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
var yaml = await ReadYamlAsync(directoryPath, file, schema, cancellationToken).ConfigureAwait(false);
|
||
CollectReferenceUsages(referenceUsages, schema, file, yaml);
|
||
values.Add(DeserializeValue(deserializer, directoryPath, file, schema, yaml));
|
||
}
|
||
|
||
return values;
|
||
}
|
||
|
||
private static string[] GetYamlFiles(string directoryPath)
|
||
{
|
||
return Directory
|
||
.EnumerateFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly)
|
||
.Where(static path =>
|
||
path.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) ||
|
||
path.EndsWith(".yml", StringComparison.OrdinalIgnoreCase))
|
||
.OrderBy(static path => path, StringComparer.Ordinal)
|
||
.ToArray();
|
||
}
|
||
|
||
private async Task<string> ReadYamlAsync(
|
||
string directoryPath,
|
||
string file,
|
||
YamlConfigSchema? schema,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
return await File.ReadAllTextAsync(file, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
throw ConfigLoadExceptionFactory.Create(
|
||
ConfigLoadFailureKind.ConfigFileReadFailed,
|
||
Name,
|
||
$"Failed to read config file '{file}' for table '{Name}'.",
|
||
configDirectoryPath: directoryPath,
|
||
yamlPath: file,
|
||
schemaPath: schema?.SchemaPath,
|
||
innerException: exception);
|
||
}
|
||
}
|
||
|
||
private void CollectReferenceUsages(
|
||
List<YamlConfigReferenceUsage> referenceUsages,
|
||
YamlConfigSchema? schema,
|
||
string file,
|
||
string yaml)
|
||
{
|
||
if (schema == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 先按 schema 拒绝结构问题并提取跨表引用,避免被 IgnoreUnmatchedProperties 或默认值掩盖配置错误。
|
||
referenceUsages.AddRange(
|
||
YamlConfigSchemaValidator.ValidateAndCollectReferences(Name, schema, file, yaml));
|
||
}
|
||
|
||
private TValue DeserializeValue(
|
||
IDeserializer deserializer,
|
||
string directoryPath,
|
||
string file,
|
||
YamlConfigSchema? schema,
|
||
string yaml)
|
||
{
|
||
try
|
||
{
|
||
var value = deserializer.Deserialize<TValue>(yaml);
|
||
if (value != null)
|
||
{
|
||
return value;
|
||
}
|
||
|
||
throw new InvalidOperationException("YAML content was deserialized to null.");
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
throw ConfigLoadExceptionFactory.Create(
|
||
ConfigLoadFailureKind.DeserializationFailed,
|
||
Name,
|
||
$"Failed to deserialize config file '{file}' for table '{Name}' as '{typeof(TValue).Name}'.",
|
||
configDirectoryPath: directoryPath,
|
||
yamlPath: file,
|
||
schemaPath: schema?.SchemaPath,
|
||
detail: $"Target CLR type: {typeof(TValue).FullName}.",
|
||
innerException: exception);
|
||
}
|
||
}
|
||
|
||
private YamlTableLoadResult BuildLoadResult(
|
||
string directoryPath,
|
||
YamlConfigSchema? schema,
|
||
List<TValue> values,
|
||
List<YamlConfigReferenceUsage> referenceUsages)
|
||
{
|
||
try
|
||
{
|
||
var table = new InMemoryConfigTable<TKey, TValue>(values, _keySelector, _comparer);
|
||
return new YamlTableLoadResult(
|
||
Name,
|
||
table,
|
||
schema?.ReferencedTableNames ?? Array.Empty<string>(),
|
||
referenceUsages);
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
throw ConfigLoadExceptionFactory.Create(
|
||
ConfigLoadFailureKind.TableBuildFailed,
|
||
Name,
|
||
$"Failed to build config table '{Name}' from directory '{directoryPath}'.",
|
||
configDirectoryPath: directoryPath,
|
||
schemaPath: schema?.SchemaPath,
|
||
innerException: exception);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <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
|
||
{
|
||
private delegate bool IntegerTryParseDelegate<T>(
|
||
string value,
|
||
NumberStyles style,
|
||
IFormatProvider? provider,
|
||
out T result);
|
||
|
||
/// <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 ConfigLoadExceptionFactory.Create(
|
||
ConfigLoadFailureKind.ReferencedTableNotFound,
|
||
loadedTable.Name,
|
||
$"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' references table '{referenceUsage.ReferencedTableName}', but that table is not available in the current loader batch or registry.",
|
||
yamlPath: referenceUsage.YamlPath,
|
||
schemaPath: referenceUsage.SchemaPath,
|
||
displayPath: referenceUsage.DisplayPath,
|
||
referencedTableName: referenceUsage.ReferencedTableName,
|
||
rawValue: referenceUsage.RawValue);
|
||
}
|
||
|
||
if (!TryConvertReferenceKey(referenceUsage, targetTable.KeyType, out var convertedKey,
|
||
out var conversionError))
|
||
{
|
||
throw ConfigLoadExceptionFactory.Create(
|
||
ConfigLoadFailureKind.ReferenceKeyTypeMismatch,
|
||
loadedTable.Name,
|
||
$"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' cannot target table '{referenceUsage.ReferencedTableName}' with key type '{targetTable.KeyType.Name}'. {conversionError}",
|
||
yamlPath: referenceUsage.YamlPath,
|
||
schemaPath: referenceUsage.SchemaPath,
|
||
displayPath: referenceUsage.DisplayPath,
|
||
referencedTableName: referenceUsage.ReferencedTableName,
|
||
rawValue: referenceUsage.RawValue,
|
||
detail: conversionError);
|
||
}
|
||
|
||
if (!ContainsKey(targetTable, convertedKey!))
|
||
{
|
||
throw ConfigLoadExceptionFactory.Create(
|
||
ConfigLoadFailureKind.ReferencedKeyNotFound,
|
||
loadedTable.Name,
|
||
$"Config file '{referenceUsage.YamlPath}' property '{referenceUsage.DisplayPath}' references missing key '{referenceUsage.RawValue}' in table '{referenceUsage.ReferencedTableName}'.",
|
||
yamlPath: referenceUsage.YamlPath,
|
||
schemaPath: referenceUsage.SchemaPath,
|
||
displayPath: referenceUsage.DisplayPath,
|
||
referencedTableName: referenceUsage.ReferencedTableName,
|
||
rawValue: referenceUsage.RawValue);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 (TryConvertIntegerKey<int>(rawValue, targetKeyType, typeof(int), int.TryParse, out convertedKey) ||
|
||
TryConvertIntegerKey<long>(rawValue, targetKeyType, typeof(long), long.TryParse, out convertedKey) ||
|
||
TryConvertIntegerKey<short>(rawValue, targetKeyType, typeof(short), short.TryParse, out convertedKey) ||
|
||
TryConvertIntegerKey<byte>(rawValue, targetKeyType, typeof(byte), byte.TryParse, out convertedKey) ||
|
||
TryConvertIntegerKey<uint>(rawValue, targetKeyType, typeof(uint), uint.TryParse, out convertedKey) ||
|
||
TryConvertIntegerKey<ulong>(rawValue, targetKeyType, typeof(ulong), ulong.TryParse, out convertedKey) ||
|
||
TryConvertIntegerKey<ushort>(rawValue, targetKeyType, typeof(ushort), ushort.TryParse, out convertedKey) ||
|
||
TryConvertIntegerKey<sbyte>(rawValue, targetKeyType, typeof(sbyte), sbyte.TryParse, out convertedKey))
|
||
{
|
||
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 TryConvertIntegerKey<T>(
|
||
string rawValue,
|
||
Type targetKeyType,
|
||
Type supportedType,
|
||
IntegerTryParseDelegate<T> tryParse,
|
||
out object? convertedKey)
|
||
where T : struct
|
||
{
|
||
convertedKey = null;
|
||
if (targetKeyType != supportedType ||
|
||
!tryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedValue))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
convertedKey = parsedValue;
|
||
return true;
|
||
}
|
||
|
||
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>
|
||
private sealed class HotReloadSession : IUnRegister, IDisposable
|
||
{
|
||
private readonly TimeSpan _debounceDelay;
|
||
|
||
private readonly Dictionary<string, IReadOnlyCollection<string>> _dependenciesByTable =
|
||
new(StringComparer.Ordinal);
|
||
|
||
private readonly IDeserializer _deserializer;
|
||
#if NET9_0_OR_GREATER
|
||
// net9.0 及以上目标使用专用 Lock,以满足分析器对专用同步原语的建议。
|
||
private readonly Lock _gate = new();
|
||
#else
|
||
// net8.0 目标仍回退到 object 锁,以保持多目标编译兼容性。
|
||
private readonly object _gate = new();
|
||
#endif
|
||
private readonly Action<string>? _onTableReloaded;
|
||
private readonly Action<string, Exception>? _onTableReloadFailed;
|
||
private readonly Dictionary<string, IYamlTableRegistration> _registrations = new(StringComparer.Ordinal);
|
||
private readonly IConfigRegistry _registry;
|
||
private readonly Dictionary<string, SemaphoreSlim> _reloadLocks = new(StringComparer.Ordinal);
|
||
private readonly Dictionary<string, CancellationTokenSource> _reloadTokens = new(StringComparer.Ordinal);
|
||
private readonly string _rootPath;
|
||
private readonly List<FileSystemWatcher> _watchers = new();
|
||
private bool _disposed;
|
||
|
||
/// <summary>
|
||
/// 初始化一个热重载会话并立即开始监听文件变更。
|
||
/// </summary>
|
||
/// <param name="rootPath">配置根目录。</param>
|
||
/// <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>
|
||
public HotReloadSession(
|
||
string rootPath,
|
||
IDeserializer deserializer,
|
||
IConfigRegistry registry,
|
||
IEnumerable<IYamlTableRegistration> registrations,
|
||
IReadOnlyDictionary<string, IReadOnlyCollection<string>> initialDependencies,
|
||
Action<string>? onTableReloaded,
|
||
Action<string, Exception>? onTableReloadFailed,
|
||
TimeSpan debounceDelay)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(rootPath);
|
||
ArgumentNullException.ThrowIfNull(deserializer);
|
||
ArgumentNullException.ThrowIfNull(registry);
|
||
ArgumentNullException.ThrowIfNull(registrations);
|
||
ArgumentNullException.ThrowIfNull(initialDependencies);
|
||
|
||
_rootPath = rootPath;
|
||
_deserializer = deserializer;
|
||
_registry = registry;
|
||
_onTableReloaded = onTableReloaded;
|
||
_onTableReloadFailed = onTableReloadFailed;
|
||
_debounceDelay = debounceDelay;
|
||
|
||
foreach (var registration in registrations)
|
||
{
|
||
_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);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 释放热重载会话持有的文件监听器与等待资源。
|
||
/// </summary>
|
||
public void Dispose()
|
||
{
|
||
List<FileSystemWatcher> watchersToDispose;
|
||
List<CancellationTokenSource> reloadTokensToDispose;
|
||
List<SemaphoreSlim> reloadLocksToDispose;
|
||
|
||
lock (_gate)
|
||
{
|
||
if (_disposed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_disposed = true;
|
||
watchersToDispose = _watchers.ToList();
|
||
_watchers.Clear();
|
||
reloadTokensToDispose = _reloadTokens.Values.ToList();
|
||
_reloadTokens.Clear();
|
||
reloadLocksToDispose = _reloadLocks.Values.ToList();
|
||
_reloadLocks.Clear();
|
||
}
|
||
|
||
foreach (var reloadToken in reloadTokensToDispose)
|
||
{
|
||
reloadToken.Cancel();
|
||
reloadToken.Dispose();
|
||
}
|
||
|
||
foreach (var watcher in watchersToDispose)
|
||
{
|
||
watcher.Dispose();
|
||
}
|
||
|
||
foreach (var reloadLock in reloadLocksToDispose)
|
||
{
|
||
reloadLock.Dispose();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 停止热重载监听。
|
||
/// </summary>
|
||
public void UnRegister()
|
||
{
|
||
Dispose();
|
||
}
|
||
|
||
private void CreateWatchersForRegistration(IYamlTableRegistration registration)
|
||
{
|
||
var configDirectoryPath = Path.Combine(_rootPath, registration.RelativePath);
|
||
AddWatcher(configDirectoryPath, "*.yaml", registration.Name);
|
||
AddWatcher(configDirectoryPath, "*.yml", registration.Name);
|
||
|
||
if (string.IsNullOrEmpty(registration.SchemaRelativePath))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var schemaFullPath = Path.Combine(_rootPath, registration.SchemaRelativePath);
|
||
var schemaDirectoryPath = Path.GetDirectoryName(schemaFullPath);
|
||
if (string.IsNullOrWhiteSpace(schemaDirectoryPath))
|
||
{
|
||
schemaDirectoryPath = _rootPath;
|
||
}
|
||
|
||
AddWatcher(schemaDirectoryPath, Path.GetFileName(schemaFullPath), registration.Name);
|
||
}
|
||
|
||
private void AddWatcher(string directoryPath, string filter, string tableName)
|
||
{
|
||
if (!Directory.Exists(directoryPath))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var watcher = new FileSystemWatcher(directoryPath, filter)
|
||
{
|
||
IncludeSubdirectories = false,
|
||
NotifyFilter = NotifyFilters.FileName |
|
||
NotifyFilters.LastWrite |
|
||
NotifyFilters.Size |
|
||
NotifyFilters.CreationTime |
|
||
NotifyFilters.DirectoryName
|
||
};
|
||
|
||
watcher.Changed += (_, _) => ScheduleReload(tableName);
|
||
watcher.Created += (_, _) => ScheduleReload(tableName);
|
||
watcher.Deleted += (_, _) => ScheduleReload(tableName);
|
||
watcher.Renamed += (_, _) => ScheduleReload(tableName);
|
||
watcher.Error += (_, eventArgs) =>
|
||
{
|
||
var exception = eventArgs.GetException() ?? new InvalidOperationException(
|
||
$"Hot reload watcher for table '{tableName}' encountered an unknown error.");
|
||
InvokeReloadFailed(tableName, exception);
|
||
};
|
||
|
||
watcher.EnableRaisingEvents = true;
|
||
|
||
lock (_gate)
|
||
{
|
||
if (_disposed)
|
||
{
|
||
watcher.Dispose();
|
||
return;
|
||
}
|
||
|
||
_watchers.Add(watcher);
|
||
}
|
||
}
|
||
|
||
private void ScheduleReload(string tableName)
|
||
{
|
||
CancellationTokenSource reloadTokenSource;
|
||
|
||
lock (_gate)
|
||
{
|
||
if (_disposed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (_reloadTokens.TryGetValue(tableName, out var previousTokenSource))
|
||
{
|
||
previousTokenSource.Cancel();
|
||
previousTokenSource.Dispose();
|
||
}
|
||
|
||
reloadTokenSource = new CancellationTokenSource();
|
||
_reloadTokens[tableName] = reloadTokenSource;
|
||
}
|
||
|
||
_ = Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(_debounceDelay, reloadTokenSource.Token).ConfigureAwait(false);
|
||
await ReloadTableAsync(tableName, reloadTokenSource.Token).ConfigureAwait(false);
|
||
}
|
||
catch (OperationCanceledException) when (reloadTokenSource.IsCancellationRequested)
|
||
{
|
||
// 新事件会替换旧任务;取消属于正常防抖行为。
|
||
}
|
||
finally
|
||
{
|
||
lock (_gate)
|
||
{
|
||
if (_reloadTokens.TryGetValue(tableName, out var currentTokenSource) &&
|
||
ReferenceEquals(currentTokenSource, reloadTokenSource))
|
||
{
|
||
_reloadTokens.Remove(tableName);
|
||
}
|
||
}
|
||
|
||
reloadTokenSource.Dispose();
|
||
}
|
||
});
|
||
}
|
||
|
||
private async Task ReloadTableAsync(string tableName, CancellationToken cancellationToken)
|
||
{
|
||
if (!_registrations.ContainsKey(tableName))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var reloadLock = _reloadLocks[tableName];
|
||
await reloadLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||
|
||
try
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
|
||
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)
|
||
.ConfigureAwait(false));
|
||
}
|
||
|
||
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 (Exception exception)
|
||
{
|
||
InvokeReloadFailed(tableName, exception);
|
||
}
|
||
finally
|
||
{
|
||
reloadLock.Release();
|
||
}
|
||
}
|
||
|
||
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 (!ContainsDependency(dependency.Value, currentTableName))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (affectedTableNames.Add(dependency.Key))
|
||
{
|
||
pendingTableNames.Enqueue(dependency.Key);
|
||
}
|
||
}
|
||
}
|
||
|
||
return affectedTableNames
|
||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||
.ToArray();
|
||
}
|
||
|
||
private static bool ContainsDependency(
|
||
IReadOnlyCollection<string> dependencies,
|
||
string tableName)
|
||
{
|
||
return dependencies.Any(
|
||
dependency => string.Equals(dependency, tableName, StringComparison.Ordinal));
|
||
}
|
||
|
||
private void InvokeReloaded(string tableName)
|
||
{
|
||
if (_onTableReloaded == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
_onTableReloaded(tableName);
|
||
}
|
||
catch
|
||
{
|
||
// 诊断回调不应反向破坏热重载流程。
|
||
}
|
||
}
|
||
|
||
private void InvokeReloadFailed(string tableName, Exception exception)
|
||
{
|
||
if (_onTableReloadFailed == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
_onTableReloadFailed(tableName, exception);
|
||
}
|
||
catch
|
||
{
|
||
// 诊断回调不应反向破坏热重载流程。
|
||
}
|
||
}
|
||
}
|
||
}
|