feat(config): 添加配置模块架构集成与YAML加载器

- 实现 GameConfigBootstrap 启动帮助器,统一管理配置注册表、YAML加载器与热重载句柄
- 创建 GameConfigModule 配置模块,集成到 Architecture 生命周期中完成自动加载与资源回收
- 实现 YamlConfigLoader 基于文件目录的YAML配置加载器,支持批量加载与热重载功能
- 添加 ArchitectureConfigIntegrationTests 集成测试,验证模块安装、加载顺序与表访问
- 实现热重载防抖机制,支持开发期配置变更监听与增量更新
- 提供同步上下文桥接支持,避免Unity主线程或UI线程上的死锁问题
This commit is contained in:
GeWuYou 2026-04-09 14:48:05 +08:00
parent f290050262
commit 13c91c8869
5 changed files with 262 additions and 30 deletions

View File

@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GFramework.Core.Architectures;
using GFramework.Core.Extensions;
@ -78,7 +79,7 @@ public class ArchitectureConfigIntegrationTests
/// 这样依赖配置的 utility 无需再自行阻塞等待配置系统完成启动。
/// </summary>
[Test]
public async Task ConfigModuleShould_Load_Config_Before_Dependent_Utility_Initialization()
public async Task ConfigModuleShouldLoadConfigBeforeDependentUtilityInitialization()
{
var rootPath = CreateTempConfigRoot();
ConsumerArchitecture? architecture = null;
@ -112,7 +113,7 @@ public class ArchitectureConfigIntegrationTests
/// 避免共享内部 bootstrap 状态导致跨架构生命周期混淆。
/// </summary>
[Test]
public async Task GameConfigModuleShould_Reject_Reusing_The_Same_Module_Instance()
public async Task GameConfigModuleShouldRejectReusingTheSameModuleInstance()
{
var rootPath = CreateTempConfigRoot();
ModuleOnlyArchitecture? firstArchitecture = null;
@ -151,6 +152,99 @@ public class ArchitectureConfigIntegrationTests
}
}
/// <summary>
/// 验证配置启动帮助器在同步阻塞且存在 <see cref="SynchronizationContext" /> 的线程上仍可完成初始化,
/// 避免架构生命周期钩子的同步桥接因为 await 捕获上下文而死锁。
/// </summary>
[Test]
public void GameConfigBootstrapShouldSupportSynchronousBridgeOnBlockingSynchronizationContext()
{
var rootPath = CreateTempConfigRoot();
GameConfigBootstrap? bootstrap = null;
try
{
bootstrap = CreateBootstrap(rootPath);
RunBlockingOnSynchronizationContext(
() => bootstrap.InitializeAsync(),
TimeSpan.FromSeconds(5));
var monsterTable = bootstrap.Registry.GetMonsterTable();
Assert.Multiple(() =>
{
Assert.That(bootstrap.IsInitialized, Is.True);
Assert.That(monsterTable.Get(1).Name, Is.EqualTo("Slime"));
Assert.That(monsterTable.FindByFaction("dungeon").Count(), Is.EqualTo(2));
});
}
finally
{
bootstrap?.Dispose();
DeleteDirectoryIfExists(rootPath);
}
}
/// <summary>
/// 验证模块在架构已经越过安装窗口时会拒绝安装,
/// 并且失败不会消耗模块实例,便于后续在新的架构上重试安装。
/// </summary>
[Test]
public async Task GameConfigModuleShouldRejectLateInstallationWithoutConsumingTheModuleInstance()
{
var rootPath = CreateTempConfigRoot();
ReadyOnlyArchitecture? readyArchitecture = null;
ModuleOnlyArchitecture? retryArchitecture = null;
var readyArchitectureInitialized = false;
var retryArchitectureInitialized = false;
try
{
var module = CreateModule(rootPath);
readyArchitecture = new ReadyOnlyArchitecture();
await readyArchitecture.InitializeAsync();
readyArchitectureInitialized = true;
var exception = Assert.Throws<InvalidOperationException>(() => readyArchitecture.InstallModule(module));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("BeforeUtilityInit"));
Assert.That(readyArchitecture.Context.GetUtilities<IConfigRegistry>(), Is.Empty);
Assert.That(module.IsInitialized, Is.False);
});
await readyArchitecture.DestroyAsync();
readyArchitectureInitialized = false;
readyArchitecture = null;
GameContext.Clear();
retryArchitecture = new ModuleOnlyArchitecture(module);
await retryArchitecture.InitializeAsync();
retryArchitectureInitialized = true;
Assert.Multiple(() =>
{
Assert.That(module.IsInitialized, Is.True);
Assert.That(retryArchitecture.Registry.GetMonsterTable().Get(1).Name, Is.EqualTo("Slime"));
});
}
finally
{
if (retryArchitecture is not null && retryArchitectureInitialized)
{
await retryArchitecture.DestroyAsync();
}
if (readyArchitecture is not null && readyArchitectureInitialized)
{
await readyArchitecture.DestroyAsync();
}
DeleteDirectoryIfExists(rootPath);
}
}
private static string CreateTempConfigRoot()
{
var rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigArchitecture", Guid.NewGuid().ToString("N"));
@ -188,23 +282,78 @@ public class ArchitectureConfigIntegrationTests
}
/// <summary>
/// 创建一个使用配置模块的架构实例。
/// 在不处理消息队列的同步上下文线程上执行阻塞等待,
/// 用于回归验证初始化异步链不会依赖原上下文恢复 continuation。
/// </summary>
/// <param name="action">要同步阻塞执行的异步操作。</param>
/// <param name="timeout">等待线程结束的超时时间。</param>
private static void RunBlockingOnSynchronizationContext(Func<Task> action, TimeSpan timeout)
{
ArgumentNullException.ThrowIfNull(action);
Exception? capturedException = null;
var workerThread = new Thread(() =>
{
SynchronizationContext.SetSynchronizationContext(new NonPumpingSynchronizationContext());
try
{
action().GetAwaiter().GetResult();
}
catch (Exception exception)
{
capturedException = exception;
}
})
{
IsBackground = true
};
workerThread.Start();
if (!workerThread.Join(timeout))
{
Assert.Fail("The blocking synchronization-context bridge did not complete within the expected timeout.");
}
if (capturedException != null)
{
throw new AssertionException(
$"The blocking synchronization-context bridge failed: {capturedException}");
}
}
private static GameConfigBootstrap CreateBootstrap(string configRoot)
{
return new GameConfigBootstrap(CreateBootstrapOptions(configRoot));
}
/// <summary>
/// 创建一个使用配置模块的模块实例。
/// </summary>
/// <param name="configRoot">测试配置根目录。</param>
/// <returns>已配置的模块实例。</returns>
private static GameConfigModule CreateModule(string configRoot)
{
return new GameConfigModule(
new GameConfigBootstrapOptions
{
RootPath = configRoot,
ConfigureLoader = static loader =>
loader.RegisterAllGeneratedConfigTables(
new GeneratedConfigRegistrationOptions
{
IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain }
})
});
return new GameConfigModule(CreateBootstrapOptions(configRoot));
}
/// <summary>
/// 创建供测试复用的配置启动选项。
/// </summary>
/// <param name="configRoot">测试配置根目录。</param>
/// <returns>可用于模块或直接 bootstrap 的启动选项。</returns>
private static GameConfigBootstrapOptions CreateBootstrapOptions(string configRoot)
{
return new GameConfigBootstrapOptions
{
RootPath = configRoot,
ConfigureLoader = static loader =>
loader.RegisterAllGeneratedConfigTables(
new GeneratedConfigRegistrationOptions
{
IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain }
})
};
}
private const string MonsterSchemaJson = @"{
@ -284,6 +433,8 @@ public class ArchitectureConfigIntegrationTests
{
public GameConfigModule ConfigModule => configModule;
public IConfigRegistry Registry => configModule.Registry;
/// <summary>
/// 安装外部传入的配置模块。
/// </summary>
@ -293,6 +444,19 @@ public class ArchitectureConfigIntegrationTests
}
}
/// <summary>
/// 仅用于把架构推进到 Ready 阶段的空壳架构。
/// </summary>
private sealed class ReadyOnlyArchitecture : Architecture
{
/// <summary>
/// 该测试架构不注册任何组件,仅验证模块的安装窗口约束。
/// </summary>
protected override void OnInitialize()
{
}
}
/// <summary>
/// 在 utility 初始化阶段直接读取配置表的探针工具。
/// 如果模块没有在 utility 阶段开始前完成首次加载,这个探针会在初始化时失败。
@ -327,4 +491,20 @@ public class ArchitectureConfigIntegrationTests
InitializedWithLoadedConfig = true;
}
}
/// <summary>
/// 模拟一个不会主动处理 <see cref="SynchronizationContext.Post" /> 回调的阻塞线程上下文。
/// 如果初始化链错误地捕获该上下文continuation 会永久悬挂,从而暴露同步桥接死锁。
/// </summary>
private sealed class NonPumpingSynchronizationContext : SynchronizationContext
{
/// <summary>
/// 丢弃异步投递的 continuation模拟被同步阻塞且未泵消息的宿主线程。
/// </summary>
/// <param name="d">要执行的回调。</param>
/// <param name="state">回调状态。</param>
public override void Post(SendOrPostCallback d, object? state)
{
}
}
}

