mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
feat(config): 添加配置系统集成测试和官方启动帮助器
- 添加 ArchitectureConfigIntegrationTests 验证架构初始化流程中配置加载 - 实现 GameConfigBootstrap 收敛配置注册、加载与热重载生命周期管理 - 提供 GameConfigBootstrapOptions 配置启动约定选项对象 - 添加 GameConfigBootstrapTests 验证启动帮助器功能完整性 - 更新中文文档详述配置系统接入模板和最佳实践 - 提供 Architecture 推荐接入模板简化框架集成步骤 - 实现热重载支持和错误诊断机制提升开发体验
This commit is contained in:
parent
a76630ad16
commit
67149ab2b2
@ -3,6 +3,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using GFramework.Core.Architectures;
|
||||
using GFramework.Game.Abstractions.Config;
|
||||
using GFramework.Game.Config;
|
||||
using GFramework.Game.Config.Generated;
|
||||
using NUnit.Framework;
|
||||
@ -10,14 +11,14 @@ using NUnit.Framework;
|
||||
namespace GFramework.Game.Tests.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 验证在 <see cref="Architecture" /> 初始化流程中可以通过聚合注册入口加载生成配置表,并通过表访问器读取数据。
|
||||
/// 验证在 <see cref="Architecture" /> 初始化流程中可以通过官方配置启动帮助器加载生成配置表,并通过表访问器读取数据。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ArchitectureConfigIntegrationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 架构初始化期间,通过 <see cref="YamlConfigLoader" /> 的聚合注册入口注册生成表,
|
||||
/// 并将 <see cref="ConfigRegistry" /> 作为 utility 暴露给架构上下文读取。
|
||||
/// 架构初始化期间,通过 <see cref="GameConfigBootstrap" /> 收敛生成表注册、加载与注册表暴露,
|
||||
/// 并将 <see cref="IConfigRegistry" /> 作为 utility 暴露给架构上下文读取。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task ConfigLoaderCanRunDuringArchitectureInitialization()
|
||||
@ -43,7 +44,7 @@ public class ArchitectureConfigIntegrationTests
|
||||
Assert.That(retrieved, Is.Not.Null);
|
||||
Assert.That(retrieved!.Get(1).Name, Is.EqualTo("Slime"));
|
||||
Assert.That(architecture.Registry.TryGetItemTable(out _), Is.False);
|
||||
Assert.That(architecture.Context.GetUtility<ConfigRegistry>(), Is.SameAs(architecture.Registry));
|
||||
Assert.That(architecture.Context.GetUtility<IConfigRegistry>(), Is.SameAs(architecture.Registry));
|
||||
});
|
||||
}
|
||||
finally
|
||||
@ -131,30 +132,43 @@ public class ArchitectureConfigIntegrationTests
|
||||
|
||||
private sealed class ConsumerArchitecture : Architecture
|
||||
{
|
||||
private readonly string _configRoot;
|
||||
private readonly GameConfigBootstrap _bootstrap;
|
||||
|
||||
public ConfigRegistry Registry { get; }
|
||||
public IConfigRegistry Registry => _bootstrap.Registry;
|
||||
|
||||
public MonsterTable MonsterTable { get; private set; } = null!;
|
||||
|
||||
public ConsumerArchitecture(string configRoot)
|
||||
{
|
||||
_configRoot = configRoot ?? throw new ArgumentNullException(nameof(configRoot));
|
||||
Registry = new ConfigRegistry();
|
||||
if (configRoot == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configRoot));
|
||||
}
|
||||
|
||||
_bootstrap = new GameConfigBootstrap(
|
||||
new GameConfigBootstrapOptions
|
||||
{
|
||||
RootPath = configRoot,
|
||||
ConfigureLoader = static loader =>
|
||||
loader.RegisterAllGeneratedConfigTables(
|
||||
new GeneratedConfigRegistrationOptions
|
||||
{
|
||||
IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain }
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
RegisterUtility(Registry);
|
||||
|
||||
var loader = new YamlConfigLoader(_configRoot)
|
||||
.RegisterAllGeneratedConfigTables(
|
||||
new GeneratedConfigRegistrationOptions
|
||||
{
|
||||
IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain }
|
||||
});
|
||||
loader.LoadAsync(Registry).GetAwaiter().GetResult();
|
||||
_bootstrap.InitializeAsync().GetAwaiter().GetResult();
|
||||
MonsterTable = Registry.GetMonsterTable();
|
||||
}
|
||||
|
||||
public override async ValueTask DestroyAsync()
|
||||
{
|
||||
_bootstrap.Dispose();
|
||||
await base.DestroyAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
235
GFramework.Game.Tests/Config/GameConfigBootstrapTests.cs
Normal file
235
GFramework.Game.Tests/Config/GameConfigBootstrapTests.cs
Normal file
@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using GFramework.Game.Abstractions.Config;
|
||||
using GFramework.Game.Config;
|
||||
using GFramework.Game.Config.Generated;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GFramework.Game.Tests.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 验证官方配置启动帮助器能够收敛注册、加载与热重载生命周期。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class GameConfigBootstrapTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 为每个测试准备独立的临时配置目录。
|
||||
/// </summary>
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_rootPath = Path.Combine(Path.GetTempPath(), "GFramework.GameConfigBootstrapTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_rootPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理测试期间创建的临时目录。
|
||||
/// </summary>
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (Directory.Exists(_rootPath))
|
||||
{
|
||||
Directory.Delete(_rootPath, true);
|
||||
}
|
||||
}
|
||||
|
||||
private string _rootPath = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 验证启动帮助器能够加载生成表,并复用调用方显式提供的注册表实例。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task InitializeAsync_Should_Load_Generated_Config_Tables_Into_Registry()
|
||||
{
|
||||
CreateMonsterFiles();
|
||||
|
||||
var registry = new ConfigRegistry();
|
||||
using var bootstrap = CreateBootstrap(registry);
|
||||
|
||||
await bootstrap.InitializeAsync();
|
||||
|
||||
var monsterTable = registry.GetMonsterTable();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(bootstrap.Registry, Is.SameAs(registry));
|
||||
Assert.That(bootstrap.Loader.RegistrationCount, Is.EqualTo(1));
|
||||
Assert.That(bootstrap.IsInitialized, Is.True);
|
||||
Assert.That(bootstrap.IsHotReloadEnabled, Is.False);
|
||||
Assert.That(monsterTable.Get(1).Name, Is.EqualTo("Slime"));
|
||||
Assert.That(monsterTable.Get(2).Hp, Is.EqualTo(30));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证启动帮助器可以在初始化后显式启用热重载,并将刷新结果写回共享注册表。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task StartHotReload_Should_Update_Registered_Table_When_Config_File_Changes()
|
||||
{
|
||||
CreateMonsterFiles();
|
||||
|
||||
using var bootstrap = CreateBootstrap();
|
||||
await bootstrap.InitializeAsync();
|
||||
|
||||
var reloadTaskSource = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
bootstrap.StartHotReload(
|
||||
new YamlConfigHotReloadOptions
|
||||
{
|
||||
OnTableReloaded = tableName => reloadTaskSource.TrySetResult(tableName),
|
||||
DebounceDelay = TimeSpan.FromMilliseconds(150)
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
CreateFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
hp: 25
|
||||
faction: dungeon
|
||||
""");
|
||||
|
||||
var tableName = await WaitForTaskWithinAsync(reloadTaskSource.Task, TimeSpan.FromSeconds(5));
|
||||
var monsterTable = bootstrap.Registry.GetMonsterTable();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(tableName, Is.EqualTo(MonsterConfigBindings.TableName));
|
||||
Assert.That(bootstrap.IsHotReloadEnabled, Is.True);
|
||||
Assert.That(monsterTable.Get(1).Hp, Is.EqualTo(25));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
bootstrap.StopHotReload();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证缺少加载器配置回调时会在构造阶段被拒绝,避免启动帮助器静默创建空加载流程。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Constructor_Should_Throw_When_ConfigureLoader_Is_Missing()
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentException>(() =>
|
||||
_ = new GameConfigBootstrap(
|
||||
new GameConfigBootstrapOptions
|
||||
{
|
||||
RootPath = _rootPath
|
||||
}));
|
||||
|
||||
Assert.That(exception!.ParamName, Is.EqualTo("ConfigureLoader"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个使用生成聚合注册入口的官方启动帮助器。
|
||||
/// </summary>
|
||||
/// <param name="registry">可选的外部注册表;为空时使用默认注册表。</param>
|
||||
/// <returns>已配置但尚未初始化的启动帮助器。</returns>
|
||||
private GameConfigBootstrap CreateBootstrap(IConfigRegistry? registry = null)
|
||||
{
|
||||
return new GameConfigBootstrap(
|
||||
new GameConfigBootstrapOptions
|
||||
{
|
||||
RootPath = _rootPath,
|
||||
Registry = registry,
|
||||
ConfigureLoader = static loader =>
|
||||
loader.RegisterAllGeneratedConfigTables(
|
||||
new GeneratedConfigRegistrationOptions
|
||||
{
|
||||
IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain }
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在临时消费者根目录中创建测试文件。
|
||||
/// </summary>
|
||||
/// <param name="relativePath">相对根目录的文件路径。</param>
|
||||
/// <param name="content">要写入的文件内容。</param>
|
||||
private void CreateFile(string relativePath, string content)
|
||||
{
|
||||
var path = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
var directoryPath = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(directoryPath))
|
||||
{
|
||||
Directory.CreateDirectory(directoryPath);
|
||||
}
|
||||
|
||||
File.WriteAllText(path, content.Replace("\n", Environment.NewLine, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在临时目录中创建 monster schema 与 YAML 测试数据。
|
||||
/// </summary>
|
||||
private void CreateMonsterFiles()
|
||||
{
|
||||
CreateFile(
|
||||
"schemas/monster.schema.json",
|
||||
"""
|
||||
{
|
||||
"title": "Monster Config",
|
||||
"description": "Defines one monster entry for the bootstrap tests.",
|
||||
"type": "object",
|
||||
"required": ["id", "name", "hp", "faction"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Monster identifier."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Monster display name."
|
||||
},
|
||||
"hp": {
|
||||
"type": "integer",
|
||||
"description": "Monster base health."
|
||||
},
|
||||
"faction": {
|
||||
"type": "string",
|
||||
"description": "Used by the bootstrap tests to validate generated queries."
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
CreateFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
hp: 10
|
||||
faction: dungeon
|
||||
""");
|
||||
CreateFile(
|
||||
"monster/goblin.yaml",
|
||||
"""
|
||||
id: 2
|
||||
name: Goblin
|
||||
hp: 30
|
||||
faction: dungeon
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在限定时间内等待异步任务完成,避免文件监听测试无限挂起。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">任务结果类型。</typeparam>
|
||||
/// <param name="task">要等待的任务。</param>
|
||||
/// <param name="timeout">超时时间。</param>
|
||||
/// <returns>任务结果。</returns>
|
||||
private static async Task<T> WaitForTaskWithinAsync<T>(Task<T> task, TimeSpan timeout)
|
||||
{
|
||||
var completedTask = await Task.WhenAny(task, Task.Delay(timeout));
|
||||
if (!ReferenceEquals(completedTask, task))
|
||||
{
|
||||
Assert.Fail($"Timed out after {timeout} while waiting for file watcher notification.");
|
||||
}
|
||||
|
||||
return await task;
|
||||
}
|
||||
}
|
||||
183
GFramework.Game/Config/GameConfigBootstrap.cs
Normal file
183
GFramework.Game/Config/GameConfigBootstrap.cs
Normal file
@ -0,0 +1,183 @@
|
||||
using GFramework.Core.Abstractions.Events;
|
||||
using GFramework.Game.Abstractions.Config;
|
||||
|
||||
namespace GFramework.Game.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 提供官方的 C# 配置启动帮助器。
|
||||
/// 该类型负责把配置注册表、YAML 加载器与开发期热重载句柄收敛到一个长生命周期对象中,
|
||||
/// 让消费者项目可以通过一个稳定入口完成配置启动,而不是在多个脚本里重复拼装运行时细节。
|
||||
/// </summary>
|
||||
public sealed class GameConfigBootstrap : IDisposable
|
||||
{
|
||||
private const string ConfigureLoaderCannotBeNullMessage = "ConfigureLoader must be provided.";
|
||||
private const string RootPathCannotBeNullOrWhiteSpaceMessage = "Root path cannot be null or whitespace.";
|
||||
|
||||
private readonly GameConfigBootstrapOptions _options;
|
||||
private IUnRegister? _hotReload;
|
||||
private YamlConfigLoader? _loader;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定选项创建配置启动帮助器。
|
||||
/// </summary>
|
||||
/// <param name="options">配置启动约定。</param>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="options" /> 为空时抛出。</exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// 当 <paramref name="options" /> 的 <see cref="GameConfigBootstrapOptions.RootPath" /> 为空,
|
||||
/// 或 <see cref="GameConfigBootstrapOptions.ConfigureLoader" /> 未提供时抛出。
|
||||
/// </exception>
|
||||
public GameConfigBootstrap(GameConfigBootstrapOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.RootPath))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
RootPathCannotBeNullOrWhiteSpaceMessage,
|
||||
nameof(options.RootPath));
|
||||
}
|
||||
|
||||
if (options.ConfigureLoader == null)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
ConfigureLoaderCannotBeNullMessage,
|
||||
nameof(options.ConfigureLoader));
|
||||
}
|
||||
|
||||
_options = options;
|
||||
RootPath = options.RootPath;
|
||||
Registry = options.Registry ?? new ConfigRegistry();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取配置根目录。
|
||||
/// </summary>
|
||||
public string RootPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前配置生命周期共享的注册表。
|
||||
/// 默认情况下该实例由启动帮助器创建;如调用方传入自定义注册表,则返回同一个对象。
|
||||
/// </summary>
|
||||
public IConfigRegistry Registry { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取一个值,指示启动帮助器是否已经成功完成初次加载。
|
||||
/// </summary>
|
||||
public bool IsInitialized => _loader != null;
|
||||
|
||||
/// <summary>
|
||||
/// 获取一个值,指示开发期热重载是否已启用。
|
||||
/// </summary>
|
||||
public bool IsHotReloadEnabled => _hotReload != null;
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前生效的 YAML 配置加载器。
|
||||
/// 只有在 <see cref="InitializeAsync" /> 成功返回后该属性才可访问。
|
||||
/// </summary>
|
||||
/// <exception cref="ObjectDisposedException">当当前实例已释放时抛出。</exception>
|
||||
/// <exception cref="InvalidOperationException">当启动帮助器尚未初始化成功时抛出。</exception>
|
||||
public YamlConfigLoader Loader
|
||||
{
|
||||
get
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
return _loader ?? throw new InvalidOperationException(
|
||||
"The config bootstrap has not been initialized yet.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行初次配置加载,并在需要时启动开发期热重载。
|
||||
/// 该方法只能成功调用一次,避免同一个生命周期对象在运行中被重新拼装为另一套加载约定。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>表示异步初始化流程的任务。</returns>
|
||||
/// <exception cref="ObjectDisposedException">当当前实例已释放时抛出。</exception>
|
||||
/// <exception cref="InvalidOperationException">当当前实例已经初始化成功时抛出。</exception>
|
||||
/// <exception cref="ConfigLoadException">当配置加载失败时抛出。</exception>
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_loader != null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The config bootstrap can only be initialized once per instance.");
|
||||
}
|
||||
|
||||
var loader = new YamlConfigLoader(RootPath);
|
||||
_options.ConfigureLoader!(loader);
|
||||
await loader.LoadAsync(Registry, cancellationToken);
|
||||
|
||||
// 仅在初次加载完全成功后才公开加载器实例,避免上层观察到半初始化状态。
|
||||
_loader = loader;
|
||||
|
||||
if (_options.EnableHotReload)
|
||||
{
|
||||
StartHotReload(_options.HotReloadOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启用开发期热重载。
|
||||
/// 该入口让调用方可以先完成一次确定性的初始加载,再按环境决定是否追加文件监听。
|
||||
/// </summary>
|
||||
/// <param name="options">热重载选项;为空时使用 <see cref="YamlConfigLoader" /> 的默认行为。</param>
|
||||
/// <exception cref="ObjectDisposedException">当当前实例已释放时抛出。</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// 当初始加载尚未完成,或热重载已经处于启用状态时抛出。
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// 当 <paramref name="options" /> 的 <see cref="YamlConfigHotReloadOptions.DebounceDelay" /> 小于
|
||||
/// <see cref="TimeSpan.Zero" /> 时抛出。
|
||||
/// </exception>
|
||||
public void StartHotReload(YamlConfigHotReloadOptions? options = null)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
var loader = _loader ?? throw new InvalidOperationException(
|
||||
"Hot reload can only be started after the initial config load succeeds.");
|
||||
|
||||
if (_hotReload != null)
|
||||
{
|
||||
throw new InvalidOperationException("Hot reload is already enabled.");
|
||||
}
|
||||
|
||||
_hotReload = loader.EnableHotReload(Registry, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止开发期热重载并释放监听资源。
|
||||
/// 该方法是幂等的,允许启动层在销毁阶段无条件调用。
|
||||
/// </summary>
|
||||
public void StopHotReload()
|
||||
{
|
||||
var hotReload = _hotReload;
|
||||
_hotReload = null;
|
||||
hotReload?.UnRegister();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止热重载并释放当前帮助器持有的资源。
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
StopHotReload();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(GameConfigBootstrap));
|
||||
}
|
||||
}
|
||||
}
|
||||
41
GFramework.Game/Config/GameConfigBootstrapOptions.cs
Normal file
41
GFramework.Game/Config/GameConfigBootstrapOptions.cs
Normal file
@ -0,0 +1,41 @@
|
||||
using GFramework.Game.Abstractions.Config;
|
||||
|
||||
namespace GFramework.Game.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 描述官方配置启动帮助器的初始化约定。
|
||||
/// 该选项对象把配置根目录、表注册回调和热重载策略收敛到一个稳定入口,
|
||||
/// 让消费项目不必在多个启动脚本里重复拼装加载器细节。
|
||||
/// </summary>
|
||||
public sealed class GameConfigBootstrapOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置配置根目录。
|
||||
/// 该路径会直接传给 <see cref="YamlConfigLoader" /> 作为 YAML 与 schema 的共同根目录。
|
||||
/// </summary>
|
||||
public string RootPath { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置用于配置 <see cref="YamlConfigLoader" /> 的回调。
|
||||
/// 调用方通常应在这里调用生成器产出的 <c>RegisterAllGeneratedConfigTables()</c>,
|
||||
/// 或显式注册当前场景所需的手写表定义。
|
||||
/// </summary>
|
||||
public Action<YamlConfigLoader>? ConfigureLoader { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置要复用的配置注册表。
|
||||
/// 为空时启动帮助器会创建默认的 <see cref="ConfigRegistry" /> 实例。
|
||||
/// </summary>
|
||||
public IConfigRegistry? Registry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置是否在初次加载成功后立即启用开发期热重载。
|
||||
/// </summary>
|
||||
public bool EnableHotReload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置初始化阶段启用热重载时使用的选项。
|
||||
/// 当 <see cref="EnableHotReload" /> 为 <see langword="false" /> 时,该值会被忽略。
|
||||
/// </summary>
|
||||
public YamlConfigHotReloadOptions? HotReloadOptions { get; init; }
|
||||
}
|
||||
@ -142,7 +142,7 @@ GameProject/
|
||||
|
||||
这段配置的作用:
|
||||
|
||||
- `GFramework.Game` 提供运行时 `YamlConfigLoader`、`ConfigRegistry` 和只读表实现
|
||||
- `GFramework.Game` 提供运行时 `YamlConfigLoader`、`ConfigRegistry`、`GameConfigBootstrap` 和只读表实现
|
||||
- 三个 `ProjectReference(... OutputItemType="Analyzer")` 把生成器接进当前消费者项目
|
||||
- `GeWuYou.GFramework.SourceGenerators.targets` 自动把 `schemas/**/*.schema.json` 加入 `AdditionalFiles`
|
||||
|
||||
@ -160,12 +160,46 @@ GameProject/
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
### 启动引导模板
|
||||
### 官方启动帮助器
|
||||
|
||||
推荐把配置系统的初始化收敛到一个单独入口,避免把 `YamlConfigLoader` 注册逻辑散落到多个启动脚本中:
|
||||
`GFramework.Game` 现在内置 `GameConfigBootstrap` 与 `GameConfigBootstrapOptions`,用于把 `ConfigRegistry`、`YamlConfigLoader`、`LoadAsync` 和热重载句柄收敛到一个正式的 C# 入口中。
|
||||
|
||||
推荐直接组合这个帮助器,而不是继续在消费者项目里复制文档模板:
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Abstractions.Config;
|
||||
using GFramework.Game.Config;
|
||||
using GFramework.Game.Config.Generated;
|
||||
|
||||
var bootstrap = new GameConfigBootstrap(
|
||||
new GameConfigBootstrapOptions
|
||||
{
|
||||
RootPath = configRootPath,
|
||||
ConfigureLoader = static loader => loader.RegisterAllGeneratedConfigTables(),
|
||||
EnableHotReload = true,
|
||||
HotReloadOptions = new YamlConfigHotReloadOptions
|
||||
{
|
||||
OnTableReloaded = tableName => Console.WriteLine($"Reloaded config table: {tableName}"),
|
||||
OnTableReloadFailed = static (_, exception) =>
|
||||
{
|
||||
var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
|
||||
Console.WriteLine($"Config reload failed: {diagnostic?.FailureKind}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await bootstrap.InitializeAsync();
|
||||
|
||||
var registry = bootstrap.Registry;
|
||||
var monsterTable = registry.GetMonsterTable();
|
||||
var slime = monsterTable.Get(1);
|
||||
|
||||
bootstrap.Dispose();
|
||||
```
|
||||
|
||||
如果你希望把它继续包装进自己的进程级入口,也建议只包一层生命周期壳,而不是重新拼装底层加载器:
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Events;
|
||||
using GFramework.Game.Abstractions.Config;
|
||||
using GFramework.Game.Config;
|
||||
using GFramework.Game.Config.Generated;
|
||||
@ -173,58 +207,55 @@ using GFramework.Game.Config.Generated;
|
||||
namespace GameProject.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 负责初始化游戏内容配置运行时入口。
|
||||
/// 封装当前游戏进程的配置启动生命周期。
|
||||
/// </summary>
|
||||
public sealed class GameConfigBootstrap : IDisposable
|
||||
public sealed class GameConfigRuntime : IDisposable
|
||||
{
|
||||
private readonly ConfigRegistry _registry = new();
|
||||
private IUnRegister? _hotReload;
|
||||
private readonly GameConfigBootstrap _bootstrap;
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前游戏进程共享的配置注册表。
|
||||
/// </summary>
|
||||
public IConfigRegistry Registry => _registry;
|
||||
|
||||
/// <summary>
|
||||
/// 从指定配置根目录加载所有已注册配置表。
|
||||
/// 使用指定配置根目录创建运行时入口。
|
||||
/// </summary>
|
||||
/// <param name="configRootPath">配置根目录。</param>
|
||||
/// <param name="enableHotReload">是否启用开发期热重载。</param>
|
||||
public async Task InitializeAsync(string configRootPath, bool enableHotReload = false)
|
||||
public GameConfigRuntime(string configRootPath)
|
||||
{
|
||||
var loader = new YamlConfigLoader(configRootPath)
|
||||
.RegisterAllGeneratedConfigTables();
|
||||
|
||||
await loader.LoadAsync(_registry);
|
||||
|
||||
if (enableHotReload)
|
||||
{
|
||||
_hotReload = loader.EnableHotReload(
|
||||
_registry,
|
||||
onTableReloaded: tableName => Console.WriteLine($"Reloaded config table: {tableName}"),
|
||||
onTableReloadFailed: static (_, exception) =>
|
||||
{
|
||||
var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
|
||||
Console.WriteLine($"Config reload failed: {diagnostic?.FailureKind}");
|
||||
});
|
||||
}
|
||||
_bootstrap = new GameConfigBootstrap(
|
||||
new GameConfigBootstrapOptions
|
||||
{
|
||||
RootPath = configRootPath,
|
||||
ConfigureLoader = static loader => loader.RegisterAllGeneratedConfigTables()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止开发期热重载并释放相关资源。
|
||||
/// 获取共享配置注册表。
|
||||
/// </summary>
|
||||
public IConfigRegistry Registry => _bootstrap.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// 执行初次配置加载。
|
||||
/// </summary>
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
return _bootstrap.InitializeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放底层热重载句柄等资源。
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_hotReload?.UnRegister();
|
||||
_bootstrap.Dispose();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这段模板刻意遵循几个约定:
|
||||
这个官方帮助器刻意遵循几个约定:
|
||||
|
||||
- 优先使用生成器产出的 `RegisterAllGeneratedConfigTables()`,把多表注册收敛为一个稳定入口
|
||||
- 由一个长生命周期对象持有 `ConfigRegistry`
|
||||
- 热重载句柄和配置生命周期绑在一起,避免监听器泄漏
|
||||
- 优先通过 `ConfigureLoader` 调用生成器产出的 `RegisterAllGeneratedConfigTables()`,把多表注册收敛为一个稳定入口
|
||||
- 由 `GameConfigBootstrap` 持有 `ConfigRegistry`、`YamlConfigLoader` 和热重载句柄
|
||||
- `InitializeAsync()` 只在首次加载完整成功后才公开运行时状态,避免半初始化对象泄漏到业务层
|
||||
- 热重载既可以在初始化时自动启用,也可以在初次加载后显式调用 `StartHotReload(...)`
|
||||
|
||||
### 运行时读取模板
|
||||
|
||||
@ -309,30 +340,38 @@ if (monsterTable.TryFindFirstByFaction("dungeon", out var firstDungeonMonster))
|
||||
|
||||
### Architecture 推荐接入模板
|
||||
|
||||
如果你的项目已经基于 `GFramework.Core.Architectures.Architecture` 组织初始化流程,推荐把配置系统接到 `OnInitialize()` 阶段,并把 `ConfigRegistry` 注册为 utility:
|
||||
如果你的项目已经基于 `GFramework.Core.Architectures.Architecture` 组织初始化流程,推荐把配置系统接到 `OnInitialize()` 阶段,并把 `GameConfigBootstrap.Registry` 注册为 utility:
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Architectures;
|
||||
using GFramework.Game.Abstractions.Config;
|
||||
using GFramework.Game.Config;
|
||||
using GFramework.Game.Config.Generated;
|
||||
|
||||
public sealed class GameArchitecture : Architecture
|
||||
{
|
||||
private readonly string _configRootPath;
|
||||
private readonly GameConfigBootstrap _configBootstrap;
|
||||
|
||||
public GameArchitecture(string configRootPath)
|
||||
{
|
||||
_configRootPath = configRootPath ?? throw new ArgumentNullException(nameof(configRootPath));
|
||||
_configBootstrap = new GameConfigBootstrap(
|
||||
new GameConfigBootstrapOptions
|
||||
{
|
||||
RootPath = configRootPath,
|
||||
ConfigureLoader = static loader => loader.RegisterAllGeneratedConfigTables()
|
||||
});
|
||||
}
|
||||
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
var registry = RegisterUtility(new ConfigRegistry());
|
||||
RegisterUtility(_configBootstrap.Registry);
|
||||
_configBootstrap.InitializeAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
var loader = new YamlConfigLoader(_configRootPath)
|
||||
.RegisterAllGeneratedConfigTables();
|
||||
|
||||
loader.LoadAsync(registry).GetAwaiter().GetResult();
|
||||
public override async ValueTask DestroyAsync()
|
||||
{
|
||||
_configBootstrap.Dispose();
|
||||
await base.DestroyAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -340,35 +379,40 @@ public sealed class GameArchitecture : Architecture
|
||||
初始化完成后,业务组件可以继续通过架构上下文读取 utility,再走生成的强类型入口:
|
||||
|
||||
```csharp
|
||||
var registry = Context.GetUtility<ConfigRegistry>();
|
||||
var registry = Context.GetUtility<IConfigRegistry>();
|
||||
var monsterTable = registry.GetMonsterTable();
|
||||
var slime = monsterTable.Get(1);
|
||||
```
|
||||
|
||||
推荐遵循以下顺序:
|
||||
|
||||
- 先注册 `ConfigRegistry`
|
||||
- 再构造并配置 `YamlConfigLoader`
|
||||
- 在 `OnInitialize()` 内完成首次 `LoadAsync`
|
||||
- 先构造 `GameConfigBootstrap`
|
||||
- 在 `OnInitialize()` 里注册 `bootstrap.Registry`
|
||||
- 再调用 `bootstrap.InitializeAsync()` 完成首次加载
|
||||
- 架构销毁时释放 `GameConfigBootstrap`
|
||||
- 初始化完成后只通过注册表和生成表包装访问配置
|
||||
|
||||
当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + ConfigRegistry + YamlConfigLoader + RegisterAllGeneratedConfigTables()` 组合已经足够作为官方推荐接入路径。
|
||||
当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + GameConfigBootstrap + RegisterAllGeneratedConfigTables()` 组合已经足够作为官方推荐接入路径。
|
||||
|
||||
### 热重载模板
|
||||
|
||||
如果你希望把开发期热重载显式收敛为一个可选能力,建议把失败诊断一起写进模板,而不是只打印异常文本:
|
||||
如果你希望把开发期热重载显式收敛为一个可选能力,推荐直接通过 `GameConfigBootstrap.StartHotReload(...)` 管理,而不是让监听句柄散落在启动层之外:
|
||||
|
||||
```csharp
|
||||
var hotReload = loader.EnableHotReload(
|
||||
registry,
|
||||
onTableReloaded: tableName => Console.WriteLine($"Reloaded: {tableName}"),
|
||||
onTableReloadFailed: (tableName, exception) =>
|
||||
await bootstrap.InitializeAsync();
|
||||
|
||||
bootstrap.StartHotReload(
|
||||
new YamlConfigHotReloadOptions
|
||||
{
|
||||
var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
|
||||
Console.WriteLine($"Reload failed: {tableName}");
|
||||
Console.WriteLine($"Failure kind: {diagnostic?.FailureKind}");
|
||||
Console.WriteLine($"Yaml path: {diagnostic?.YamlPath}");
|
||||
Console.WriteLine($"Display path: {diagnostic?.DisplayPath}");
|
||||
OnTableReloaded = tableName => Console.WriteLine($"Reloaded: {tableName}"),
|
||||
OnTableReloadFailed = (tableName, exception) =>
|
||||
{
|
||||
var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
|
||||
Console.WriteLine($"Reload failed: {tableName}");
|
||||
Console.WriteLine($"Failure kind: {diagnostic?.FailureKind}");
|
||||
Console.WriteLine($"Yaml path: {diagnostic?.YamlPath}");
|
||||
Console.WriteLine($"Display path: {diagnostic?.DisplayPath}");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
@ -378,22 +422,7 @@ var hotReload = loader.EnableHotReload(
|
||||
- 热重载失败时应优先依赖 `ConfigLoadException.Diagnostic` 做稳定日志或 UI 提示
|
||||
- 如果你的项目已经有统一日志系统,建议在这里把诊断字段转成结构化日志,而不是拼接一整段字符串
|
||||
|
||||
如果你后续还需要为热重载增加更多开关,推荐优先使用选项对象入口,而不是继续叠加位置参数:
|
||||
|
||||
```csharp
|
||||
var hotReload = loader.EnableHotReload(
|
||||
registry,
|
||||
new YamlConfigHotReloadOptions
|
||||
{
|
||||
OnTableReloaded = tableName => Console.WriteLine($"Reloaded: {tableName}"),
|
||||
OnTableReloadFailed = (tableName, exception) =>
|
||||
{
|
||||
var diagnostic = (exception as ConfigLoadException)?.Diagnostic;
|
||||
Console.WriteLine($"{tableName}: {diagnostic?.FailureKind}");
|
||||
},
|
||||
DebounceDelay = TimeSpan.FromMilliseconds(150)
|
||||
});
|
||||
```
|
||||
如果你确实需要直接控制底层加载器,`YamlConfigLoader.EnableHotReload(...)` 仍然保留;但在一般启动路径下,优先让 `GameConfigBootstrap` 持有并停止监听句柄。
|
||||
|
||||
## 运行时接入
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user