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]
|
[TestFixture]
|
||||||
public class YamlConfigLoaderTests
|
public class YamlConfigLoaderTests
|
||||||
{
|
{
|
||||||
private string _rootPath = null!;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 为每个测试创建独立临时目录,避免文件系统状态互相污染。
|
/// 为每个测试创建独立临时目录,避免文件系统状态互相污染。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -33,6 +31,8 @@ public class YamlConfigLoaderTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string _rootPath = null!;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 验证加载器能够扫描 YAML 文件并将结果写入注册表。
|
/// 验证加载器能够扫描 YAML 文件并将结果写入注册表。
|
||||||
/// </summary>
|
/// </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>
|
||||||
/// 创建测试用配置文件。
|
/// 创建测试用配置文件。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -358,6 +495,24 @@ public class YamlConfigLoaderTests
|
|||||||
CreateConfigFile(relativePath, content);
|
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>
|
/// <summary>
|
||||||
/// 用于 YAML 加载测试的最小怪物配置类型。
|
/// 用于 YAML 加载测试的最小怪物配置类型。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
using GFramework.Core.Abstractions.Events;
|
||||||
using GFramework.Game.Abstractions.Config;
|
using GFramework.Game.Abstractions.Config;
|
||||||
using YamlDotNet.Serialization;
|
using YamlDotNet.Serialization;
|
||||||
using YamlDotNet.Serialization.NamingConventions;
|
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>
|
/// <summary>
|
||||||
/// 注册一个 YAML 配置表定义。
|
/// 注册一个 YAML 配置表定义。
|
||||||
/// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。
|
/// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。
|
||||||
@ -193,6 +223,21 @@ public sealed class YamlConfigLoader : IConfigLoader
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private interface IYamlTableRegistration
|
private interface IYamlTableRegistration
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取配置表名称。
|
||||||
|
/// </summary>
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取相对配置根目录的子目录。
|
||||||
|
/// </summary>
|
||||||
|
string RelativePath { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时返回空。
|
||||||
|
/// </summary>
|
||||||
|
string? SchemaRelativePath { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 从指定根目录加载配置表。
|
/// 从指定根目录加载配置表。
|
||||||
/// </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` 静默吞掉。
|
这样可以避免错误配置被默认值或 `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` 生成配置类型和表包装类。
|
配置生成器会从 `*.schema.json` 生成配置类型和表包装类。
|
||||||
@ -118,7 +152,6 @@ var slime = monsterTable.Get(1);
|
|||||||
|
|
||||||
以下能力尚未完全完成:
|
以下能力尚未完全完成:
|
||||||
|
|
||||||
- 运行时热重载
|
|
||||||
- 跨表引用校验
|
- 跨表引用校验
|
||||||
- 更完整的 JSON Schema 支持
|
- 更完整的 JSON Schema 支持
|
||||||
- 更强的 VS Code 表单编辑器
|
- 更强的 VS Code 表单编辑器
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user