feat(ioc): 添加 Microsoft DI 容器适配器和 CI/CD 工作流

- 实现 MicrosoftDiContainer 类,提供 Microsoft.Extensions.DependencyInjection 的适配器
- 添加 DefaultCqrsHandlerRegistrar 默认 CQRS 处理器注册器实现
- 配置 GitHub Actions CI/CD 工作流,包含代码质量检查和构建测试任务
- 设置 .NET 8/9/10 多版本支持和缓存策略
- 添加单元测试覆盖 IoC 容器的各项功能,包括注册、解析和生命周期管理
- 实现线程安全的读写锁机制保护容器操作
- 支持 CQRS 处理器和管道行为的注册管理
This commit is contained in:
GeWuYou 2026-04-15 15:13:43 +08:00
parent 048f96c6cd
commit 34e140e919
17 changed files with 294 additions and 9 deletions

View File

@ -166,6 +166,12 @@ jobs:
--logger "trx;LogFileName=sg-$RANDOM.trx" \
--results-directory TestResults &
dotnet test GFramework.Cqrs.Tests \
-c Release \
--no-build \
--logger "trx;LogFileName=cqrs-$RANDOM.trx" \
--results-directory TestResults &
dotnet test GFramework.Ecs.Arch.Tests \
-c Release \
--no-build \

View File

@ -0,0 +1,36 @@
// Copyright (c) 2026 GeWuYou
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
namespace GFramework.Core.Tests.Cqrs;
/// <summary>
/// 为容器层测试提供可扫描的最小通知夹具。
/// </summary>
internal sealed record DeterministicOrderNotification : INotification;
/// <summary>
/// 供容器注册测试验证程序集扫描结果的通知处理器。
/// </summary>
internal sealed class DeterministicOrderNotificationHandler : INotificationHandler<DeterministicOrderNotification>
{
/// <summary>
/// 无副作用地消费通知。
/// </summary>
/// <param name="notification">通知实例。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>已完成任务。</returns>
public ValueTask Handle(DeterministicOrderNotification notification, CancellationToken cancellationToken)
{
return ValueTask.CompletedTask;
}
}

View File