View File

@ -1,3 +1,4 @@
using System.Threading;
using GFramework.Core.Abstractions.Events;
using GFramework.Game.Abstractions.Config;
@ -127,6 +128,11 @@ public sealed class GameConfigBootstrap : IDisposable
/// 该方法只能成功调用一次,避免同一个生命周期对象在运行中被重新拼装为另一套加载约定。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <remarks>
/// 该入口会被 <see cref="GameConfigModule" /> 的同步生命周期钩子桥接调用,
/// 因此内部所有异步等待都必须使用 <c>ConfigureAwait(false)</c>,避免在 Unity 主线程、
/// UI Dispatcher 或带 <see cref="SynchronizationContext" /> 的测试线程上发生同步阻塞死锁。
/// </remarks>
/// <returns>表示异步初始化流程的任务。</returns>
/// <exception cref="ObjectDisposedException">当当前实例已释放时抛出。</exception>
/// <exception cref="InvalidOperationException">当当前实例已经初始化成功时抛出。</exception>
@ -152,7 +158,7 @@ public sealed class GameConfigBootstrap : IDisposable
{
var loader = new YamlConfigLoader(RootPath);
_options.ConfigureLoader!(loader);
await loader.LoadAsync(Registry, cancellationToken);
await loader.LoadAsync(Registry, cancellationToken).ConfigureAwait(false);
if (_options.EnableHotReload)
{

View File

@ -20,6 +20,10 @@ namespace GFramework.Game.Config;
/// </remarks>
public sealed class GameConfigModule : IArchitectureModule
{
private const int InstallStateNotInstalled = 0;
private const int InstallStateInstalling = 1;
private const int InstallStateInstalled = 2;
private readonly GameConfigBootstrap _bootstrap;
private readonly ModuleBootstrapLifetimeUtility _lifetimeUtility;
private int _installState;
@ -76,15 +80,31 @@ public sealed class GameConfigModule : IArchitectureModule
{
ArgumentNullException.ThrowIfNull(architecture);
if (Interlocked.Exchange(ref _installState, 1) != 0)
ValidateInstallationPhase(architecture);
if (Interlocked.CompareExchange(
ref _installState,
InstallStateInstalling,
InstallStateNotInstalled) != InstallStateNotInstalled)
{
throw new InvalidOperationException(
"The same GameConfigModule instance cannot be installed more than once.");
}
architecture.RegisterUtility(Registry);
architecture.RegisterUtility(_lifetimeUtility);
architecture.RegisterLifecycleHook(new BootstrapInitializationHook(_bootstrap));
try
{
// 先注册生命周期钩子,确保任何“已错过 BeforeUtilityInit”的安装都会在暴露注册表之前失败
// 避免架构看到永远不会完成首次加载的半安装配置入口。
architecture.RegisterLifecycleHook(new BootstrapInitializationHook(_bootstrap));
architecture.RegisterUtility(Registry);
architecture.RegisterUtility(_lifetimeUtility);
Volatile.Write(ref _installState, InstallStateInstalled);
}
catch
{
Volatile.Write(ref _installState, InstallStateNotInstalled);
throw;
}
}
/// <summary>
@ -127,7 +147,30 @@ public sealed class GameConfigModule : IArchitectureModule
// 架构生命周期钩子当前是同步接口,因此这里显式桥接到统一的 bootstrap 异步实现,
// 让 Architecture 模式和独立运行时模式保持同一套加载、诊断和热重载启动语义。
bootstrap.InitializeAsync().GetAwaiter().GetResult();
bootstrap.InitializeAsync().ConfigureAwait(false).GetAwaiter().GetResult();
}
}
/// <summary>
/// 验证模块仍处于允许接入的生命周期窗口。
/// 该模块依赖 <see cref="ArchitecturePhase.BeforeUtilityInit" /> 钩子完成首次加载,
/// 因此一旦架构已经离开 <see cref="ArchitecturePhase.None" />,继续安装只会错过首载时机。
/// </summary>
/// <param name="architecture">目标架构实例。</param>
/// <exception cref="InvalidOperationException">
/// 当目标架构已经开始组件初始化阶段时抛出。
/// </exception>
private static void ValidateInstallationPhase(IArchitecture architecture)
{
if (architecture is not Architecture concreteArchitecture)
{
return;
}
if (concreteArchitecture.CurrentPhase != ArchitecturePhase.None)
{
throw new InvalidOperationException(
"GameConfigModule must be installed before the architecture enters BeforeUtilityInit.");
}
}

View File

@ -69,7 +69,8 @@ public sealed class YamlConfigLoader : IConfigLoader
foreach (var registration in _registrations)
{
cancellationToken.ThrowIfCancellationRequested();
loadedTables.Add(await registration.LoadAsync(_rootPath, _deserializer, cancellationToken));
loadedTables.Add(
await registration.LoadAsync(_rootPath, _deserializer, cancellationToken).ConfigureAwait(false));
}
CrossTableReferenceValidator.Validate(registry, loadedTables);
@ -442,7 +443,8 @@ public sealed class YamlConfigLoader : IConfigLoader
if (!string.IsNullOrEmpty(SchemaRelativePath))
{
var schemaPath = Path.Combine(rootPath, SchemaRelativePath);
schema = await YamlConfigSchemaValidator.LoadAsync(Name, schemaPath, cancellationToken);
schema = await YamlConfigSchemaValidator.LoadAsync(Name, schemaPath, cancellationToken)
.ConfigureAwait(false);
referencedTableNames = schema.ReferencedTableNames;
}
@ -463,7 +465,7 @@ public sealed class YamlConfigLoader : IConfigLoader
string yaml;
try
{
yaml = await File.ReadAllTextAsync(file, cancellationToken);
yaml = await File.ReadAllTextAsync(file, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
@ -987,8 +989,8 @@ public sealed class YamlConfigLoader : IConfigLoader
{
try
{
await Task.Delay(_debounceDelay, reloadTokenSource.Token);
await ReloadTableAsync(tableName, reloadTokenSource.Token);
await Task.Delay(_debounceDelay, reloadTokenSource.Token).ConfigureAwait(false);
await ReloadTableAsync(tableName, reloadTokenSource.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (reloadTokenSource.IsCancellationRequested)
{
@ -1018,7 +1020,7 @@ public sealed class YamlConfigLoader : IConfigLoader
}
var reloadLock = _reloadLocks[tableName];
await reloadLock.WaitAsync(cancellationToken);
await reloadLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
@ -1031,8 +1033,9 @@ public sealed class YamlConfigLoader : IConfigLoader
foreach (var affectedTableName in affectedTableNames)
{
cancellationToken.ThrowIfCancellationRequested();
loadedTables.Add(await _registrations[affectedTableName].LoadAsync(_rootPath, _deserializer,
cancellationToken));
loadedTables.Add(
await _registrations[affectedTableName].LoadAsync(_rootPath, _deserializer, cancellationToken)
.ConfigureAwait(false));
}
CrossTableReferenceValidator.Validate(_registry, loadedTables);
@ -1125,4 +1128,4 @@ public sealed class YamlConfigLoader : IConfigLoader
}
}
}
}
}

View File

@ -51,7 +51,7 @@ internal static class YamlConfigSchemaValidator
string schemaText;
try
{
schemaText = await File.ReadAllTextAsync(schemaPath, cancellationToken);
schemaText = await File.ReadAllTextAsync(schemaPath, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{