docs(core): 添加 CQRS 文档并完善相关扩展方法

- 新增 CQRS 核心概念、命令查询处理器使用指南
- 添加管道行为、流式处理和最佳实践说明
- 实现 CQRS 协程扩展方法支持异步命令执行
- 添加 ContextAware 接口的 CQRS 命令查询扩展
- 集成 Microsoft DI 容器依赖注入支持
- 补充架构模块行为测试验证功能完整性
- 扩展 GameContext 测试用例提高代码覆盖率
This commit is contained in:
GeWuYou 2026-04-15 07:34:01 +08:00
parent 115fe65e88
commit 088f02d586
13 changed files with 190 additions and 17 deletions

View File

@ -2,6 +2,7 @@ using GFramework.Core.Abstractions.Lifecycle;
using GFramework.Core.Abstractions.Model;
using GFramework.Core.Abstractions.Systems;
using GFramework.Core.Abstractions.Utility;
using Microsoft.Extensions.DependencyInjection;
namespace GFramework.Core.Abstractions.Architectures;

View File

@ -1,5 +1,6 @@
using GFramework.Core.Abstractions.Rule;
using GFramework.Core.Abstractions.Systems;
using Microsoft.Extensions.DependencyInjection;
namespace GFramework.Core.Abstractions.Ioc;

View File

@ -77,6 +77,28 @@ public class ArchitectureModulesBehaviorTests
await architecture.DestroyAsync();
}
/// <summary>
/// 验证兼容别名 <c>RegisterMediatorBehavior</c> 仍会把 CQRS 行为接入请求管道。
/// </summary>
[Test]
public async Task RegisterMediatorBehavior_Should_Apply_Pipeline_Behavior_To_Request()
{
var architecture = new ModuleTestArchitecture(target =>
target.RegisterMediatorBehavior<TrackingPipelineBehavior<ModuleBehaviorRequest, string>>());
await architecture.InitializeAsync();
var response = await architecture.Context.SendRequestAsync(new ModuleBehaviorRequest());
Assert.Multiple(() =>
{
Assert.That(response, Is.EqualTo("handled"));
Assert.That(TrackingPipelineBehavior<ModuleBehaviorRequest, string>.InvocationCount, Is.EqualTo(1));
});
await architecture.DestroyAsync();
}
/// <summary>
/// 用于测试模块行为的最小架构实现。
/// </summary>

View File

