mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 17:21:16 +08:00
feat(config): 添加配置模块架构集成与YAML加载器
- 实现 GameConfigBootstrap 启动帮助器,统一管理配置注册表、YAML加载器与热重载句柄 - 创建 GameConfigModule 配置模块,集成到 Architecture 生命周期中完成自动加载与资源回收 - 实现 YamlConfigLoader 基于文件目录的YAML配置加载器,支持批量加载与热重载功能 - 添加 ArchitectureConfigIntegrationTests 集成测试,验证模块安装、加载顺序与表访问 - 实现热重载防抖机制,支持开发期配置变更监听与增量更新 - 提供同步上下文桥接支持,避免Unity主线程或UI线程上的死锁问题
This commit is contained in:
parent
f290050262
commit
13c91c8869
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user