feat(config): 添加YAML配置加载器及开发期热重载功能

- 实现YamlConfigLoader支持基于文件目录的YAML配置加载
- 添加EnableHotReload方法支持开发期配置文件变更自动重载
- 提供带schema校验的配置表注册功能
- 实现按表粒度的热重载机制及错误处理回调
- 添加配置文件变更监听和防抖处理
- 更新文档说明热重载使用方法和行为特性
- 移除未完成功能列表中的运行时热重载项
This commit is contained in:
GeWuYou 2026-03-31 22:39:39 +08:00
parent ae9693e0ff
commit 3332aaff7b
3 changed files with 522 additions and 3 deletions

View File

@ -9,8 +9,6 @@ namespace GFramework.Game.Tests.Config;
[TestFixture]
public class YamlConfigLoaderTests
{
private string _rootPath = null!;
/// <summary>
/// 为每个测试创建独立临时目录,避免文件系统状态互相污染。
/// </summary>
@ -33,6 +31,8 @@ public class YamlConfigLoaderTests
}
}
private string _rootPath = null!;
/// <summary>
/// 验证加载器能够扫描 YAML 文件并将结果写入注册表。
/// </summary>
@ -331,6 +331,143 @@ public class YamlConfigLoaderTests
});
}
/// <summary>
/// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。
/// </summary>
[Test]
public async Task EnableHotReload_Should_Update_Registered_Table_When_Config_File_Changes()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
hp: 10
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"hp": { "type": "integer" }
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
await loader.LoadAsync(registry);
var reloadTaskSource = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
var hotReload = loader.EnableHotReload(
registry,
onTableReloaded: tableName => reloadTaskSource.TrySetResult(tableName),
debounceDelay: TimeSpan.FromMilliseconds(150));
try
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
hp: 25
""");
var tableName = await WaitForTaskWithinAsync(reloadTaskSource.Task, TimeSpan.FromSeconds(5));
Assert.Multiple(() =>
{
Assert.That(tableName, Is.EqualTo("monster"));
Assert.That(registry.GetTable<int, MonsterConfigStub>("monster").Get(1).Hp, Is.EqualTo(25));
});
}
finally
{
hotReload.UnRegister();
}
}
/// <summary>
/// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。
/// </summary>
[Test]
public async Task EnableHotReload_Should_Keep_Previous_Table_When_Schema_Change_Makes_Reload_Fail()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
hp: 10
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"hp": { "type": "integer" }
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterConfigStub>("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
{
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "rarity"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"hp": { "type": "integer" },
"rarity": { "type": "string" }
}
}
""");
var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5));
Assert.Multiple(() =>
{
Assert.That(failure.TableName, Is.EqualTo("monster"));
Assert.That(failure.Exception.Message, Does.Contain("rarity"));
Assert.That(registry.GetTable<int, MonsterConfigStub>("monster").Get(1).Hp, Is.EqualTo(10));
});
}
finally
{
hotReload.UnRegister();
}
}
/// <summary>
/// 创建测试用配置文件。
/// </summary>
@ -358,6 +495,24 @@ public class YamlConfigLoaderTests
CreateConfigFile(relativePath, content);
}
/// <summary>
/// 在限定时间内等待异步任务完成,避免文件监听测试无限挂起。
/// </summary>
/// <typeparam name="T">任务结果类型。</typeparam>
/// <param name="task">要等待的任务。</param>
/// <param name="timeout">超时时间。</param>
/// <returns>任务结果。</returns>
private static async Task<T> WaitForTaskWithinAsync<T>(Task<T> task, TimeSpan timeout)
{
var completedTask = await Task.WhenAny(task, Task.Delay(timeout));
if (!ReferenceEquals(completedTask, task))
{
Assert.Fail($"Timed out after {timeout} while waiting for file watcher notification.");
}
return await task;
}
/// <summary>
/// 用于 YAML 加载测试的最小怪物配置类型。
/// </summary>

View File