@ -394,45 +394,106 @@ public class TestArchitectureContext : IArchitectureContext
{
}
/// <summary>
/// 测试桩:异步发送统一 CQRS 请求。
/// </summary>
/// <typeparam name="TResponse">响应类型。</typeparam>
/// <param name="request">要发送的请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>请求响应任务。</returns>
/// <exception cref="NotImplementedException">该测试桩未实现此成员。</exception>
public ValueTask<TResponse> SendRequestAsync<TResponse>(IRequest<TResponse> request,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
/// <summary>
/// 测试桩:同步发送统一 CQRS 请求。
/// </summary>
/// <typeparam name="TResponse">响应类型。</typeparam>
/// <param name="request">要发送的请求。</param>
/// <returns>请求响应。</returns>
/// <exception cref="NotImplementedException">该测试桩未实现此成员。</exception>
public TResponse SendRequest<TResponse>(IRequest<TResponse> request)
{
throw new NotImplementedException();
}
/// <summary>
/// 测试桩:异步发送 CQRS 命令并返回响应。
/// </summary>
/// <typeparam name="TResponse">命令响应类型。</typeparam>
/// <param name="command">要发送的命令。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>命令响应任务。</returns>
/// <exception cref="NotImplementedException">该测试桩未实现此成员。</exception>
public ValueTask<TResponse> SendCommandAsync<TResponse>(Abstractions.Cqrs.Command.ICommand<TResponse> command,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
/// <summary>
/// 测试桩:同步发送 CQRS 命令并返回响应。
/// </summary>
/// <typeparam name="TResponse">命令响应类型。</typeparam>
/// <param name="command">要发送的命令。</param>
/// <returns>命令响应。</returns>
/// <exception cref="NotImplementedException">该测试桩未实现此成员。</exception>
public TResponse SendCommand<TResponse>(Abstractions.Cqrs.Command.ICommand<TResponse> command)
{
throw new NotImplementedException();
}
/// <summary>
/// 测试桩:异步发送 CQRS 查询并返回结果。
/// </summary>
/// <typeparam name="TResponse">查询结果类型。</typeparam>
/// <param name="query">要发送的查询。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>查询结果任务。</returns>
/// <exception cref="NotImplementedException">该测试桩未实现此成员。</exception>
public ValueTask<TResponse> SendQueryAsync<TResponse>(Abstractions.Cqrs.Query.IQuery<TResponse> query,
CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
/// <summary>
/// 测试桩:同步发送 CQRS 查询并返回结果。
/// </summary>
/// <typeparam name="TResponse">查询结果类型。</typeparam>
/// <param name="query">要发送的查询。</param>
/// <returns>查询结果。</returns>
/// <exception cref="NotImplementedException">该测试桩未实现此成员。</exception>
public TResponse SendQuery<TResponse>(Abstractions.Cqrs.Query.IQuery<TResponse> query)
{
throw new NotImplementedException();
}
/// <summary>
/// 测试桩:异步发布 CQRS 通知。
/// </summary>
/// <typeparam name="TNotification">通知类型。</typeparam>
/// <param name="notification">要发布的通知。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>通知发布任务。</returns>
/// <exception cref="NotImplementedException">该测试桩未实现此成员。</exception>
public ValueTask PublishAsync<TNotification>(TNotification notification,
CancellationToken cancellationToken = default) where TNotification : INotification
{
throw new NotImplementedException();
}
/// <summary>
/// 测试桩:创建 CQRS 流式请求响应序列。
/// </summary>
/// <typeparam name="TResponse">流式响应元素类型。</typeparam>
/// <param name="request">流式请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>异步响应流。</returns>
/// <exception cref="NotImplementedException">该测试桩未实现此成员。</exception>
public IAsyncEnumerable<TResponse> CreateStream<TResponse>(
IStreamRequest<TResponse> request,
CancellationToken cancellationToken = default)
@ -440,12 +501,28 @@ public class TestArchitectureContext : IArchitectureContext
throw new NotImplementedException();
}
/// <summary>
/// 测试桩:异步发送无返回值 CQRS 命令。
/// </summary>
/// <typeparam name="TCommand">命令类型。</typeparam>
/// <param name="command">要发送的命令。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>命令发送任务。</returns>
/// <exception cref="NotImplementedException">该测试桩未实现此成员。</exception>
public ValueTask SendAsync<TCommand>(TCommand command, CancellationToken cancellationToken = default)
where TCommand : IRequest<Unit>
{
throw new NotImplementedException();
}
/// <summary>
/// 测试桩:异步发送带返回值的 CQRS 请求。
/// </summary>
/// <typeparam name="TResponse">响应类型。</typeparam>
/// <param name="command">要发送的请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>请求响应任务。</returns>
/// <exception cref="NotImplementedException">该测试桩未实现此成员。</exception>
public ValueTask<TResponse> SendAsync<TResponse>(IRequest<TResponse> command,
CancellationToken cancellationToken = default)
{

View File

@ -66,6 +66,32 @@ public class CqrsCoroutineExtensionsTests
Assert.That(exception, Is.SameAs(expectedException));
}
/// <summary>
/// 验证 SendCommandCoroutine 在提供错误回调时也会传递解包后的原始异常,
/// 避免回调路径暴露 <see cref="AggregateException" />。
/// </summary>
[Test]
public void SendCommandCoroutine_Should_Forward_Inner_Exception_To_Error_Handler()
{
var command = new TestCommand("Test");
var contextAware = new TestContextAware();
var expectedException = new InvalidOperationException("Command failed.");
Exception? capturedException = null;
contextAware.MockContext
.Setup(ctx => ctx.SendAsync(command, It.IsAny<CancellationToken>()))
.Returns(new ValueTask(Task.FromException(expectedException)));
var coroutine = CqrsCoroutineExtensions.SendCommandCoroutine(
contextAware,
command,
exception => capturedException = exception);
Assert.That(coroutine.MoveNext(), Is.True);
Assert.That(coroutine.MoveNext(), Is.False);
Assert.That(capturedException, Is.SameAs(expectedException));
}
/// <summary>
/// 测试用的简单命令类
/// </summary>

View File

@ -23,4 +23,6 @@ global using GFramework.Core.Abstractions.StateManagement;
global using GFramework.Core.Extensions;
global using GFramework.Core.Property;
global using GFramework.Core.StateManagement;
global using GFramework.Core.Abstractions.Property;
global using GFramework.Core.Abstractions.Property;
global using Microsoft.Extensions.DependencyInjection;
global using Moq;

View File

@ -19,6 +19,13 @@ public static class CqrsCoroutineExtensions
/// <param name="command">要发送的命令对象。</param>
/// <param name="onError">发生异常时的回调处理函数。</param>
/// <returns>协程枚举器,用于协程执行。</returns>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="contextAware" /> 或 <paramref name="command" /> 为 <see langword="null" /> 时抛出。
/// </exception>
/// <remarks>
/// 当底层命令调度失败时,该扩展会把底层异常解包后传给 <paramref name="onError" />
/// 或在未提供回调时重新抛出同一个异常实例,避免两条失败路径暴露不同的异常类型。
/// </remarks>
public static IEnumerator<IYieldInstruction> SendCommandCoroutine<TCommand>(
this IContextAware contextAware,
TCommand command,
@ -35,9 +42,10 @@ public static class CqrsCoroutineExtensions
if (!task.IsFaulted)
yield break;
var exception = task.Exception!.InnerException ?? task.Exception;
if (onError != null)
onError.Invoke(task.Exception!);
onError.Invoke(exception);
else
throw task.Exception!.InnerException ?? task.Exception;
throw exception;
}
}

View File

@ -15,6 +15,9 @@ public static class ContextAwareCqrsCommandExtensions
/// <param name="contextAware">实现 <see cref="IContextAware" /> 接口的对象。</param>
/// <param name="command">要发送的命令对象。</param>
/// <returns>命令执行结果。</returns>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="contextAware" /> 或 <paramref name="command" /> 为 <see langword="null" /> 时抛出。
/// </exception>
public static TResponse SendCommand<TResponse>(this IContextAware contextAware, ICommand<TResponse> command)
{
ArgumentNullException.ThrowIfNull(contextAware);
@ -31,6 +34,9 @@ public static class ContextAwareCqrsCommandExtensions
/// <param name="command">要发送的命令对象。</param>
/// <param name="cancellationToken">取消令牌,用于取消操作。</param>
/// <returns>包含命令执行结果的 <see cref="ValueTask{TResult}" />。</returns>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="contextAware" /> 或 <paramref name="command" /> 为 <see langword="null" /> 时抛出。
/// </exception>
public static ValueTask<TResponse> SendCommandAsync<TResponse>(
this IContextAware contextAware,
ICommand<TResponse> command,

View File

@ -17,6 +17,9 @@ public static class ContextAwareCqrsExtensions
/// <param name="request">要发送的请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>请求结果。</returns>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="contextAware" /> 或 <paramref name="request" /> 为 <see langword="null" /> 时抛出。
/// </exception>
public static ValueTask<TResponse> SendRequestAsync<TResponse>(
this IContextAware contextAware,
IRequest<TResponse> request,
@ -35,6 +38,9 @@ public static class ContextAwareCqrsExtensions
/// <param name="contextAware">实现 <see cref="IContextAware" /> 接口的对象。</param>
/// <param name="request">要发送的请求。</param>
/// <returns>请求结果。</returns>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="contextAware" /> 或 <paramref name="request" /> 为 <see langword="null" /> 时抛出。
/// </exception>
public static TResponse SendRequest<TResponse>(this IContextAware contextAware, IRequest<TResponse> request)
{
ArgumentNullException.ThrowIfNull(contextAware);
@ -51,6 +57,9 @@ public static class ContextAwareCqrsExtensions
/// <param name="notification">要发布的通知。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>异步任务。</returns>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="contextAware" /> 或 <paramref name="notification" /> 为 <see langword="null" /> 时抛出。
/// </exception>
public static ValueTask PublishAsync<TNotification>(
this IContextAware contextAware,
TNotification notification,
@ -71,6 +80,9 @@ public static class ContextAwareCqrsExtensions
/// <param name="request">流式请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>异步响应流。</returns>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="contextAware" /> 或 <paramref name="request" /> 为 <see langword="null" /> 时抛出。
/// </exception>
public static IAsyncEnumerable<TResponse> CreateStream<TResponse>(
this IContextAware contextAware,
IStreamRequest<TResponse> request,
@ -90,6 +102,9 @@ public static class ContextAwareCqrsExtensions
/// <param name="command">要发送的命令。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>异步任务。</returns>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="contextAware" /> 或 <paramref name="command" /> 为 <see langword="null" /> 时抛出。
/// </exception>
public static ValueTask SendAsync<TCommand>(
this IContextAware contextAware,
TCommand command,
@ -110,6 +125,9 @@ public static class ContextAwareCqrsExtensions
/// <param name="command">要发送的命令。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>命令执行结果。</returns>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="contextAware" /> 或 <paramref name="command" /> 为 <see langword="null" /> 时抛出。
/// </exception>
public static ValueTask<TResponse> SendAsync<TResponse>(
this IContextAware contextAware,
IRequest<TResponse> command,

View File

@ -15,6 +15,9 @@ public static class ContextAwareCqrsQueryExtensions
/// <param name="contextAware">实现 <see cref="IContextAware" /> 接口的对象。</param>
/// <param name="query">要发送的查询对象。</param>
/// <returns>查询结果。</returns>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="contextAware" /> 或 <paramref name="query" /> 为 <see langword="null" /> 时抛出。
/// </exception>
public static TResponse SendQuery<TResponse>(this IContextAware contextAware, IQuery<TResponse> query)
{
ArgumentNullException.ThrowIfNull(contextAware);
@ -31,6 +34,9 @@ public static class ContextAwareCqrsQueryExtensions
/// <param name="query">要发送的查询对象。</param>
/// <param name="cancellationToken">取消令牌,用于取消操作。</param>
/// <returns>包含查询结果的 <see cref="ValueTask{TResult}" />。</returns>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="contextAware" /> 或 <paramref name="query" /> 为 <see langword="null" /> 时抛出。
/// </exception>
public static ValueTask<TResponse> SendQueryAsync<TResponse>(
this IContextAware contextAware,
IQuery<TResponse> query,

View File

@ -16,4 +16,5 @@ global using System.Collections.Generic;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
global using System.Threading.Channels;
global using System.Threading.Channels;
global using Microsoft.Extensions.DependencyInjection;

View File

@ -2,6 +2,9 @@
using GFramework.Core.Abstractions.Cqrs.Command;
using GFramework.Core.Abstractions.Cqrs.Query;
using GFramework.Core.Abstractions.Rule;
using GFramework.Core.Coroutine;
using GFramework.Core.Coroutine.Extensions;
using GFramework.Core.Cqrs.Extensions;
namespace GFramework.Godot.Coroutine;
@ -26,7 +29,7 @@ public static class ContextAwareCoroutineExtensions
string? tag = null,
CancellationToken cancellationToken = default)
{
return Core.Cqrs.Extensions.ContextAwareCqrsCommandExtensions
return ContextAwareCqrsCommandExtensions
.SendCommandAsync(contextAware, command, cancellationToken)
.AsTask()
.ToCoroutineEnumerator()
@ -50,7 +53,7 @@ public static class ContextAwareCoroutineExtensions
string? tag = null,
CancellationToken cancellationToken = default)
{
return Core.Cqrs.Extensions.ContextAwareCqrsCommandExtensions
return ContextAwareCqrsCommandExtensions
.SendCommandAsync(contextAware, command, cancellationToken)
.AsTask()
.ToCoroutineEnumerator()
@ -74,7 +77,7 @@ public static class ContextAwareCoroutineExtensions
string? tag = null,
CancellationToken cancellationToken = default)
{
return Core.Cqrs.Extensions.ContextAwareCqrsQueryExtensions
return ContextAwareCqrsQueryExtensions
.SendQueryAsync(contextAware, query, cancellationToken)
.AsTask()
.ToCoroutineEnumerator()
@ -97,7 +100,7 @@ public static class ContextAwareCoroutineExtensions
string? tag = null,
CancellationToken cancellationToken = default)
{
return Core.Cqrs.Extensions.ContextAwareCqrsExtensions
return ContextAwareCqrsExtensions
.PublishAsync(contextAware, notification, cancellationToken)
.AsTask()
.ToCoroutineEnumerator()

View File

@ -204,22 +204,24 @@ public async Task<List<ScoreData>> GetHighScores()
### 注册处理器
在架构中注册 CQRS 行为并让处理器自动扫描注册
在架构中注册 CQRS 行为;默认会自动扫描当前架构所在程序集和 `GFramework.Core` 程序集中的处理器
```csharp
public class GameArchitecture : Architecture
{
protected override void Init()
protected override void OnInitialize()
{
// 注册通用开放泛型行为
RegisterCqrsPipelineBehavior<LoggingBehavior<,>>();
RegisterCqrsPipelineBehavior<PerformanceBehavior<,>>();
// 处理器会自动通过依赖注入注册
// 默认只自动扫描当前架构程序集和 GFramework.Core 程序集中的处理器
}
}
```
如果处理器位于其他模块或扩展程序集中,需要额外接入对应程序集的处理器注册,而不是依赖默认扫描。
`RegisterCqrsPipelineBehavior<TBehavior>()` 是推荐入口;旧的 `RegisterMediatorBehavior<TBehavior>()`
仅作为兼容名称保留。当前接口支持两种形式:
@ -338,8 +340,8 @@ public class LoggingBehavior<TMessage, TResponse> : IPipelineBehavior<TMessage,
{
public async ValueTask<TResponse> Handle(
TMessage message,
CancellationToken cancellationToken,
MessageHandlerDelegate<TMessage, TResponse> next)
MessageHandlerDelegate<TMessage, TResponse> next,
CancellationToken cancellationToken)
{
var messageName = message.GetType().Name;
Console.WriteLine($"[开始] {messageName}");
@ -358,8 +360,8 @@ public class PerformanceBehavior<TMessage, TResponse> : IPipelineBehavior<TMessa
{
public async ValueTask<TResponse> Handle(
TMessage message,
CancellationToken cancellationToken,
MessageHandlerDelegate<TMessage, TResponse> next)
MessageHandlerDelegate<TMessage, TResponse> next,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
@ -390,8 +392,8 @@ public class ValidationBehavior<TMessage, TResponse> : IPipelineBehavior<TMessag
{
public async ValueTask<TResponse> Handle(
TMessage message,
CancellationToken cancellationToken,
MessageHandlerDelegate<TMessage, TResponse> next)
MessageHandlerDelegate<TMessage, TResponse> next,
CancellationToken cancellationToken)
{
// 验证输入
if (message is IValidatable validatable)