mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-04-02 20:09:00 +08:00
feat(config): 添加YAML配置加载器及开发期热重载功能
- 实现YamlConfigLoader支持基于文件目录的YAML配置加载 - 添加EnableHotReload方法支持开发期配置文件变更自动重载 - 提供带schema校验的配置表注册功能 - 实现按表粒度的热重载机制及错误处理回调 - 添加配置文件变更监听和防抖处理 - 更新文档说明热重载使用方法和行为特性 - 移除未完成功能列表中的运行时热重载项
This commit is contained in:
parent
ae9693e0ff
commit
3332aaff7b
@ -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>
|
||||
|
||||
@ -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
|
||||
{
|
||||
// 诊断回调不应反向破坏热重载流程。
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 表单编辑器
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user