@ -1,3 +1,4 @@
using GFramework.Core.Abstractions.Events;
using GFramework.Game.Abstractions.Config;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
@ -71,6 +72,35 @@ public sealed class YamlConfigLoader : IConfigLoader
}
}
/// <summary>
/// 启用开发期热重载。
/// 该能力会监听已注册配置表对应的配置目录和 schema 文件,并在检测到文件变更后按表粒度重新加载。
/// 重载失败时会保留注册表中的旧表,避免开发期错误配置直接破坏当前运行时状态。
/// </summary>
/// <param name="registry">要被热重载更新的配置注册表。</param>
/// <param name="onTableReloaded">单个配置表重载成功后的可选回调。</param>
/// <param name="onTableReloadFailed">单个配置表重载失败后的可选回调。</param>
/// <param name="debounceDelay">防抖延迟;为空时默认使用 200 毫秒。</param>
/// <returns>用于停止热重载监听的注销句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为空时抛出。</exception>
public IUnRegister EnableHotReload(
IConfigRegistry registry,
Action<string>? onTableReloaded = null,
Action<string, Exception>? onTableReloadFailed = null,
TimeSpan? debounceDelay = null)
{
ArgumentNullException.ThrowIfNull(registry);
return new HotReloadSession(
_rootPath,
_deserializer,
registry,
_registrations,
onTableReloaded,
onTableReloadFailed,
debounceDelay ?? TimeSpan.FromMilliseconds(200));
}
/// <summary>
/// 注册一个 YAML 配置表定义。
/// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。
@ -193,6 +223,21 @@ public sealed class YamlConfigLoader : IConfigLoader
/// </summary>
private interface IYamlTableRegistration
{
/// <summary>
/// 获取配置表名称。
/// </summary>
string Name { get; }
/// <summary>
/// 获取相对配置根目录的子目录。
/// </summary>
string RelativePath { get; }
/// <summary>
/// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时返回空。
/// </summary>
string? SchemaRelativePath { get; }
/// <summary>
/// 从指定根目录加载配置表。
/// </summary>
@ -337,4 +382,290 @@ public sealed class YamlConfigLoader : IConfigLoader
}
}
}
/// <summary>
/// 封装开发期热重载所需的文件监听与按表重载逻辑。
/// 该会话只影响通过当前加载器注册的表,不尝试接管注册表中的其他来源数据。
/// </summary>
private sealed class HotReloadSession : IUnRegister, IDisposable
{
private readonly TimeSpan _debounceDelay;
private readonly IDeserializer _deserializer;
private readonly object _gate = new();
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="onTableReloaded">单表重载成功回调。</param>
/// <param name="onTableReloadFailed">单表重载失败回调。</param>
/// <param name="debounceDelay">监听事件防抖延迟。</param>
public HotReloadSession(
string rootPath,
IDeserializer deserializer,
IConfigRegistry registry,
IEnumerable<IYamlTableRegistration> registrations,
Action<string>? onTableReloaded,
Action<string, Exception>? onTableReloadFailed,
TimeSpan debounceDelay)
{
ArgumentNullException.ThrowIfNull(rootPath);
ArgumentNullException.ThrowIfNull(deserializer);
ArgumentNullException.ThrowIfNull(registry);
ArgumentNullException.ThrowIfNull(registrations);
_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));
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);
await ReloadTableAsync(tableName, reloadTokenSource.Token);
}
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.TryGetValue(tableName, out var registration))
{
return;
}
var reloadLock = _reloadLocks[tableName];
await reloadLock.WaitAsync(cancellationToken);
try
{
cancellationToken.ThrowIfCancellationRequested();
var (name, table) = await registration.LoadAsync(_rootPath, _deserializer, cancellationToken);
RegistrationDispatcher.Register(_registry, name, table);
InvokeReloaded(name);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// 防抖替换或会话关闭导致的取消不应视为错误。
}
catch (Exception exception)
{
InvokeReloadFailed(tableName, exception);
}
finally
{
reloadLock.Release();
}
}
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
{
// 诊断回调不应反向破坏热重载流程。
}
}
}
}

View File

@ -94,6 +94,40 @@ var slime = monsterTable.Get(1);
这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。
## 开发期热重载
如果你希望在开发期修改配置文件后自动刷新运行时表,可以在初次加载完成后启用热重载:
```csharp
using GFramework.Game.Config;
var registry = new ConfigRegistry();
var loader = new YamlConfigLoader("config-root")
.RegisterTable<int, MonsterConfig>(
"monster",
"monster",
"schemas/monster.schema.json",
static config => config.Id);
await loader.LoadAsync(registry);
var hotReload = loader.EnableHotReload(
registry,
onTableReloaded: tableName => Console.WriteLine($"Reloaded: {tableName}"),
onTableReloadFailed: (tableName, exception) =>
Console.WriteLine($"Reload failed: {tableName}, {exception.Message}"));
```
当前热重载行为如下:
- 监听已注册表对应的配置目录
- 监听该表绑定的 schema 文件
- 检测到变更后按表粒度重载
- 重载成功后替换该表在 `IConfigRegistry` 中的注册
- 重载失败时保留旧表,并通过失败回调提供诊断
这项能力默认定位为开发期工具,不承诺生产环境热更新平台语义。
## 生成器接入约定
配置生成器会从 `*.schema.json` 生成配置类型和表包装类。
@ -118,7 +152,6 @@ var slime = monsterTable.Get(1);
以下能力尚未完全完成:
- 运行时热重载
- 跨表引用校验
- 更完整的 JSON Schema 支持
- 更强的 VS Code 表单编辑器