From 13c91c88691390289d7f225b59e32d2cb949169e Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:48:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=A8=A1=E5=9D=97=E6=9E=B6=E6=9E=84=E9=9B=86=E6=88=90?= =?UTF-8?q?=E4=B8=8EYAML=E5=8A=A0=E8=BD=BD=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 GameConfigBootstrap 启动帮助器,统一管理配置注册表、YAML加载器与热重载句柄 - 创建 GameConfigModule 配置模块,集成到 Architecture 生命周期中完成自动加载与资源回收 - 实现 YamlConfigLoader 基于文件目录的YAML配置加载器,支持批量加载与热重载功能 - 添加 ArchitectureConfigIntegrationTests 集成测试,验证模块安装、加载顺序与表访问 - 实现热重载防抖机制,支持开发期配置变更监听与增量更新 - 提供同步上下文桥接支持,避免Unity主线程或UI线程上的死锁问题 --- .../ArchitectureConfigIntegrationTests.cs | 208 ++++++++++++++++-- GFramework.Game/Config/GameConfigBootstrap.cs | 8 +- GFramework.Game/Config/GameConfigModule.cs | 53 ++++- GFramework.Game/Config/YamlConfigLoader.cs | 21 +- .../Config/YamlConfigSchemaValidator.cs | 2 +- 5 files changed, 262 insertions(+), 30 deletions(-) diff --git a/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs b/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs index 7f7948a9..7e94d6a3 100644 --- a/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs +++ b/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs @@ -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 无需再自行阻塞等待配置系统完成启动。 /// [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 状态导致跨架构生命周期混淆。 /// [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 } } + /// + /// 验证配置启动帮助器在同步阻塞且存在 的线程上仍可完成初始化, + /// 避免架构生命周期钩子的同步桥接因为 await 捕获上下文而死锁。 + /// + [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); + } + } + + /// + /// 验证模块在架构已经越过安装窗口时会拒绝安装, + /// 并且失败不会消耗模块实例,便于后续在新的架构上重试安装。 + /// + [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(() => readyArchitecture.InstallModule(module)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain("BeforeUtilityInit")); + Assert.That(readyArchitecture.Context.GetUtilities(), 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 } /// - /// 创建一个使用配置模块的架构实例。 + /// 在不处理消息队列的同步上下文线程上执行阻塞等待, + /// 用于回归验证初始化异步链不会依赖原上下文恢复 continuation。 + /// + /// 要同步阻塞执行的异步操作。 + /// 等待线程结束的超时时间。 + private static void RunBlockingOnSynchronizationContext(Func 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)); + } + + /// + /// 创建一个使用配置模块的模块实例。 /// /// 测试配置根目录。 /// 已配置的模块实例。 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)); + } + + /// + /// 创建供测试复用的配置启动选项。 + /// + /// 测试配置根目录。 + /// 可用于模块或直接 bootstrap 的启动选项。 + 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; + /// /// 安装外部传入的配置模块。 /// @@ -293,6 +444,19 @@ public class ArchitectureConfigIntegrationTests } } + /// + /// 仅用于把架构推进到 Ready 阶段的空壳架构。 + /// + private sealed class ReadyOnlyArchitecture : Architecture + { + /// + /// 该测试架构不注册任何组件,仅验证模块的安装窗口约束。 + /// + protected override void OnInitialize() + { + } + } + /// /// 在 utility 初始化阶段直接读取配置表的探针工具。 /// 如果模块没有在 utility 阶段开始前完成首次加载,这个探针会在初始化时失败。 @@ -327,4 +491,20 @@ public class ArchitectureConfigIntegrationTests InitializedWithLoadedConfig = true; } } + + /// + /// 模拟一个不会主动处理 回调的阻塞线程上下文。 + /// 如果初始化链错误地捕获该上下文,continuation 会永久悬挂,从而暴露同步桥接死锁。 + /// + private sealed class NonPumpingSynchronizationContext : SynchronizationContext + { + /// + /// 丢弃异步投递的 continuation,模拟被同步阻塞且未泵消息的宿主线程。 + /// + /// 要执行的回调。 + /// 回调状态。 + public override void Post(SendOrPostCallback d, object? state) + { + } + } } diff --git a/GFramework.Game/Config/GameConfigBootstrap.cs b/GFramework.Game/Config/GameConfigBootstrap.cs index 242002be..9ffe34ec 100644 --- a/GFramework.Game/Config/GameConfigBootstrap.cs +++ b/GFramework.Game/Config/GameConfigBootstrap.cs @@ -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 /// 该方法只能成功调用一次,避免同一个生命周期对象在运行中被重新拼装为另一套加载约定。 /// /// 取消令牌。 + /// + /// 该入口会被 的同步生命周期钩子桥接调用, + /// 因此内部所有异步等待都必须使用 ConfigureAwait(false),避免在 Unity 主线程、 + /// UI Dispatcher 或带 的测试线程上发生同步阻塞死锁。 + /// /// 表示异步初始化流程的任务。 /// 当当前实例已释放时抛出。 /// 当当前实例已经初始化成功时抛出。 @@ -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) { diff --git a/GFramework.Game/Config/GameConfigModule.cs b/GFramework.Game/Config/GameConfigModule.cs index 087102d3..4b726877 100644 --- a/GFramework.Game/Config/GameConfigModule.cs +++ b/GFramework.Game/Config/GameConfigModule.cs @@ -20,6 +20,10 @@ namespace GFramework.Game.Config; /// 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; + } } /// @@ -127,7 +147,30 @@ public sealed class GameConfigModule : IArchitectureModule // 架构生命周期钩子当前是同步接口,因此这里显式桥接到统一的 bootstrap 异步实现, // 让 Architecture 模式和独立运行时模式保持同一套加载、诊断和热重载启动语义。 - bootstrap.InitializeAsync().GetAwaiter().GetResult(); + bootstrap.InitializeAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + } + } + + /// + /// 验证模块仍处于允许接入的生命周期窗口。 + /// 该模块依赖 钩子完成首次加载, + /// 因此一旦架构已经离开 ,继续安装只会错过首载时机。 + /// + /// 目标架构实例。 + /// + /// 当目标架构已经开始组件初始化阶段时抛出。 + /// + 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."); } } diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index 012caa32..15e38b1b 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -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 } } } -} \ No newline at end of file +} diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index f435c10a..381f321c 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -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) {