@ -315,7 +315,7 @@ public class MicrosoftDiContainerTests
[Test]
public void Clear_Should_Reset_Cqrs_Assembly_Deduplication_State()
{
var assembly = typeof(CqrsHandlerRegistrarTests).Assembly;
var assembly = typeof(DeterministicOrderNotification).Assembly;
_container.RegisterCqrsHandlersFromAssembly(assembly);
Assert.That(

View File

@ -1,4 +1,5 @@
using System.Reflection;
using GFramework.Core.Abstractions.Cqrs;
using GFramework.Core.Abstractions.Ioc;
using GFramework.Core.Abstractions.Logging;

View File

@ -9,6 +9,7 @@
<EnableGFrameworkPackageTransitiveGlobalUsings>true</EnableGFrameworkPackageTransitiveGlobalUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GFramework.Cqrs.Abstractions\GFramework.Cqrs.Abstractions.csproj"/>
<ProjectReference Include="..\$(AssemblyName).Abstractions\$(AssemblyName).Abstractions.csproj"/>
</ItemGroup>
<ItemGroup>

View File

@ -1,6 +1,7 @@
using System.ComponentModel;
using System.Reflection;
using GFramework.Core.Abstractions.Bases;
using GFramework.Core.Abstractions.Cqrs;
using GFramework.Core.Abstractions.Ioc;
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Abstractions.Systems;

View File

@ -1,4 +1,3 @@
using System.Reflection;
using GFramework.Core.Abstractions.Cqrs;
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Architectures;

View File

@ -0,0 +1,117 @@
using GFramework.Core.Abstractions.Cqrs;
using GFramework.Core.Abstractions.Ioc;
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Architectures;
using GFramework.Core.Ioc;
using GFramework.Core.Logging;
namespace GFramework.Core.Tests;
/// <summary>
/// 为测试项目提供对 CQRS 处理器真实注册入口的受控访问。
/// </summary>
/// <remarks>
/// 测试应通过该入口驱动注册流程,而不是直接反射调用注册器的私有辅助方法,
/// 这样可以覆盖生产启动路径中的程序集去重、日志记录与容错恢复行为。
/// </remarks>
internal static class CqrsTestRuntime
{
private static readonly Type CqrsHandlerRegistrarType = typeof(ArchitectureContext).Assembly
.GetType(
"GFramework.Core.Cqrs.Internal.CqrsHandlerRegistrar",
throwOnError: true)!
?? throw new InvalidOperationException(
"Failed to locate CqrsHandlerRegistrar type.");
private static readonly MethodInfo RegisterHandlersMethod = CqrsHandlerRegistrarType
.GetMethod(
"RegisterHandlers",
BindingFlags.Public | BindingFlags.NonPublic |
BindingFlags.Static,
binder: null,
[
typeof(IIocContainer),
typeof(IEnumerable<Assembly>),
typeof(ILogger)
],
modifiers: null)
?? throw new InvalidOperationException(
"Failed to locate CqrsHandlerRegistrar.RegisterHandlers.");
private static readonly Type CqrsDispatcherType = typeof(ArchitectureContext).Assembly
.GetType(
"GFramework.Core.Cqrs.Internal.CqrsDispatcher",
throwOnError: true)!
?? throw new InvalidOperationException(
"Failed to locate CqrsDispatcher type.");
private static readonly ConstructorInfo CqrsDispatcherConstructor = CqrsDispatcherType.GetConstructor(
BindingFlags.Instance |
BindingFlags.Public |
BindingFlags.NonPublic,
binder: null,
[
typeof(IIocContainer),
typeof(ILogger)
],
modifiers: null)
?? throw new InvalidOperationException(
"Failed to locate CqrsDispatcher constructor.");
private static readonly Type DefaultCqrsHandlerRegistrarType = typeof(ArchitectureContext).Assembly
.GetType(
"GFramework.Core.Cqrs.Internal.DefaultCqrsHandlerRegistrar",
throwOnError: true)!
?? throw new InvalidOperationException(
"Failed to locate DefaultCqrsHandlerRegistrar type.");
private static readonly ConstructorInfo DefaultCqrsHandlerRegistrarConstructor =
DefaultCqrsHandlerRegistrarType.GetConstructor(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
binder: null,
[
typeof(IIocContainer),
typeof(ILogger)
],
modifiers: null)
?? throw new InvalidOperationException(
"Failed to locate DefaultCqrsHandlerRegistrar constructor.");
/// <summary>
/// 为裸测试容器补齐默认 CQRS runtime seam。
/// 这使仅使用 <see cref="MicrosoftDiContainer" /> 的测试环境也能观察与生产路径一致的 runtime 行为,
/// 而无需完整启动服务模块管理器。
/// </summary>
/// <param name="container">目标测试容器。</param>
internal static void RegisterInfrastructure(MicrosoftDiContainer container)
{
ArgumentNullException.ThrowIfNull(container);
var runtimeLogger = LoggerFactoryResolver.Provider.CreateLogger("CqrsDispatcher");
var registrarLogger = LoggerFactoryResolver.Provider.CreateLogger(nameof(CqrsTestRuntime));
var runtime = (ICqrsRuntime)CqrsDispatcherConstructor.Invoke([container, runtimeLogger]);
var registrar =
(ICqrsHandlerRegistrar)DefaultCqrsHandlerRegistrarConstructor.Invoke([container, registrarLogger]);
container.Register<ICqrsRuntime>(runtime);
container.Register<ICqrsHandlerRegistrar>(registrar);
}
/// <summary>
/// 通过与生产代码一致的注册入口扫描并注册指定程序集中的 CQRS 处理器。
/// </summary>
/// <param name="container">承载处理器映射的测试容器。</param>
/// <param name="assemblies">要扫描的程序集集合。</param>
internal static void RegisterHandlers(MicrosoftDiContainer container, params Assembly[] assemblies)
{
ArgumentNullException.ThrowIfNull(container);
ArgumentNullException.ThrowIfNull(assemblies);
RegisterInfrastructure(container);
var logger = LoggerFactoryResolver.Provider.CreateLogger(nameof(CqrsTestRuntime));
RegisterHandlersMethod.Invoke(
null,
[container, assemblies.Where(static assembly => assembly is not null).Distinct().ToArray(), logger]);
}
}

View File

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TestTargetFrameworks Condition="'$(TestTargetFrameworks)' == ''">net10.0</TestTargetFrameworks>
<TargetFrameworks>$(TestTargetFrameworks)</TargetFrameworks>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<WarningLevel>0</WarningLevel>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0"/>
<PackageReference Include="Moq" Version="4.20.72"/>
<PackageReference Include="NUnit" Version="4.5.1"/>
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GFramework.Cqrs.Abstractions\GFramework.Cqrs.Abstractions.csproj"/>
<ProjectReference Include="..\GFramework.Core.Abstractions\GFramework.Core.Abstractions.csproj"/>
<ProjectReference Include="..\GFramework.Core\GFramework.Core.csproj"/>
</ItemGroup>
</Project>

View File

@ -0,0 +1,26 @@
// Copyright (c) 2026 GeWuYou
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
global using System;
global using System.Collections;
global using System.Collections.Generic;
global using System.Diagnostics;
global using System.Linq;
global using System.Reflection;
global using System.Runtime.CompilerServices;
global using System.Threading;
global using System.Threading.Tasks;
global using Microsoft.Extensions.DependencyInjection;
global using Moq;
global using NUnit.Compatibility;
global using NUnit.Framework;

View File

@ -0,0 +1,56 @@
// Copyright (c) 2026 GeWuYou
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Logging;
namespace GFramework.Core.Tests.Logging;
/// <summary>
/// 供 CQRS 测试项目复用的最小日志记录器实现。
/// </summary>
public sealed class TestLogger : AbstractLogger
{
/// <summary>
/// 初始化测试日志记录器。
/// </summary>
/// <param name="name">日志名称。</param>
/// <param name="minLevel">最小日志级别。</param>
public TestLogger(string? name = null, LogLevel minLevel = LogLevel.Info) : base(name, minLevel)
{
}
/// <summary>
/// 获取当前测试期间捕获到的日志条目。
/// </summary>
public List<LogEntry> Logs { get; } = [];
/// <summary>
/// 将日志写入内存,供断言使用。
/// </summary>
/// <param name="level">日志级别。</param>
/// <param name="message">日志消息。</param>
/// <param name="exception">关联异常。</param>
protected override void Write(LogLevel level, string message, Exception? exception)
{
Logs.Add(new LogEntry(level, message, exception));
}
/// <summary>
/// 表示单条测试日志记录。
/// </summary>
/// <param name="Level">日志级别。</param>
/// <param name="Message">日志消息。</param>
/// <param name="Exception">关联异常。</param>
public sealed record LogEntry(LogLevel Level, string Message, Exception? Exception);
}

View File

@ -1,5 +1,3 @@
using System.Diagnostics;
using System.Reflection;
using GFramework.Core.Abstractions.Cqrs;
using GFramework.Core.Architectures;
using GFramework.Core.Ioc;

View File

@ -1,5 +1,3 @@
using System.Diagnostics;
using System.Reflection;
using GFramework.Core.Abstractions.Architectures;
using GFramework.Core.Abstractions.Cqrs;
using GFramework.Core.Architectures;

View File

@ -1,6 +1,3 @@
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using GFramework.Core.Abstractions.Architectures;
using GFramework.Core.Abstractions.Cqrs;
using GFramework.Core.Abstractions.Events;

View File

@ -63,6 +63,9 @@
<None Remove="Godot\**"/>
<None Remove="GFramework.Game.Tests\**"/>
<None Remove="GFramework.Godot.Tests\**"/>
<None Remove="GFramework.Cqrs\**"/>
<None Remove="GFramework.Cqrs.Abstractions\**"/>
<None Remove="GFramework.Cqrs.Tests\**"/>
</ItemGroup>
<!-- 聚合核心模块 -->
<ItemGroup>
@ -104,6 +107,9 @@
<Compile Remove="Godot\**"/>
<Compile Remove="GFramework.Game.Tests\**"/>
<Compile Remove="GFramework.Godot.Tests\**"/>
<Compile Remove="GFramework.Cqrs\**"/>
<Compile Remove="GFramework.Cqrs.Abstractions\**"/>
<Compile Remove="GFramework.Cqrs.Tests\**"/>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Remove="GFramework.Core\**"/>
@ -131,6 +137,9 @@
<EmbeddedResource Remove="Godot\**"/>
<EmbeddedResource Remove="GFramework.Game.Tests\**"/>
<EmbeddedResource Remove="GFramework.Godot.Tests\**"/>
<EmbeddedResource Remove="GFramework.Cqrs\**"/>
<EmbeddedResource Remove="GFramework.Cqrs.Abstractions\**"/>
<EmbeddedResource Remove="GFramework.Cqrs.Tests\**"/>
</ItemGroup>
<ItemGroup>
<AdditionalFiles Remove="AnalyzerReleases.Shipped.md"/>

View File

@ -42,6 +42,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Cqrs.Abstraction
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Cqrs", "GFramework.Cqrs\GFramework.Cqrs.csproj", "{E7034F34-0D2B-4D99-B8E2-D149EF6C88F2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Cqrs.Tests", "GFramework.Cqrs.Tests\GFramework.Cqrs.Tests.csproj", "{29037A55-9A89-425C-AB33-D44872B2E601}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -304,6 +306,18 @@ Global
{E7034F34-0D2B-4D99-B8E2-D149EF6C88F2}.Release|x64.Build.0 = Release|Any CPU
{E7034F34-0D2B-4D99-B8E2-D149EF6C88F2}.Release|x86.ActiveCfg = Release|Any CPU
{E7034F34-0D2B-4D99-B8E2-D149EF6C88F2}.Release|x86.Build.0 = Release|Any CPU
{29037A55-9A89-425C-AB33-D44872B2E601}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{29037A55-9A89-425C-AB33-D44872B2E601}.Debug|Any CPU.Build.0 = Debug|Any CPU
{29037A55-9A89-425C-AB33-D44872B2E601}.Debug|x64.ActiveCfg = Debug|Any CPU
{29037A55-9A89-425C-AB33-D44872B2E601}.Debug|x64.Build.0 = Debug|Any CPU
{29037A55-9A89-425C-AB33-D44872B2E601}.Debug|x86.ActiveCfg = Debug|Any CPU
{29037A55-9A89-425C-AB33-D44872B2E601}.Debug|x86.Build.0 = Debug|Any CPU
{29037A55-9A89-425C-AB33-D44872B2E601}.Release|Any CPU.ActiveCfg = Release|Any CPU
{29037A55-9A89-425C-AB33-D44872B2E601}.Release|Any CPU.Build.0 = Release|Any CPU
{29037A55-9A89-425C-AB33-D44872B2E601}.Release|x64.ActiveCfg = Release|Any CPU
{29037A55-9A89-425C-AB33-D44872B2E601}.Release|x64.Build.0 = Release|Any CPU
{29037A55-9A89-425C-AB33-D44872B2E601}.Release|x86.ActiveCfg = Release|Any CPU
{29037A55-9A89-425C-AB33-D44872B2E601}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE