From 3332aaff7bf384663953583c94d5c093a71b2529 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:39:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0YAML=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=8A=A0=E8=BD=BD=E5=99=A8=E5=8F=8A=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E6=9C=9F=E7=83=AD=E9=87=8D=E8=BD=BD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现YamlConfigLoader支持基于文件目录的YAML配置加载 - 添加EnableHotReload方法支持开发期配置文件变更自动重载 - 提供带schema校验的配置表注册功能 - 实现按表粒度的热重载机制及错误处理回调 - 添加配置文件变更监听和防抖处理 - 更新文档说明热重载使用方法和行为特性 - 移除未完成功能列表中的运行时热重载项 --- .../Config/YamlConfigLoaderTests.cs | 159 ++++++++- GFramework.Game/Config/YamlConfigLoader.cs | 331 ++++++++++++++++++ docs/zh-CN/game/config-system.md | 35 +- 3 files changed, 522 insertions(+), 3 deletions(-) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index c4d5e81..236bf34 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -9,8 +9,6 @@ namespace GFramework.Game.Tests.Config; [TestFixture] public class YamlConfigLoaderTests { - private string _rootPath = null!; - /// /// 为每个测试创建独立临时目录,避免文件系统状态互相污染。 /// @@ -33,6 +31,8 @@ public class YamlConfigLoaderTests } } + private string _rootPath = null!; + /// /// 验证加载器能够扫描 YAML 文件并将结果写入注册表。 /// @@ -331,6 +331,143 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。 + /// + [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("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + await loader.LoadAsync(registry); + + var reloadTaskSource = new TaskCompletionSource(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("monster").Get(1).Hp, Is.EqualTo(25)); + }); + } + finally + { + hotReload.UnRegister(); + } + } + + /// + /// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。 + /// + [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("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("monster").Get(1).Hp, Is.EqualTo(10)); + }); + } + finally + { + hotReload.UnRegister(); + } + } + /// /// 创建测试用配置文件。 /// @@ -358,6 +495,24 @@ public class YamlConfigLoaderTests CreateConfigFile(relativePath, content); } + /// + /// 在限定时间内等待异步任务完成,避免文件监听测试无限挂起。 + /// + /// 任务结果类型。 + /// 要等待的任务。 + /// 超时时间。 + /// 任务结果。 + private static async Task WaitForTaskWithinAsync(Task 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; + } + /// /// 用于 YAML 加载测试的最小怪物配置类型。 /// diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index 11f2d99..6570035 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -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 } } + /// + /// 启用开发期热重载。 + /// 该能力会监听已注册配置表对应的配置目录和 schema 文件,并在检测到文件变更后按表粒度重新加载。 + /// 重载失败时会保留注册表中的旧表,避免开发期错误配置直接破坏当前运行时状态。 + /// + /// 要被热重载更新的配置注册表。 + /// 单个配置表重载成功后的可选回调。 + /// 单个配置表重载失败后的可选回调。 + /// 防抖延迟;为空时默认使用 200 毫秒。 + /// 用于停止热重载监听的注销句柄。 + /// 为空时抛出。 + public IUnRegister EnableHotReload( + IConfigRegistry registry, + Action? onTableReloaded = null, + Action? onTableReloadFailed = null, + TimeSpan? debounceDelay = null) + { + ArgumentNullException.ThrowIfNull(registry); + + return new HotReloadSession( + _rootPath, + _deserializer, + registry, + _registrations, + onTableReloaded, + onTableReloadFailed, + debounceDelay ?? TimeSpan.FromMilliseconds(200)); + } + /// /// 注册一个 YAML 配置表定义。 /// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。 @@ -193,6 +223,21 @@ public sealed class YamlConfigLoader : IConfigLoader /// private interface IYamlTableRegistration { + /// + /// 获取配置表名称。 + /// + string Name { get; } + + /// + /// 获取相对配置根目录的子目录。 + /// + string RelativePath { get; } + + /// + /// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时返回空。 + /// + string? SchemaRelativePath { get; } + /// /// 从指定根目录加载配置表。 /// @@ -337,4 +382,290 @@ public sealed class YamlConfigLoader : IConfigLoader } } } + + /// + /// 封装开发期热重载所需的文件监听与按表重载逻辑。 + /// 该会话只影响通过当前加载器注册的表,不尝试接管注册表中的其他来源数据。 + /// + private sealed class HotReloadSession : IUnRegister, IDisposable + { + private readonly TimeSpan _debounceDelay; + private readonly IDeserializer _deserializer; + private readonly object _gate = new(); + private readonly Action? _onTableReloaded; + private readonly Action? _onTableReloadFailed; + private readonly Dictionary _registrations = new(StringComparer.Ordinal); + private readonly IConfigRegistry _registry; + private readonly Dictionary _reloadLocks = new(StringComparer.Ordinal); + private readonly Dictionary _reloadTokens = new(StringComparer.Ordinal); + private readonly string _rootPath; + private readonly List _watchers = new(); + private bool _disposed; + + /// + /// 初始化一个热重载会话并立即开始监听文件变更。 + /// + /// 配置根目录。 + /// YAML 反序列化器。 + /// 要更新的配置注册表。 + /// 已注册的配置表定义。 + /// 单表重载成功回调。 + /// 单表重载失败回调。 + /// 监听事件防抖延迟。 + public HotReloadSession( + string rootPath, + IDeserializer deserializer, + IConfigRegistry registry, + IEnumerable registrations, + Action? onTableReloaded, + Action? 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); + } + } + + /// + /// 释放热重载会话持有的文件监听器与等待资源。 + /// + public void Dispose() + { + List watchersToDispose; + List reloadTokensToDispose; + List 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(); + } + } + + /// + /// 停止热重载监听。 + /// + 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 + { + // 诊断回调不应反向破坏热重载流程。 + } + } + } } \ No newline at end of file diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 87a0cbe..591febc 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -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( + "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 表单编辑器