docs(cqrs): 收口批处理剩余文档与追踪

- 新增 NotificationLifetime 基准并补充验证结果

- 更新 CQRS README 与 legacy Command/Query 迁移说明

- 补充 registration fallback 回归测试并同步 ai-plan 恢复点
This commit is contained in:
gewuyou 2026-05-11 13:02:01 +08:00
parent f650bc5776
commit babd132e81
8 changed files with 600 additions and 43 deletions

View File

@ -0,0 +1,315 @@
// Copyright (c) 2025-2026 GeWuYou
// SPDX-License-Identifier: Apache-2.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Columns;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Order;
using System;
using System.Threading;
using System.Threading.Tasks;
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Ioc;
using GFramework.Core.Logging;
using GFramework.Cqrs.Abstractions.Cqrs;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
namespace GFramework.Cqrs.Benchmarks.Messaging;
/// <summary>
/// 对比单处理器 notification publish 在不同 handler 生命周期下的额外开销。
/// </summary>
/// <remarks>
/// 当前矩阵覆盖 <c>Singleton</c>、<c>Scoped</c> 与 <c>Transient</c>。
/// 其中 <c>Scoped</c> 会在每次 notification publish 前显式创建并释放真实的 DI 作用域,
/// 避免把 scoped handler 错误地压到根容器解析而扭曲生命周期对照。
/// </remarks>
[Config(typeof(Config))]
public class NotificationLifetimeBenchmarks
{
private MicrosoftDiContainer _container = null!;
private ICqrsRuntime? _runtime;
private ScopedBenchmarkContainer? _scopedContainer;
private ICqrsRuntime? _scopedRuntime;
private ServiceProvider _serviceProvider = null!;
private IPublisher? _publisher;
private BenchmarkNotificationHandler _baselineHandler = null!;
private BenchmarkNotification _notification = null!;
private ILogger _runtimeLogger = null!;
/// <summary>
/// 控制当前 benchmark 使用的 handler 生命周期。
/// </summary>
[Params(HandlerLifetime.Singleton, HandlerLifetime.Scoped, HandlerLifetime.Transient)]
public HandlerLifetime Lifetime { get; set; }
/// <summary>
/// 可公平比较的 benchmark handler 生命周期集合。
/// </summary>
public enum HandlerLifetime
{
/// <summary>
/// 复用单个 handler 实例。
/// </summary>
Singleton,
/// <summary>
/// 每次 publish 在显式作用域内解析并复用 handler 实例。
/// </summary>
Scoped,
/// <summary>
/// 每次 publish 都重新解析新的 handler 实例。
/// </summary>
Transient
}
/// <summary>
/// 配置 notification lifetime benchmark 的公共输出格式。
/// </summary>
private sealed class Config : ManualConfig
{
public Config()
{
AddJob(Job.Default);
AddColumnProvider(DefaultColumnProviders.Instance);
AddColumn(new CustomColumn("Scenario", static (_, _) => "NotificationLifetime"));
AddDiagnoser(MemoryDiagnoser.Default);
WithOrderer(new DefaultOrderer(SummaryOrderPolicy.FastestToSlowest, MethodOrderPolicy.Declared));
}
}
/// <summary>
/// 构建当前生命周期下的 GFramework 与 MediatR notification 对照宿主。
/// </summary>
[GlobalSetup]
public void Setup()
{
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider
{
MinLevel = LogLevel.Fatal
};
Fixture.Setup($"NotificationLifetime/{Lifetime}", handlerCount: 1, pipelineCount: 0);
BenchmarkDispatcherCacheHelper.ClearDispatcherCaches();
_baselineHandler = new BenchmarkNotificationHandler();
_notification = new BenchmarkNotification(Guid.NewGuid());
_runtimeLogger = LoggerFactoryResolver.Provider.CreateLogger(nameof(NotificationLifetimeBenchmarks) + "." + Lifetime);
_container = BenchmarkHostFactory.CreateFrozenGFrameworkContainer(container =>
{
RegisterGFrameworkHandler(container, Lifetime);
});
if (Lifetime != HandlerLifetime.Scoped)
{
_runtime = GFramework.Cqrs.CqrsRuntimeFactory.CreateRuntime(_container, _runtimeLogger);
}
else
{
_scopedContainer = new ScopedBenchmarkContainer(_container);
_scopedRuntime = GFramework.Cqrs.CqrsRuntimeFactory.CreateRuntime(_scopedContainer, _runtimeLogger);
}
_serviceProvider = BenchmarkHostFactory.CreateMediatRServiceProvider(
configure: null,
typeof(NotificationLifetimeBenchmarks),
static candidateType => candidateType == typeof(BenchmarkNotificationHandler),
ResolveMediatRLifetime(Lifetime));
if (Lifetime != HandlerLifetime.Scoped)
{
_publisher = _serviceProvider.GetRequiredService<IPublisher>();
}
}
/// <summary>
/// 释放当前生命周期矩阵持有的 benchmark 宿主资源。
/// </summary>
[GlobalCleanup]
public void Cleanup()
{
try
{
BenchmarkCleanupHelper.DisposeAll(_container, _serviceProvider);
}
finally
{
BenchmarkDispatcherCacheHelper.ClearDispatcherCaches();
}
}
/// <summary>
/// 直接调用 handler作为不同生命周期矩阵下的 publish 额外开销 baseline。
/// </summary>
[Benchmark(Baseline = true)]
public ValueTask PublishNotification_Baseline()
{
return _baselineHandler.Handle(_notification, CancellationToken.None);
}
/// <summary>
/// 通过 GFramework.CQRS runtime 发布 notification。
/// </summary>
[Benchmark]
public ValueTask PublishNotification_GFrameworkCqrs()
{
if (Lifetime == HandlerLifetime.Scoped)
{
return PublishScopedGFrameworkNotificationAsync(
_scopedRuntime!,
_scopedContainer!,
_notification,
CancellationToken.None);
}
return _runtime!.PublishAsync(BenchmarkContext.Instance, _notification, CancellationToken.None);
}
/// <summary>
/// 通过 MediatR 发布 notification作为外部对照。
/// </summary>
[Benchmark]
public Task PublishNotification_MediatR()
{
if (Lifetime == HandlerLifetime.Scoped)
{
return PublishScopedMediatRNotificationAsync(_serviceProvider, _notification, CancellationToken.None);
}
return _publisher!.Publish(_notification, CancellationToken.None);
}
/// <summary>
/// 按生命周期把 benchmark notification handler 注册到 GFramework 容器。
/// </summary>
/// <param name="container">当前 benchmark 拥有并负责释放的容器。</param>
/// <param name="lifetime">待比较的 handler 生命周期。</param>
private static void RegisterGFrameworkHandler(MicrosoftDiContainer container, HandlerLifetime lifetime)
{
ArgumentNullException.ThrowIfNull(container);
switch (lifetime)
{
case HandlerLifetime.Singleton:
container.RegisterSingleton<GFramework.Cqrs.Abstractions.Cqrs.INotificationHandler<BenchmarkNotification>, BenchmarkNotificationHandler>();
return;
case HandlerLifetime.Scoped:
container.RegisterScoped<GFramework.Cqrs.Abstractions.Cqrs.INotificationHandler<BenchmarkNotification>, BenchmarkNotificationHandler>();
return;
case HandlerLifetime.Transient:
container.RegisterTransient<GFramework.Cqrs.Abstractions.Cqrs.INotificationHandler<BenchmarkNotification>, BenchmarkNotificationHandler>();
return;
default:
throw new ArgumentOutOfRangeException(nameof(lifetime), lifetime, "Unsupported benchmark handler lifetime.");
}
}
/// <summary>
/// 将 benchmark 生命周期映射为 MediatR 组装所需的 <see cref="ServiceLifetime" />。
/// </summary>
/// <param name="lifetime">待比较的 handler 生命周期。</param>
private static ServiceLifetime ResolveMediatRLifetime(HandlerLifetime lifetime)
{
return lifetime switch
{
HandlerLifetime.Singleton => ServiceLifetime.Singleton,
HandlerLifetime.Scoped => ServiceLifetime.Scoped,
HandlerLifetime.Transient => ServiceLifetime.Transient,
_ => throw new ArgumentOutOfRangeException(nameof(lifetime), lifetime, "Unsupported benchmark handler lifetime.")
};
}
/// <summary>
/// 在真实的 publish 级作用域内执行一次 GFramework.CQRS notification 分发。
/// </summary>
/// <param name="runtime">复用的 scoped benchmark runtime。</param>
/// <param name="scopedContainer">负责为每次 publish 激活独立作用域的只读容器适配层。</param>
/// <param name="notification">要发布的 notification。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>代表当前 publish 完成的值任务。</returns>
/// <remarks>
/// notification lifetime benchmark 只关心 handler 解析和 publish 本身的热路径,
/// 因此这里复用同一个 runtime但在每次调用前后显式创建并释放新的 DI 作用域,
/// 让 scoped handler 真正绑定到 publish 边界。
/// </remarks>
private static async ValueTask PublishScopedGFrameworkNotificationAsync(
ICqrsRuntime runtime,
ScopedBenchmarkContainer scopedContainer,
BenchmarkNotification notification,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(scopedContainer);
ArgumentNullException.ThrowIfNull(notification);
using var scopeLease = scopedContainer.EnterScope();
await runtime.PublishAsync(BenchmarkContext.Instance, notification, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// 在真实的 publish 级作用域内执行一次 MediatR notification 分发。
/// </summary>
/// <param name="rootServiceProvider">当前 benchmark 的根 <see cref="ServiceProvider" />。</param>
/// <param name="notification">要发布的 notification。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>代表当前 publish 完成的任务。</returns>
/// <remarks>
/// 这里显式从新的 scope 解析 <see cref="IPublisher" />,确保 <c>Scoped</c> handler 与依赖绑定到 publish 边界。
/// </remarks>
private static async Task PublishScopedMediatRNotificationAsync(
ServiceProvider rootServiceProvider,
BenchmarkNotification notification,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(rootServiceProvider);
ArgumentNullException.ThrowIfNull(notification);
using var scope = rootServiceProvider.CreateScope();
var publisher = scope.ServiceProvider.GetRequiredService<IPublisher>();
await publisher.Publish(notification, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Benchmark notification。
/// </summary>
/// <param name="Id">通知标识。</param>
public sealed record BenchmarkNotification(Guid Id) :
GFramework.Cqrs.Abstractions.Cqrs.INotification,
MediatR.INotification;
/// <summary>
/// 同时实现 GFramework.CQRS 与 MediatR 契约的最小 notification handler。
/// </summary>
public sealed class BenchmarkNotificationHandler :
GFramework.Cqrs.Abstractions.Cqrs.INotificationHandler<BenchmarkNotification>,
MediatR.INotificationHandler<BenchmarkNotification>
{
/// <summary>
/// 处理 GFramework.CQRS notification。
/// </summary>
public ValueTask Handle(BenchmarkNotification notification, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(notification);
cancellationToken.ThrowIfCancellationRequested();
return ValueTask.CompletedTask;
}
/// <summary>
/// 处理 MediatR notification。
/// </summary>
Task MediatR.INotificationHandler<BenchmarkNotification>.Handle(
BenchmarkNotification notification,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(notification);
cancellationToken.ThrowIfCancellationRequested();
return Task.CompletedTask;
}
}
}

View File

@ -38,13 +38,16 @@
- `Messaging/StreamStartupBenchmarks.cs`
- `Initialization``ColdStart` 两组下,`GFramework.Cqrs` reflection、`GFramework.Cqrs` generated、`MediatR`
- 其中 `ColdStart` 的边界是“新宿主 + 首个元素命中”,不是完整枚举整个 stream
- notification
- notification steady-state
- `Messaging/NotificationBenchmarks.cs`
- 单处理器 publish 下,`GFramework.Cqrs` runtime、NuGet `Mediator` source-generated concrete path、`MediatR`
- `Messaging/NotificationLifetimeBenchmarks.cs`
- 单处理器 publish 在 `Singleton / Scoped / Transient` 三类 handler 生命周期下的 baseline、`GFramework.Cqrs``MediatR` 对照
- `Messaging/NotificationFanOutBenchmarks.cs`
- 固定 `4 handler` fan-out 下的 baseline、`GFramework.Cqrs` 默认顺序发布器、内置 `TaskWhenAllNotificationPublisher`、NuGet `Mediator``MediatR`
- notification startup
- `Messaging/NotificationStartupBenchmarks.cs`
- `Initialization``ColdStart` 两组下,`GFramework.Cqrs`、NuGet `Mediator``MediatR`
## 最小使用方式
@ -95,6 +98,5 @@ dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.cspro
- 当前没有 stream 版的 NuGet `Mediator` source-generated concrete path 对照stream steady-state、lifetime、startup 现在都只覆盖 `GFramework.Cqrs``MediatR`
- 当前没有 request 生命周期下的 NuGet `Mediator` compile-time lifetime 矩阵;`RequestLifetimeBenchmarks` 只覆盖 `GFramework.Cqrs``MediatR`
- 当前没有 notification startup / cold-start benchmarknotification 只覆盖 steady-state 单处理器、生命周期、固定 `4 handler` fan-out
- 当前没有 notification fan-out 的生命周期矩阵;`NotificationFanOutBenchmarks` 只覆盖固定 `4 handler` 的已装配宿主
- 当前没有 stream pipeline benchmark现有 pipeline coverage 仅限 request

View File

@ -87,17 +87,125 @@ internal sealed class CqrsRegistrationServiceTests
});
}
/// <summary>
/// 验证当 <see cref="Assembly.FullName" /> 缺失时,协调器会退化到 <see cref="AssemblyName.Name" /> 作为稳定程序集键。
/// </summary>
[Test]
public void RegisterHandlers_Should_Fallback_To_Simple_Name_When_Full_Name_Is_Missing()
{
var logger = new TestLogger("DefaultCqrsRegistrationService", LogLevel.Debug);
var registrar = new Mock<ICqrsHandlerRegistrar>(MockBehavior.Strict);
var firstAssembly = CreateAssembly(
assemblyFullName: null,
assemblySimpleName: "GFramework.Cqrs.Tests.SimpleNameFallback",
assemblyDisplayName: "DisplayName-A");
var secondAssembly = CreateAssembly(
assemblyFullName: null,
assemblySimpleName: "GFramework.Cqrs.Tests.SimpleNameFallback",
assemblyDisplayName: "DisplayName-B");
IEnumerable<Assembly>? registeredAssemblies = null;
registrar
.Setup(static currentRegistrar => currentRegistrar.RegisterHandlers(It.IsAny<IEnumerable<Assembly>>()))
.Callback<IEnumerable<Assembly>>(assemblies => registeredAssemblies = assemblies.ToArray());
var service = CqrsRuntimeFactory.CreateRegistrationService(registrar.Object, logger);
service.RegisterHandlers([firstAssembly.Object]);
service.RegisterHandlers([secondAssembly.Object]);
registrar.Verify(
static currentRegistrar => currentRegistrar.RegisterHandlers(It.IsAny<IEnumerable<Assembly>>()),
Times.Once);
Assert.Multiple(() =>
{
Assert.That(registeredAssemblies, Is.EqualTo([firstAssembly.Object]));
var debugMessages = logger.Logs
.Where(static log => log.Level == LogLevel.Debug)
.Select(static log => log.Message)
.ToArray();
Assert.That(debugMessages, Has.Length.EqualTo(1));
Assert.That(debugMessages[0], Does.Contain("GFramework.Cqrs.Tests.SimpleNameFallback"));
Assert.That(debugMessages[0], Does.Not.Contain("DisplayName-B"));
});
}
/// <summary>
/// 验证当 <see cref="Assembly.FullName" /> 与 <see cref="AssemblyName.Name" /> 均缺失时,
/// 协调器会退化到 <see cref="object.ToString" /> 结果作为稳定程序集键。
/// </summary>
[Test]
public void RegisterHandlers_Should_Fallback_To_ToString_When_Full_Name_And_Simple_Name_Are_Missing()
{
var logger = new TestLogger("DefaultCqrsRegistrationService", LogLevel.Debug);
var registrar = new Mock<ICqrsHandlerRegistrar>(MockBehavior.Strict);
const string assemblyDisplayName = "GFramework.Cqrs.Tests.ToStringFallback";
var firstAssembly = CreateAssembly(
assemblyFullName: null,
assemblySimpleName: null,
assemblyDisplayName: assemblyDisplayName);
var secondAssembly = CreateAssembly(
assemblyFullName: null,
assemblySimpleName: null,
assemblyDisplayName: assemblyDisplayName);
IEnumerable<Assembly>? registeredAssemblies = null;
registrar
.Setup(static currentRegistrar => currentRegistrar.RegisterHandlers(It.IsAny<IEnumerable<Assembly>>()))
.Callback<IEnumerable<Assembly>>(assemblies => registeredAssemblies = assemblies.ToArray());
var service = CqrsRuntimeFactory.CreateRegistrationService(registrar.Object, logger);
service.RegisterHandlers([firstAssembly.Object]);
service.RegisterHandlers([secondAssembly.Object]);
registrar.Verify(
static currentRegistrar => currentRegistrar.RegisterHandlers(It.IsAny<IEnumerable<Assembly>>()),
Times.Once);
Assert.Multiple(() =>
{
Assert.That(registeredAssemblies, Is.EqualTo([firstAssembly.Object]));
var debugMessages = logger.Logs
.Where(static log => log.Level == LogLevel.Debug)
.Select(static log => log.Message)
.ToArray();
Assert.That(debugMessages, Has.Length.EqualTo(1));
Assert.That(debugMessages[0], Does.Contain(assemblyDisplayName));
Assert.That(debugMessages[0], Does.Contain("already registered"));
});
}
/// <summary>
/// 创建一个带稳定程序集键的程序集 mock用于模拟不同 <see cref="Assembly" /> 实例表示同一程序集的场景。
/// </summary>
/// <param name="assemblyFullName">要返回的程序集完整名称。</param>
/// <returns>配置好完整名称的程序集 mock。</returns>
private static Mock<Assembly> CreateAssembly(string assemblyFullName)
{
return CreateAssembly(assemblyFullName, assemblySimpleName: null, assemblyDisplayName: assemblyFullName);
}
/// <summary>
/// 创建一个可配置程序集元数据退化路径的程序集 mock用于验证稳定程序集键的回退顺序。
/// </summary>
/// <param name="assemblyFullName">要返回的程序集完整名称;为 <see langword="null" /> 时模拟缺失完整名称。</param>
/// <param name="assemblySimpleName">要返回的程序集简单名称;为 <see langword="null" /> 时模拟缺失简单名称。</param>
/// <param name="assemblyDisplayName">当需要退化到 <see cref="object.ToString" /> 时返回的显示名称。</param>
/// <returns>配置好程序集元数据的程序集 mock。</returns>
private static Mock<Assembly> CreateAssembly(string? assemblyFullName, string? assemblySimpleName, string assemblyDisplayName)
{
var assembly = new Mock<Assembly>();
var assemblyName = new AssemblyName();
assembly
.SetupGet(static currentAssembly => currentAssembly.FullName)
.Returns(assemblyFullName);
assemblyName.Name = assemblySimpleName;
assembly
.Setup(static currentAssembly => currentAssembly.GetName())
.Returns(assemblyName);
assembly
.Setup(static currentAssembly => currentAssembly.ToString())
.Returns(assemblyDisplayName);
return assembly;
}

View File

@ -72,7 +72,7 @@ dotnet add package GeWuYou.GFramework.Cqrs
dotnet add package GeWuYou.GFramework.Cqrs.Abstractions
```
如果你希望减少处理器注册时的反射扫描,再额外安装:
如果你希望把可静态表达的 handler 注册与 request / stream invoker 元数据前移到编译期,再额外安装:
```bash
dotnet add package GeWuYou.GFramework.Cqrs.SourceGenerators
@ -116,7 +116,9 @@ using GFramework.Cqrs.Extensions;
var playerId = await this.SendAsync(new CreatePlayerCommand(new CreatePlayerInput("Alice")));
```
`ArchitectureContext` 上也可以直接使用统一 CQRS 入口,例如 `SendRequestAsync``SendQueryAsync``PublishAsync``CreateStream`
`ArchitectureContext` 上也可以直接使用统一 CQRS 入口,例如 `SendRequestAsync``SendAsync``SendQueryAsync``PublishAsync``CreateStream`
如果你走标准 `GFramework.Core` 架构启动路径,`CqrsRuntimeModule` 会自动创建 runtime 并接线默认注册流程;只有在裸容器、测试宿主或自定义组合根里,才需要显式补齐 runtime、publisher 策略或额外程序集注册。
## 运行时行为
@ -126,6 +128,8 @@ var playerId = await this.SendAsync(new CreatePlayerCommand(new CreatePlayerInpu
- 通知分发
- 通知会分发给所有已注册 `INotificationHandler<>`;零处理器时默认静默完成。
- 若容器在 runtime 创建前已显式注册 `INotificationPublisher`,默认 runtime 会复用该策略;未注册时回退到内置 `SequentialNotificationPublisher`
- notification publish 不存在 generated invoker 通道;它始终基于当前已注册的 `INotificationHandler<>` 集合和选定的 `INotificationPublisher` 策略执行。
- 默认 runtime 只消费一个 `INotificationPublisher`;如果容器里已经存在该注册,再调用 `UseNotificationPublisher(...)``UseNotificationPublisher<TPublisher>()``UseSequentialNotificationPublisher()``UseTaskWhenAllNotificationPublisher()` 会直接报错,而不是按“后注册覆盖前注册”处理。
- 内置 notification publisher 的推荐选择如下:
| 策略 | 推荐场景 | 执行顺序 | 失败语义 | 备注 |
@ -134,7 +138,7 @@ var playerId = await this.SendAsync(new CreatePlayerCommand(new CreatePlayerInpu
| `TaskWhenAllNotificationPublisher` | 需要让全部处理器并行完成,并在结束后统一观察失败或取消 | 不保证顺序 | 不会在首个失败时停止其余处理器;会聚合最终异常或取消结果 | 更适合语义补齐,不是性能开关 |
| `UseNotificationPublisher(...)` / `UseNotificationPublisher<TPublisher>()` | 需要接入仓库外的自定义策略或第三方策略 | 取决于具体实现 | 取决于具体实现 | 前者复用现成实例,后者让容器负责单例生命周期 |
- 若只是为了降低 fixed fan-out publish 的 steady-state 成本,当前 benchmark 并不表明 `TaskWhenAllNotificationPublisher` 会优于默认顺序发布器;它更适合你需要“等待全部处理器完成并统一观察失败”的场景。
- 若只是为了降低 fixed fan-out publish 的 steady-state 成本,当前 benchmark 并不表明 `TaskWhenAllNotificationPublisher` 会优于默认顺序发布器;它更适合你需要“等待全部处理器完成并统一观察失败”的场景,而不是把 publish 切成另一条 generated 或更快的分发通道
如果你需要显式保留默认顺序语义,也可以在组合根里直接声明:
@ -191,18 +195,20 @@ container.UseNotificationPublisher<MyCustomNotificationPublisher>();
- 优先尝试消费端程序集上的 `ICqrsHandlerRegistry` 生成注册器。
- 当生成注册器同时暴露 generated request invoker provider 或 generated stream invoker provider 时registrar 会把对应 descriptor 元数据接线到 runtime 缓存。
- 生成注册器不可用或元数据损坏时,记录告警并回退到反射扫描。
- generated invoker 只覆盖 request 与 stream 两类单次分发元数据;`INotificationHandler<>` 仍然只参与 registry / fallback 注册,通知分发本身继续由 runtime 解析出的 handler 集合和 `INotificationPublisher` 策略决定。
- 当程序集声明了 `CqrsReflectionFallbackAttribute` 时,运行时会先执行生成注册器,再只补它未覆盖的 handler。
- `CqrsReflectionFallbackAttribute` 现在可以多次声明,并同时承载 `Type[]``string[]` 两类 fallback 清单。
- 运行时会优先复用 fallback 特性里直接提供的 `Type` 条目,只对字符串条目执行定向 `Assembly.GetType(...)` 查找;只有旧版空 marker 才会退回整程序集扫描。
- `CqrsReflectionFallbackAttribute` 可以多次声明,并同时承载 `Type[]``string[]` 两类 fallback 清单。
- 运行时会优先复用 fallback 特性里直接提供的 `Type` 条目,只对字符串条目执行定向 `Assembly.GetType(...)` 查找;只有旧版空 marker、空 fallback 元数据,或生成注册器整体不可用时,才会退回整程序集扫描。
- 处理器以 transient 方式注册,避免上下文感知处理器在并发请求间共享可变上下文。
如果你走标准 `GFramework.Core` 架构初始化路径,这些步骤通常由框架自动完成;裸容器或测试环境则需要显式补齐 runtime 与注册入口。
## 适用边界
- 这个包是默认实现,不是“纯契约包”。
- 这个包是默认实现,不是“纯契约包”;如果你只需要共享请求/处理器契约,请停在 `GeWuYou.GFramework.Cqrs.Abstractions`
- 处理器基类依赖 runtime 在分发前注入上下文,不适合脱离 dispatcher 直接手动实例化后调用。
- README 中的消息基类和 handler 基类位于 `GFramework.Cqrs`,接口契约位于 `GFramework.Cqrs.Abstractions`;最小示例通常需要同时引入这两个命名空间层级。
- 如果你的目标只是“先用起来”,优先沿用 `ArchitectureContext` / `IContextAware` 的统一入口;只有在需要更换通知策略、接入额外程序集或搭裸容器测试时,再显式配置组合根。
## 文档入口

View File

@ -12,57 +12,94 @@ CQRS 迁移与收敛。
## 当前恢复点
- 恢复点编号:`CQRS-REWRITE-RP-132`
- 恢复点编号:`CQRS-REWRITE-RP-133`
- 当前阶段:`Phase 8`
- 当前 PR 锚点:`PR #347`
- 当前结论:
- 已用 `$gframework-pr-review` 重新抓取并复核 `PR #347` 的 latest-head review当前仍成立的代码问题已收口到
`GFramework.Cqrs.Benchmarks` 单模块与 `ai-plan/public/cqrs-rewrite/**` 恢复入口。
- `Program.cs` 现在按“任意已生效的 artifacts 隔离配置”而不是“仅命令行 `--artifacts-suffix`”决定是否重启隔离宿主;
同时新增“目标宿主目录不得等于或嵌套在当前宿主输出目录内”的防守式校验,避免 `host/host/...` 递归膨胀。
- `RequestLifetimeBenchmarks``StreamLifetimeBenchmarks``Scoped` 路径改为复用单个 scoped runtime /
dispatcher只在每次 benchmark 调用时显式创建并释放真实 DI scope避免把 runtime 构造常量成本混进生命周期矩阵。
- `ScopedBenchmarkContainer` 已补齐只读适配语义与作用域租约的 XML 合同说明,避免 PR review 再次停留在“公开成员文档不完整”。
- active tracking / trace 已完成瘦身:历史长流水迁移到 `archive/` 新文件,当前 active 入口只保留恢复点、风险、
权威验证与下一步。
- 本轮按 `$gframework-batch-boot` 协调多波 non-conflicting subagent基线固定为
`origin/main @ 3b2e6899d5ffdcfb634b28f3846f57528fbf9196 (2026-05-11T12:25:00+08:00)`
- 本轮停止继续扩 batch 的主信号是 `reviewability / context-budget`,不是 `50` 文件阈值;
自然停点时累计 branch diff 约为 `12 files`,仍明显低于阈值。
- CQRS runtime / tests 侧已补齐并提交:
- `CqrsNotificationPublisherTests` 锁定“多 publisher 报错”与“单 dispatcher 内 publisher 缓存复用”
- `CqrsGeneratedRequestInvokerProviderTests``CqrsHandlerRegistrar` 收口 generated descriptor 的异常枚举、
坏元数据与重复 pair 回退契约
- `CqrsDispatcherCacheTests` 锁定 request / stream pipeline presence、executor cache 与上下文重新注入组合分支
- benchmark 侧已补齐并提交:
- `RequestStartupBenchmarks``Mediator` startup 对照
- `StreamStartupBenchmarks`
- `NotificationStartupBenchmarks`
- `GFramework.Cqrs.Benchmarks/README.md` 的 current coverage / gap 收口
- 文档与恢复入口侧已补齐并提交:
- `GFramework.Cqrs/README.md`
- `docs/zh-CN/core/cqrs.md`
- `docs/zh-CN/source-generators/cqrs-handler-registry-generator.md`
- `ai-plan/public/cqrs-rewrite/archive/**` 顶部导航与跳转约定
- 当前尚未提交的收尾切片仅剩:
- `GFramework.Cqrs.Benchmarks/Messaging/NotificationLifetimeBenchmarks.cs`
- `GFramework.Cqrs.Tests/Cqrs/CqrsRegistrationServiceTests.cs`
- `GFramework.Cqrs/README.md`
- `docs/zh-CN/core/command.md`
- `docs/zh-CN/core/query.md`
- 本 tracking / trace 文件本身
## 当前活跃事实
- 当前分支:`feat/cqrs-optimization`
- 当前 PR`PR #347`
- 当前写面:
- `GFramework.Cqrs.Benchmarks/Program.cs`
- `GFramework.Cqrs.Benchmarks/README.md`
- `GFramework.Cqrs.Benchmarks/Messaging/BenchmarkHostFactory.cs`
- `GFramework.Cqrs.Benchmarks/Messaging/RequestLifetimeBenchmarks.cs`
- `GFramework.Cqrs.Benchmarks/Messaging/ScopedBenchmarkContainer.cs`
- `GFramework.Cqrs.Benchmarks/Messaging/StreamLifetimeBenchmarks.cs`
- `GFramework.Cqrs.Benchmarks/Messaging/NotificationLifetimeBenchmarks.cs`
- `GFramework.Cqrs.Benchmarks/Messaging/NotificationStartupBenchmarks.cs`
- `GFramework.Cqrs.Benchmarks/Messaging/RequestStartupBenchmarks.cs`
- `GFramework.Cqrs.Benchmarks/Messaging/StreamStartupBenchmarks.cs`
- `GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherCacheTests.cs`
- `GFramework.Cqrs.Tests/Cqrs/CqrsGeneratedRequestInvokerProviderTests.cs`
- `GFramework.Cqrs.Tests/Cqrs/CqrsNotificationPublisherTests.cs`
- `GFramework.Cqrs.Tests/Cqrs/CqrsRegistrationServiceTests.cs`
- `GFramework.Cqrs/Internal/CqrsHandlerRegistrar.cs`
- `GFramework.Cqrs/README.md`
- `ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md`
- `ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md`
- `ai-plan/public/cqrs-rewrite/archive/todos/cqrs-rewrite-migration-tracking-history-through-rp131.md`
- `ai-plan/public/cqrs-rewrite/archive/traces/cqrs-rewrite-migration-trace-history-through-rp131.md`
- `docs/zh-CN/core/command.md`
- `docs/zh-CN/core/cqrs.md`
- `docs/zh-CN/core/query.md`
- `docs/zh-CN/source-generators/cqrs-handler-registry-generator.md`
- 当前基线:
- `RequestLifetimeBenchmarks.SendRequest_GFrameworkCqrs` short-job 当前约为
`Singleton 52.69 ns / 32 B``Transient 57.88 ns / 56 B``Scoped 144.72 ns / 368 B`
- `StreamLifetimeBenchmarks.Stream_GFramework*` short-job 当前约为
`Scoped + FirstItem 266.7~267.0 ns / 792 B`
`Scoped + DrainAll 331.6~332.2 ns / 856 B`
- 两条并发 smoke 均已落到独立的
`BenchmarkDotNet.Artifacts/pr347-req-scoped/host/...`
`BenchmarkDotNet.Artifacts/pr347-stream-scoped/host/...`
- 本轮 batch 启动前,分支相对基线的累计 diff 为 `0 files / 0 lines`
- 当前自然停点时,累计 diff 约为 `12 files`
- 本轮新增 benchmark smoke 结果:
- `RequestStartupBenchmarks`
- `ColdStart_GFrameworkCqrs 61.648 us / 25336 B`
- `ColdStart_Mediator 110.867 us / 57872 B`
- `ColdStart_MediatR 679.103 us / 606256 B`
- `StreamStartupBenchmarks`
- `ColdStart_GFrameworkReflection 71.13 us / 25504 B`
- `ColdStart_GFrameworkGenerated 82.12 us / 28280 B`
- `ColdStart_MediatR 933.87 us / 678992 B`
- `NotificationStartupBenchmarks`
- `ColdStart_GFrameworkCqrs 85.09 us / 24752 B`
- `ColdStart_Mediator 136.08 us / 62512 B`
- `ColdStart_MediatR 1.379 ms / 719056 B`
## 当前风险
- `Program.cs` 的“嵌套目标目录保护”只覆盖当前宿主目录与隔离宿主目录关系;若后续再扩展更多自定义 artifacts 入口,
仍需保持同一层防守式校验,避免配置分叉。
- `ScopedBenchmarkContainer` 现在明确禁止重叠 active scope若后续 benchmark 引入同一 runtime 的并行枚举或嵌套调用,
需要新的宿主模型,不能直接突破当前只读适配器的约束。
- 本轮 benchmark 结果仍是 `job short + 1 iteration` smoke用于证明路径正确与相对量级不应用作稳定性能结论。
- `NotificationLifetimeBenchmarks` 当前已跑完整默认作业,但还没并入提交;若继续新开 batch未提交面会明显降低可审查性。
- `RequestStartup` 的提交 `8990749d` 连带带入了 `CqrsDispatcherCacheTests.cs`;虽然两条切片均有效且已验证通过,但提交边界不再严格对应单个 ownership slice。
- startup 与 lifetime benchmark 的默认作业结果已足以证明路径与相对量级,但 `Initialization_*` 与少量 short-run 结果仍不应直接当成稳定排序结论。
## 最近权威验证
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
- 结果:通过,`0 warning / 0 error`
- `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release`
- 结果:通过,`0 warning / 0 error`
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
- 结果:通过,`0 warning / 0 error`
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --filter "FullyQualifiedName~CqrsRegistrationServiceTests"`
- 结果:通过,`Passed: 4, Failed: 0`
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --artifacts-suffix pr347-req-scoped --filter "*RequestLifetimeBenchmarks.SendRequest_GFrameworkCqrs*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
- 结果:通过
- 备注:`Singleton 52.69 ns / 32 B``Transient 57.88 ns / 56 B``Scoped 144.72 ns / 368 B`
@ -72,9 +109,9 @@ CQRS 迁移与收敛。
## 下一推荐步骤
1. 再次运行 `$gframework-pr-review` 复核 `PR #347` latest-head open thread 是否已随本轮 head 收敛
2. 若 review 已清空,继续留在 `GFramework.Cqrs.Benchmarks` 单模块推进下一批 benchmark 对照,而不是立即扩散到 runtime
3. 若 review 仍保留 benchmark 相关线程,优先区分 stale 与新增结论,再决定是否需要新的 scoped-host 或 artifacts 入口修补
1. 先提交当前未提交的 `NotificationLifetime + registration fallback tests + CQRS/legacy docs` 收尾切片,回收工作树到干净状态
2. 再次运行 `$gframework-pr-review`,复核 `PR #347` latest-head open thread 是否已随着本轮多波 head 收敛
3. 若继续扩 benchmark优先从 `GFramework.Cqrs.Benchmarks/README.md` 已明确列出的 gap 中选下一个单文件切片,而不是继续扩大 shared infra 改动面
## 活跃文档

View File

@ -64,3 +64,55 @@ SPDX-License-Identifier: Apache-2.0
### 当前下一步
- 推送本轮变更后,重新运行 `$gframework-pr-review`,确认 `PR #347` 的 latest-head open thread 是否已随着新 head 收敛。
### 阶段:多波 batch 收口与 benchmark / docs 扩面CQRS-REWRITE-RP-133
- 按 `$gframework-batch-boot` 启动多波 non-conflicting subagent基线固定为
`origin/main @ 3b2e6899d5ffdcfb634b28f3846f57528fbf9196 (2026-05-11T12:25:00+08:00)`
- 启动前分支累计 diff 为 `0 files / 0 lines`;自然停点时累计 branch diff 约为 `12 files`
- 主线程把 stop decision 明确交给 `reviewability / context-budget`,没有在仍有文件预算时继续机械追到 `50 files`
- 本轮 accepted delegated scope
- runtime / tests
- `CqrsNotificationPublisherTests`:补“多 publisher 报错”与“publisher 缓存复用”回归
- `CqrsGeneratedRequestInvokerProviderTests` + `CqrsHandlerRegistrar`:补 generated descriptor 坏元数据、异常枚举、重复 pair 回退契约
- `CqrsDispatcherCacheTests`:补 request / stream pipeline presence、executor cache 与上下文重新注入组合分支
- `CqrsRegistrationServiceTests`:补稳定程序集键 fallback 到 `AssemblyName.Name` / `ToString()` 的回归
- benchmarks
- `RequestStartupBenchmarks`:补 `Mediator` startup 对照
- `StreamStartupBenchmarks`
- `NotificationStartupBenchmarks`
- `NotificationLifetimeBenchmarks`
- `GFramework.Cqrs.Benchmarks/README.md`:收口当前 coverage / gap / smoke 解释边界
- docs / recovery
- `GFramework.Cqrs/README.md`
- `docs/zh-CN/core/cqrs.md`
- `docs/zh-CN/source-generators/cqrs-handler-registry-generator.md`
- `docs/zh-CN/core/command.md`
- `docs/zh-CN/core/query.md`
- `ai-plan/public/cqrs-rewrite/archive/**` 顶部导航与跳转约定
- 本轮权威验证:
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
- `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release`
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --filter "FullyQualifiedName~CqrsRegistrationServiceTests"`
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --artifacts-suffix notif-lifetime --filter "*NotificationLifetimeBenchmarks*"`
- 本轮 benchmark 结果摘要:
- `RequestStartupBenchmarks`
- `ColdStart_GFrameworkCqrs 61.648 us / 25336 B`
- `ColdStart_Mediator 110.867 us / 57872 B`
- `ColdStart_MediatR 679.103 us / 606256 B`
- `StreamStartupBenchmarks`
- `ColdStart_GFrameworkReflection 71.13 us / 25504 B`
- `ColdStart_GFrameworkGenerated 82.12 us / 28280 B`
- `ColdStart_MediatR 933.87 us / 678992 B`
- `NotificationStartupBenchmarks`
- `ColdStart_GFrameworkCqrs 85.09 us / 24752 B`
- `ColdStart_Mediator 136.08 us / 62512 B`
- `ColdStart_MediatR 1.379 ms / 719056 B`
- `NotificationLifetimeBenchmarks`
- `Singleton``GFramework 295.48 ns / 360 B``MediatR 77.99 ns / 288 B`
- `Scoped``GFramework 410.92 ns / 640 B``MediatR 213.49 ns / 632 B`
- `Transient``GFramework 311.21 ns / 416 B``MediatR 74.36 ns / 288 B`
- 当前收尾判断:
- branch diff 仍远低于 `50` 文件阈值,但 active 未提交面与 benchmark 运行输出已经足够构成自然 stop boundary
- 下一步不继续扩 batch先提交当前收尾切片并回到干净工作树再按 PR review 结果决定后续波次

View File

@ -109,25 +109,45 @@ var reward = this.SendCommand(new GetGoldRewardCommand(new GetGoldRewardInput(3)
这意味着历史命令调用链在不改调用方式的前提下,也会复用同一套 pipeline 与上下文注入语义。
只有在你直接 `new CommandExecutor()` 做隔离测试,且没有提供 `ICqrsRuntime` 时,才会回退到 legacy 直接执行;此时不会注入统一 pipeline也不会额外补上下文桥接链路。
## 兼容入口和 CQRS bridge 的关系
这里可以把旧命令路径理解成“保留旧 API、内部接到新 runtime”
- 对调用方来说,`SendCommand(...)` / `SendCommandAsync(...)` 仍然是旧命令入口
- 对运行时来说,标准 `Architecture` 路径会把这些旧命令包装成内部 bridge request再交给 `ICqrsRuntime`
- 对处理过程来说,命令最终会复用当前 CQRS 的 request pipeline 与上下文注入链路,而不是维持一套完全独立的分发栈
因此,兼容入口的意义主要是降低迁移成本,而不是鼓励新模块继续围绕旧执行器设计。
`IContextAware` 对象内,通常直接通过扩展使用:
```csharp
using GFramework.Core.Extensions;
```
## 什么时候还应该用旧命令
## 什么时候继续保留旧命令
- 你在维护既有 `Core.Command` 代码
- 你的调用链已经依赖旧 `CommandExecutor`
- 当前改动目标是局部修复,不值得同时做 CQRS 迁移
- 你需要保持现有命令类型、调用入口或测试夹具不变,只希望它们在标准架构下继续工作
## 什么时候该切到 CQRS
这类场景的重点是“让存量代码继续跑”,而不是把旧命令体系当成新模块默认入口。
下面这些场景更适合新 CQRS runtime
## 什么时候该开始迁移
如果出现下面这些信号,说明更适合把命令迁到新 CQRS
- 需要 request / notification / stream 的统一模型
- 需要 pipeline behaviors
- 需要 handler registry 生成器
- 你正在写新的业务模块,而不是维护历史命令代码
- 你希望命令处理逻辑直接落在 `AbstractCommandHandler<,>` 等 CQRS handler 上,而不是继续扩展 `AbstractCommand*`
- 你需要让命令和查询、通知共用同一套注册与调试路径
一个简单判断方法:
- 继续保留旧路径:为了兼容已有 `Command` 类型和调用链
- 迁移到 CQRS为了给新功能建立统一 request model而不是继续扩大 legacy 面积
迁移后常见写法见:[cqrs](./cqrs.md)

View File

@ -85,6 +85,17 @@ var count = this.SendQuery(
在标准架构启动路径中,这些兼容入口底层同样会转到统一 `ICqrsRuntime`
因此历史查询对象仍保持原始 `SendQuery(...)` / `SendQueryAsync(...)` 用法,但会共享新版 request pipeline 与上下文注入链路。
只有在你直接 `new QueryExecutor()``new AsyncQueryExecutor()` 做隔离测试,且没有提供 `ICqrsRuntime` 时,才会回退到 legacy 直接执行;这时异步查询也不会进入统一 CQRS pipeline。
## 兼容入口和 CQRS bridge 的关系
旧查询页面的重点不是再引入一套新执行模型,而是说明兼容入口现在如何接到 CQRS runtime
- `SendQuery(...)` / `SendQueryAsync(...)` 仍然是面向存量代码的旧 API
- 标准 `Architecture` 路径会把旧查询包装成内部 bridge request再交给 `ICqrsRuntime`
- 这让旧查询对象在不改调用方式的前提下,也能共享当前 CQRS 的 pipeline、handler 调度和上下文注入语义
如果你依赖的是 direct executor 测试或隔离运行,那么仍要把它看成 legacy 路径,而不是完整的新 CQRS 使用方式。
`IContextAware` 对象内部,通常直接使用 `GFramework.Core.Extensions` 里的扩展:
@ -97,10 +108,11 @@ using GFramework.Core.Extensions;
- 你在维护现有 `Core.Query` 代码
- 当前代码已经建立在旧查询执行器之上
- 你只想修正局部行为,不想顺手迁移整条调用链
- 你需要保留现有 `AbstractQuery*` 类型与测试入口,只要求标准架构下继续复用统一 runtime
## 什么时候改用 CQRS 查询
如果你正在写新的读取路径,优先考虑:
如果你正在写新的读取路径,或者已经需要统一读写模型,优先考虑:
- `GFramework.Cqrs.Abstractions.Cqrs.Query.IQuery<TResponse>`
- `AbstractQueryHandler<TQuery, TResponse>`
@ -108,4 +120,9 @@ using GFramework.Core.Extensions;
原因很简单:新查询路径和命令、通知、流式请求共享同一 dispatcher 与行为管道。
可以按下面的判断来选:
- 继续保留旧路径:为了兼容已有 `Query` 类型、旧执行器或局部修复场景
- 迁移到 CQRS为了把新的读取能力纳入统一 request model而不是继续扩大 legacy 查询面
继续阅读:[cqrs](./cqrs.md)