mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-06 16:16:44 +08:00
Merge pull request #197 from GeWuYou/feat/config-module-architecture-integration
feat(config): 添加配置模块集成测试和架构模块支持
This commit is contained in:
commit
73d2577fe1
@ -1,27 +1,37 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using GFramework.Core.Architectures;
|
||||
using GFramework.Core.Extensions;
|
||||
using GFramework.Core.Utility;
|
||||
using GFramework.Game.Abstractions.Config;
|
||||
using GFramework.Game.Config;
|
||||
using GFramework.Game.Config.Generated;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GFramework.Game.Tests.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 验证在 <see cref="Architecture" /> 初始化流程中可以通过官方配置启动帮助器加载生成配置表,并通过表访问器读取数据。
|
||||
/// 验证 <see cref="Architecture" /> 场景下的官方配置模块接入链路。
|
||||
/// 这些测试覆盖模块安装、utility 初始化顺序以及生成表访问,确保模块化入口能够替代手写 bootstrap 模板。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ArchitectureConfigIntegrationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 架构初始化期间,通过 <see cref="GameConfigBootstrap" /> 收敛生成表注册、加载与注册表暴露,
|
||||
/// 清理全局架构上下文,避免测试之间残留同类型架构绑定。
|
||||
/// </summary>
|
||||
[SetUp]
|
||||
[TearDown]
|
||||
public void ResetGlobalArchitectureContext()
|
||||
{
|
||||
GameContext.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 架构初始化期间,通过 <see cref="GameConfigModule" /> 收敛生成表注册、加载与注册表暴露,
|
||||
/// 并将 <see cref="IConfigRegistry" /> 作为 utility 暴露给架构上下文读取。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task ConfigLoaderCanRunDuringArchitectureInitialization()
|
||||
public async Task ConfigModuleCanRunDuringArchitectureInitialization()
|
||||
{
|
||||
var rootPath = CreateTempConfigRoot();
|
||||
ConsumerArchitecture? architecture = null;
|
||||
@ -45,6 +55,8 @@ public class ArchitectureConfigIntegrationTests
|
||||
Assert.That(retrieved!.Get(1).Name, Is.EqualTo("Slime"));
|
||||
Assert.That(architecture.Registry.TryGetItemTable(out _), Is.False);
|
||||
Assert.That(architecture.Context.GetUtility<IConfigRegistry>(), Is.SameAs(architecture.Registry));
|
||||
Assert.That(architecture.ConfigModule.IsInitialized, Is.True);
|
||||
Assert.That(architecture.ConfigModule.IsHotReloadEnabled, Is.False);
|
||||
});
|
||||
}
|
||||
finally
|
||||
@ -58,6 +70,183 @@ public class ArchitectureConfigIntegrationTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证配置模块会在其他 utility 初始化之前完成首次加载,
|
||||
/// 这样依赖配置的 utility 无需再自行阻塞等待配置系统完成启动。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task ConfigModuleShouldLoadConfigBeforeDependentUtilityInitialization()
|
||||
{
|
||||
var rootPath = CreateTempConfigRoot();
|
||||
ConsumerArchitecture? architecture = null;
|
||||
var initialized = false;
|
||||
try
|
||||
{
|
||||
architecture = new ConsumerArchitecture(rootPath);
|
||||
await architecture.InitializeAsync();
|
||||
initialized = true;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(architecture.ProbeUtility.InitializedWithLoadedConfig, Is.True);
|
||||
Assert.That(architecture.ProbeUtility.ObservedMonsterName, Is.EqualTo("Slime"));
|
||||
Assert.That(architecture.ProbeUtility.ObservedDungeonMonsterCount, Is.EqualTo(2));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (architecture is not null && initialized)
|
||||
{
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
|
||||
DeleteDirectoryIfExists(rootPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证同一个模块实例不会被重复安装到多个架构中,
|
||||
/// 避免共享内部 bootstrap 状态导致跨架构生命周期混淆。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task GameConfigModuleShouldRejectReusingTheSameModuleInstance()
|
||||
{
|
||||
var rootPath = CreateTempConfigRoot();
|
||||
ModuleOnlyArchitecture? firstArchitecture = null;
|
||||
var firstDestroyed = false;
|
||||
try
|
||||
{
|
||||
var module = CreateModule(rootPath);
|
||||
|
||||
firstArchitecture = new ModuleOnlyArchitecture(module);
|
||||
await firstArchitecture.InitializeAsync();
|
||||
var wasInitializedBeforeDestroy = module.IsInitialized;
|
||||
await firstArchitecture.DestroyAsync();
|
||||
firstDestroyed = true;
|
||||
firstArchitecture = null;
|
||||
GameContext.Clear();
|
||||
|
||||
var secondArchitecture = new ModuleOnlyArchitecture(module);
|
||||
var exception =
|
||||
Assert.ThrowsAsync<InvalidOperationException>(async () => await secondArchitecture.InitializeAsync());
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(wasInitializedBeforeDestroy, Is.True);
|
||||
Assert.That(exception, Is.Not.Null);
|
||||
Assert.That(exception!.Message, Does.Contain("cannot be installed more than once"));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (firstArchitecture is not null && !firstDestroyed)
|
||||
{
|
||||
await firstArchitecture.DestroyAsync();
|
||||
}
|
||||
|
||||
DeleteDirectoryIfExists(rootPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证配置模块在同步阻塞且存在 <see cref="SynchronizationContext" /> 的线程上仍可完成架构初始化,
|
||||
/// 直接覆盖 <see cref="GameConfigModule" /> 通过生命周期钩子执行同步桥接的真实路径。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void GameConfigModuleShouldSupportSynchronousBridgeOnBlockingSynchronizationContext()
|
||||
{
|
||||
var rootPath = CreateTempConfigRoot();
|
||||
ConsumerArchitecture? architecture = null;
|
||||
var initialized = false;
|
||||
try
|
||||
{
|
||||
architecture = new ConsumerArchitecture(rootPath);
|
||||
|
||||
RunBlockingOnSynchronizationContext(
|
||||
() => architecture.InitializeAsync(),
|
||||
TimeSpan.FromSeconds(5));
|
||||
initialized = true;
|
||||
|
||||
var monsterTable = architecture.Registry.GetMonsterTable();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(architecture.ConfigModule.IsInitialized, Is.True);
|
||||
Assert.That(monsterTable.Get(1).Name, Is.EqualTo("Slime"));
|
||||
Assert.That(monsterTable.FindByFaction("dungeon").Count(), Is.EqualTo(2));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (architecture is not null && initialized)
|
||||
{
|
||||
architecture.DestroyAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
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"));
|
||||
@ -94,6 +283,76 @@ 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个使用配置模块的模块实例。
|
||||
/// </summary>
|
||||
/// <param name="configRoot">测试配置根目录。</param>
|
||||
/// <returns>已配置的模块实例。</returns>
|
||||
private static GameConfigModule CreateModule(string configRoot)
|
||||
{
|
||||
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 = @"{
|
||||
""title"": ""Monster Config"",
|
||||
""description"": ""Defines one monster entry for the generated consumer integration test."",
|
||||
@ -132,12 +391,14 @@ public class ArchitectureConfigIntegrationTests
|
||||
|
||||
private sealed class ConsumerArchitecture : Architecture
|
||||
{
|
||||
private readonly GameConfigBootstrap _bootstrap;
|
||||
|
||||
public IConfigRegistry Registry => _bootstrap.Registry;
|
||||
|
||||
public MonsterTable MonsterTable { get; private set; } = null!;
|
||||
private readonly GameConfigModule _configModule;
|
||||
private readonly ConfigAwareProbeUtility _probeUtility = new();
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定配置根目录创建一个消费者测试架构。
|
||||
/// </summary>
|
||||
/// <param name="configRoot">测试配置根目录。</param>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="configRoot" /> 为空时抛出。</exception>
|
||||
public ConsumerArchitecture(string configRoot)
|
||||
{
|
||||
if (configRoot == null)
|
||||
@ -145,30 +406,151 @@ public class ArchitectureConfigIntegrationTests
|
||||
throw new ArgumentNullException(nameof(configRoot));
|
||||
}
|
||||
|
||||
_bootstrap = new GameConfigBootstrap(
|
||||
new GameConfigBootstrapOptions
|
||||
{
|
||||
RootPath = configRoot,
|
||||
ConfigureLoader = static loader =>
|
||||
loader.RegisterAllGeneratedConfigTables(
|
||||
new GeneratedConfigRegistrationOptions
|
||||
{
|
||||
IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain }
|
||||
})
|
||||
});
|
||||
_configModule = CreateModule(configRoot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前架构安装的配置模块。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该模块会在 <see cref="OnInitialize" /> 中安装,并在架构进入
|
||||
/// <see cref="GFramework.Core.Abstractions.Enums.ArchitecturePhase.BeforeUtilityInit" /> 时完成首次加载。
|
||||
/// 调用方可通过该属性观察模块初始化状态,但不应在架构初始化完成前假定加载已经成功。
|
||||
/// </remarks>
|
||||
public GameConfigModule ConfigModule => _configModule;
|
||||
|
||||
/// <summary>
|
||||
/// 获取由配置模块暴露到架构上下文中的注册表。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该属性在模块安装后即可访问同一个注册表实例,但只有在模块首次加载完成后,
|
||||
/// 其中的生成配置表读取才具备成功契约。
|
||||
/// </remarks>
|
||||
public IConfigRegistry Registry => _configModule.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// 获取测试使用的怪物配置表。
|
||||
/// </summary>
|
||||
/// <returns>已经从 <see cref="Registry" /> 中解析出的怪物表包装。</returns>
|
||||
/// <exception cref="InvalidOperationException">当模块首次加载尚未完成时抛出。</exception>
|
||||
/// <remarks>
|
||||
/// 该属性用于断言生成访问器在架构初始化完成后可直接读取;
|
||||
/// 它依赖模块在 utility 初始化阶段之前已经完成首次加载。
|
||||
/// </remarks>
|
||||
public MonsterTable MonsterTable => Registry.GetMonsterTable();
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于观测 utility 初始化阶段配置可见性的探针 utility。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该 utility 会在 <see cref="OnInitialize" /> 中注册,并在 utility 初始化阶段读取配置表,
|
||||
/// 用于验证配置模块是否按约定在更早的生命周期阶段完成首载。
|
||||
/// </remarks>
|
||||
public ConfigAwareProbeUtility ProbeUtility => _probeUtility;
|
||||
|
||||
/// <summary>
|
||||
/// 在用户初始化阶段安装配置模块,并注册一个依赖配置的测试 utility,
|
||||
/// 以验证模块会在 utility 初始化前完成首次加载。
|
||||
/// </summary>
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
RegisterUtility(Registry);
|
||||
_bootstrap.InitializeAsync().GetAwaiter().GetResult();
|
||||
MonsterTable = Registry.GetMonsterTable();
|
||||
InstallModule(_configModule);
|
||||
RegisterUtility(_probeUtility);
|
||||
}
|
||||
}
|
||||
|
||||
public override async ValueTask DestroyAsync()
|
||||
/// <summary>
|
||||
/// 用于验证模块复用限制的最小架构。
|
||||
/// </summary>
|
||||
private sealed class ModuleOnlyArchitecture(GameConfigModule configModule) : Architecture
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取安装到当前测试架构中的配置模块。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该属性直接暴露传入的模块实例,便于测试验证同一模块实例跨架构复用时的生命周期约束。
|
||||
/// </remarks>
|
||||
public GameConfigModule ConfigModule => configModule;
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前模块共享的配置注册表。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该注册表实例在模块安装后即与架构绑定,但只有在架构完成配置首载后,
|
||||
/// 其中的强类型配置表访问才应被视为可用。
|
||||
/// </remarks>
|
||||
public IConfigRegistry Registry => configModule.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// 安装外部传入的配置模块。
|
||||
/// </summary>
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
InstallModule(configModule);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅用于把架构推进到 Ready 阶段的空壳架构。
|
||||
/// </summary>
|
||||
private sealed class ReadyOnlyArchitecture : Architecture
|
||||
{
|
||||
/// <summary>
|
||||
/// 该测试架构不注册任何组件,仅验证模块的安装窗口约束。
|
||||
/// </summary>
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在 utility 初始化阶段直接读取配置表的探针工具。
|
||||
/// 如果模块没有在 utility 阶段开始前完成首次加载,这个探针会在初始化时失败。
|
||||
/// </summary>
|
||||
private sealed class ConfigAwareProbeUtility : AbstractContextUtility
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取一个值,指示初始化时是否已经读取到有效配置。
|
||||
/// </summary>
|
||||
public bool InitializedWithLoadedConfig { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取初始化期间读取到的怪物名称。
|
||||
/// </summary>
|
||||
public string? ObservedMonsterName { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取初始化期间读取到的 dungeon 阵营怪物数量。
|
||||
/// </summary>
|
||||
public int ObservedDungeonMonsterCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 读取架构上下文中的配置注册表并验证目标表已经可用。
|
||||
/// </summary>
|
||||
protected override void OnInit()
|
||||
{
|
||||
var registry = this.GetUtility<IConfigRegistry>();
|
||||
var monsterTable = registry.GetMonsterTable();
|
||||
|
||||
ObservedMonsterName = monsterTable.Get(1).Name;
|
||||
ObservedDungeonMonsterCount = monsterTable.FindByFaction("dungeon").Count();
|
||||
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)
|
||||
{
|
||||
_bootstrap.Dispose();
|
||||
await base.DestroyAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
206
GFramework.Game/Config/GameConfigModule.cs
Normal file
206
GFramework.Game/Config/GameConfigModule.cs
Normal file
@ -0,0 +1,206 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using GFramework.Core.Abstractions.Architectures;
|
||||
using GFramework.Core.Abstractions.Enums;
|
||||
using GFramework.Core.Architectures;
|
||||
using GFramework.Core.Utility;
|
||||
using GFramework.Game.Abstractions.Config;
|
||||
|
||||
namespace GFramework.Game.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 提供基于 <see cref="Architecture" /> 的官方配置模块接入入口。
|
||||
/// 该模块负责把 <see cref="GameConfigBootstrap" /> 挂接到架构生命周期中,统一完成注册表暴露、
|
||||
/// 首次加载以及架构销毁时的资源回收。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 使用该模块时,推荐在 <c>Architecture.OnInitialize()</c> 的较早位置调用 <see cref="IArchitecture.InstallModule" />,
|
||||
/// 以便其他 utility、model 和 system 在各自初始化阶段都能读取到已经完成首次加载的配置表。
|
||||
/// 如果消费项目不基于 <see cref="Architecture" />,则继续直接使用 <see cref="GameConfigBootstrap" /> 更合适。
|
||||
/// </remarks>
|
||||
public sealed class GameConfigModule : IArchitectureModule
|
||||
{
|
||||
private const int InstallStateNotInstalled = 0;
|
||||
private const int InstallStateInstalling = 1;
|
||||
private const int InstallStateConsumed = 2;
|
||||
|
||||
private readonly GameConfigBootstrap _bootstrap;
|
||||
private readonly ModuleBootstrapLifetimeUtility _lifetimeUtility;
|
||||
private int _installState;
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定的启动选项创建配置模块。
|
||||
/// </summary>
|
||||
/// <param name="options">配置启动帮助器选项。</param>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="options" /> 为空时抛出。</exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// 当 <paramref name="options" /> 不满足 <see cref="GameConfigBootstrap" /> 的构造约束时抛出。
|
||||
/// </exception>
|
||||
public GameConfigModule(GameConfigBootstrapOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_bootstrap = new GameConfigBootstrap(options);
|
||||
_lifetimeUtility = new ModuleBootstrapLifetimeUtility(_bootstrap);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前模块复用的配置注册表。
|
||||
/// 该实例会在模块安装时注册为架构 utility,供其他组件通过上下文直接读取。
|
||||
/// </summary>
|
||||
public IConfigRegistry Registry => _bootstrap.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// 获取一个值,指示模块绑定的配置启动器是否已经完成首次加载。
|
||||
/// </summary>
|
||||
public bool IsInitialized => _bootstrap.IsInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// 获取一个值,指示模块绑定的开发期热重载是否已启用。
|
||||
/// </summary>
|
||||
public bool IsHotReloadEnabled => _bootstrap.IsHotReloadEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前生效的 YAML 配置加载器。
|
||||
/// 只有在模块完成首次加载后该属性才可访问。
|
||||
/// </summary>
|
||||
/// <exception cref="ObjectDisposedException">当模块所属架构已销毁时抛出。</exception>
|
||||
/// <exception cref="InvalidOperationException">当首次加载尚未成功完成时抛出。</exception>
|
||||
public YamlConfigLoader Loader => _bootstrap.Loader;
|
||||
|
||||
/// <summary>
|
||||
/// 将配置模块安装到指定架构中。
|
||||
/// 安装后会立即暴露 <see cref="Registry" />,并注册一个生命周期钩子,
|
||||
/// 以便在 utility 初始化之前完成一次确定性的配置加载。
|
||||
/// </summary>
|
||||
/// <param name="architecture">目标架构实例。</param>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="architecture" /> 为空时抛出。</exception>
|
||||
/// <exception cref="InvalidOperationException">当同一个模块实例被重复安装时抛出。</exception>
|
||||
/// <remarks>
|
||||
/// 生命周期阶段校验会在任何注册动作前执行,因此错过安装窗口的调用不会消耗当前模块实例。
|
||||
/// 一旦开始向架构注册 utility 或生命周期钩子,就不存在回滚 API;因此后续任何失败都会把该模块实例视为已消耗,
|
||||
/// 调用方必须创建新的 <see cref="GameConfigModule" /> 再重试。
|
||||
/// </remarks>
|
||||
public void Install(IArchitecture architecture)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(architecture);
|
||||
|
||||
ValidateInstallationPhase(architecture);
|
||||
|
||||
if (Interlocked.CompareExchange(
|
||||
ref _installState,
|
||||
InstallStateInstalling,
|
||||
InstallStateNotInstalled) != InstallStateNotInstalled)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The same GameConfigModule instance cannot be installed more than once.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 阶段窗口已经在前面做过无副作用校验,因此这里优先注册 utility,
|
||||
// 让常见的容器/上下文接线失败在不可回滚的 hook 注册之前暴露出来。
|
||||
architecture.RegisterUtility(Registry);
|
||||
architecture.RegisterUtility(_lifetimeUtility);
|
||||
architecture.RegisterLifecycleHook(new BootstrapInitializationHook(_bootstrap));
|
||||
Volatile.Write(ref _installState, InstallStateConsumed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 架构对 utility / hook 注册都不提供回滚入口,因此一旦进入注册阶段,
|
||||
// 即使安装失败也必须禁止复用同一个模块实例,避免重复暴露共享注册表或挂上第二个 hook。
|
||||
Volatile.Write(ref _installState, InstallStateConsumed);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在首次加载成功后显式启用开发期热重载。
|
||||
/// 该入口与 <see cref="GameConfigBootstrap.StartHotReload" /> 的语义保持一致,
|
||||
/// 供已经保留模块实例引用的架构启动层按环境决定是否追加监听。
|
||||
/// </summary>
|
||||
/// <param name="options">热重载选项;为空时使用默认行为。</param>
|
||||
public void StartHotReload(YamlConfigHotReloadOptions? options = null)
|
||||
{
|
||||
_bootstrap.StartHotReload(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止开发期热重载并释放监听资源。
|
||||
/// 该方法是幂等的,允许架构外部的开发期开关无条件调用。
|
||||
/// </summary>
|
||||
public void StopHotReload()
|
||||
{
|
||||
_bootstrap.StopHotReload();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在 utility 初始化之前完成首次配置加载的生命周期钩子。
|
||||
/// 这样后续 utility、model 和 system 在各自初始化阶段就能直接依赖已加载的注册表。
|
||||
/// </summary>
|
||||
private sealed class BootstrapInitializationHook(GameConfigBootstrap bootstrap) : IArchitectureLifecycleHook
|
||||
{
|
||||
/// <summary>
|
||||
/// 在目标阶段触发配置加载。
|
||||
/// </summary>
|
||||
/// <param name="phase">当前架构阶段。</param>
|
||||
/// <param name="architecture">相关架构实例;当前实现不直接使用,但保留用于接口契约一致性。</param>
|
||||
public void OnPhase(ArchitecturePhase phase, IArchitecture architecture)
|
||||
{
|
||||
if (phase != ArchitecturePhase.BeforeUtilityInit)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 架构生命周期钩子当前是同步接口,因此这里显式桥接到统一的 bootstrap 异步实现,
|
||||
// 让 Architecture 模式和独立运行时模式保持同一套加载、诊断和热重载启动语义。
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 跟随架构 utility 生命周期释放底层 bootstrap 资源的薄封装。
|
||||
/// 该 utility 本身不承担加载职责,只负责在架构销毁时停止热重载并释放监听句柄。
|
||||
/// </summary>
|
||||
private sealed class ModuleBootstrapLifetimeUtility(GameConfigBootstrap bootstrap) : AbstractContextUtility
|
||||
{
|
||||
/// <summary>
|
||||
/// 该 utility 不需要在初始化阶段执行额外逻辑。
|
||||
/// 首次加载已经由 <see cref="BootstrapInitializationHook" /> 在 utility 初始化前完成。
|
||||
/// </summary>
|
||||
protected override void OnInit()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 架构销毁时释放 bootstrap 资源,确保热重载监听句柄不会泄漏到架构生命周期之外。
|
||||
/// </summary>
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
bootstrap.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -168,7 +168,7 @@ GameProject/
|
||||
|
||||
这段配置的作用:
|
||||
|
||||
- `GFramework.Game` 提供运行时 `YamlConfigLoader`、`ConfigRegistry`、`GameConfigBootstrap` 和只读表实现
|
||||
- `GFramework.Game` 提供运行时 `YamlConfigLoader`、`ConfigRegistry`、`GameConfigBootstrap`、`GameConfigModule` 和只读表实现
|
||||
- 三个 `ProjectReference(... OutputItemType="Analyzer")` 把生成器接进当前消费者项目
|
||||
- `GeWuYou.GFramework.SourceGenerators.targets` 自动把 `schemas/**/*.schema.json` 加入 `AdditionalFiles`
|
||||
|
||||
@ -404,7 +404,7 @@ if (monsterTable.TryFindFirstByFaction("dungeon", out var firstDungeonMonster))
|
||||
|
||||
### Architecture 推荐接入模板
|
||||
|
||||
如果你的项目已经基于 `GFramework.Core.Architectures.Architecture` 组织初始化流程,并且当前宿主没有提供更上层的异步启动入口,可以把配置系统接到 `OnInitialize()` 阶段,并把 `GameConfigBootstrap.Registry` 注册为 utility:
|
||||
如果你的项目已经基于 `GFramework.Core.Architectures.Architecture` 组织初始化流程,推荐优先使用 `GameConfigModule`,而不是在 `OnInitialize()` 里手动拼装 `GameConfigBootstrap` 的注册、加载和销毁顺序:
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Architectures;
|
||||
@ -414,11 +414,11 @@ using GFramework.Game.Config.Generated;
|
||||
|
||||
public sealed class GameArchitecture : Architecture
|
||||
{
|
||||
private readonly GameConfigBootstrap _configBootstrap;
|
||||
private readonly GameConfigModule _configModule;
|
||||
|
||||
public GameArchitecture(string configRootPath)
|
||||
{
|
||||
_configBootstrap = new GameConfigBootstrap(
|
||||
_configModule = new GameConfigModule(
|
||||
new GameConfigBootstrapOptions
|
||||
{
|
||||
RootPath = configRootPath,
|
||||
@ -428,14 +428,7 @@ public sealed class GameArchitecture : Architecture
|
||||
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
RegisterUtility(_configBootstrap.Registry);
|
||||
_configBootstrap.InitializeAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public override async ValueTask DestroyAsync()
|
||||
{
|
||||
_configBootstrap.Dispose();
|
||||
await base.DestroyAsync();
|
||||
InstallModule(_configModule);
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -456,22 +449,28 @@ var slime = monsterTable.Get(1);
|
||||
|
||||
推荐遵循以下顺序:
|
||||
|
||||
- 先构造 `GameConfigBootstrap`
|
||||
- 在 `OnInitialize()` 里注册 `bootstrap.Registry`
|
||||
- 再调用 `bootstrap.InitializeAsync()` 完成首次加载
|
||||
- 架构销毁时释放 `GameConfigBootstrap`
|
||||
- 先构造 `GameConfigModule`
|
||||
- 在 `OnInitialize()` 的较早位置调用 `InstallModule(_configModule)`
|
||||
- 让模块在 `BeforeUtilityInit` 阶段完成首次加载
|
||||
- 架构销毁时让模块跟随 utility 生命周期自动释放 `GameConfigBootstrap`
|
||||
- 初始化完成后只通过注册表和生成表包装访问配置
|
||||
|
||||
当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + GameConfigBootstrap + RegisterAllGeneratedConfigTables()` 组合已经足够作为官方推荐接入路径。
|
||||
这样做的收益是:
|
||||
|
||||
- `IConfigRegistry` 会在模块安装时立即注册为 utility,后续组件统一从上下文读取
|
||||
- 首次加载发生在 `BeforeUtilityInit`,因此依赖配置的 utility、model 和 system 在自己的初始化阶段就能直接读取表
|
||||
- 架构销毁时不再需要手写 `Dispose()` 样板来停止热重载句柄
|
||||
|
||||
如果你仍然需要在架构外直接控制 `InitializeAsync()`、`StartHotReload(...)` 或 `StopHotReload()` 的调用时机,继续直接使用 `GameConfigBootstrap` 仍然是合适的;`GameConfigModule` 是面向 `Architecture` 宿主的官方薄封装,而不是替代底层 bootstrap。
|
||||
|
||||
### 热重载模板
|
||||
|
||||
如果你希望把开发期热重载显式收敛为一个可选能力,推荐直接通过 `GameConfigBootstrap.StartHotReload(...)` 管理,而不是让监听句柄散落在启动层之外:
|
||||
如果你希望把开发期热重载显式收敛为一个可选能力,在 `Architecture` 场景下可以直接保留上面示例中的 `_configModule` 字段并调用 `GameConfigModule.StartHotReload(...)`;非 `Architecture` 场景则继续直接通过 `GameConfigBootstrap.StartHotReload(...)` 管理,而不是让监听句柄散落在启动层之外:
|
||||
|
||||
```csharp
|
||||
await bootstrap.InitializeAsync();
|
||||
await architecture.InitializeAsync();
|
||||
|
||||
bootstrap.StartHotReload(
|
||||
_configModule.StartHotReload(
|
||||
new YamlConfigHotReloadOptions
|
||||
{
|
||||
OnTableReloaded = tableName => Console.WriteLine($"Reloaded: {tableName}"),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user