feat(config): 添加配置模块集成测试和架构模块支持

- 新增 ArchitectureConfigIntegrationTests 验证配置模块在架构场景下的接入链路
- 添加 GameConfigModule 作为 Architecture 官方配置模块接入入口
- 实现模块生命周期管理,统一完成注册表暴露和首次加载
- 添加配置模块防止重复安装到多个架构的保护机制
- 验证配置模块在其他 utility 初始化前完成首次加载的顺序
- 更新中文文档详细说明配置系统接入模板和架构集成方式

 Conflicts:
	docs/zh-CN/game/config-system.md
This commit is contained in:
GeWuYou 2026-04-09 11:27:41 +08:00 committed by gewuyou
parent 55a51fb5d2
commit f290050262
3 changed files with 356 additions and 45 deletions

View File

@ -3,6 +3,8 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
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;
@ -11,17 +13,28 @@ 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 +58,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 +73,84 @@ public class ArchitectureConfigIntegrationTests
}
}
/// <summary>
/// 验证配置模块会在其他 utility 初始化之前完成首次加载,
/// 这样依赖配置的 utility 无需再自行阻塞等待配置系统完成启动。
/// </summary>
[Test]
public async Task ConfigModuleShould_Load_Config_Before_Dependent_Utility_Initialization()
{
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 GameConfigModuleShould_Reject_Reusing_The_Same_Module_Instance()
{
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);
}
}
private static string CreateTempConfigRoot()
{
var rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigArchitecture", Guid.NewGuid().ToString("N"));
@ -94,6 +187,26 @@ public class ArchitectureConfigIntegrationTests
}
}
/// <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 }
})
});
}
private const string MonsterSchemaJson = @"{
""title"": ""Monster Config"",
""description"": ""Defines one monster entry for the generated consumer integration test."",
@ -132,11 +245,8 @@ 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();
public ConsumerArchitecture(string configRoot)
{
@ -145,30 +255,76 @@ 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);
}
public GameConfigModule ConfigModule => _configModule;
public IConfigRegistry Registry => _configModule.Registry;
public MonsterTable MonsterTable => Registry.GetMonsterTable();
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
{
public GameConfigModule ConfigModule => configModule;
/// <summary>
/// 安装外部传入的配置模块。
/// </summary>
protected override void OnInitialize()
{
_bootstrap.Dispose();
await base.DestroyAsync();
InstallModule(configModule);
}
}
/// <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;
}
}
}

View File

@ -0,0 +1,156 @@
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 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>
public void Install(IArchitecture architecture)
{
ArgumentNullException.ThrowIfNull(architecture);
if (Interlocked.Exchange(ref _installState, 1) != 0)
{
throw new InvalidOperationException(
"The same GameConfigModule instance cannot be installed more than once.");
}
architecture.RegisterUtility(Registry);
architecture.RegisterUtility(_lifetimeUtility);
architecture.RegisterLifecycleHook(new BootstrapInitializationHook(_bootstrap));
}
/// <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().GetAwaiter().GetResult();
}
}
/// <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();
}
}
}

View File

@ -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}"),