From babd132e8131ae825b936ec60253c7b4fc93a353 Mon Sep 17 00:00:00 2001 From: gewuyou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 11 May 2026 13:02:01 +0800 Subject: [PATCH] =?UTF-8?q?docs(cqrs):=20=E6=94=B6=E5=8F=A3=E6=89=B9?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=89=A9=E4=BD=99=E6=96=87=E6=A1=A3=E4=B8=8E?= =?UTF-8?q?=E8=BF=BD=E8=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 NotificationLifetime 基准并补充验证结果 - 更新 CQRS README 与 legacy Command/Query 迁移说明 - 补充 registration fallback 回归测试并同步 ai-plan 恢复点 --- .../NotificationLifetimeBenchmarks.cs | 315 ++++++++++++++++++ GFramework.Cqrs.Benchmarks/README.md | 6 +- .../Cqrs/CqrsRegistrationServiceTests.cs | 108 ++++++ GFramework.Cqrs/README.md | 18 +- .../todos/cqrs-rewrite-migration-tracking.md | 99 ++++-- .../traces/cqrs-rewrite-migration-trace.md | 52 +++ docs/zh-CN/core/command.md | 26 +- docs/zh-CN/core/query.md | 19 +- 8 files changed, 600 insertions(+), 43 deletions(-) create mode 100644 GFramework.Cqrs.Benchmarks/Messaging/NotificationLifetimeBenchmarks.cs diff --git a/GFramework.Cqrs.Benchmarks/Messaging/NotificationLifetimeBenchmarks.cs b/GFramework.Cqrs.Benchmarks/Messaging/NotificationLifetimeBenchmarks.cs new file mode 100644 index 00000000..ac83127a --- /dev/null +++ b/GFramework.Cqrs.Benchmarks/Messaging/NotificationLifetimeBenchmarks.cs @@ -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; + +/// +/// 对比单处理器 notification publish 在不同 handler 生命周期下的额外开销。 +/// +/// +/// 当前矩阵覆盖 SingletonScopedTransient。 +/// 其中 Scoped 会在每次 notification publish 前显式创建并释放真实的 DI 作用域, +/// 避免把 scoped handler 错误地压到根容器解析而扭曲生命周期对照。 +/// +[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!; + + /// + /// 控制当前 benchmark 使用的 handler 生命周期。 + /// + [Params(HandlerLifetime.Singleton, HandlerLifetime.Scoped, HandlerLifetime.Transient)] + public HandlerLifetime Lifetime { get; set; } + + /// + /// 可公平比较的 benchmark handler 生命周期集合。 + /// + public enum HandlerLifetime + { + /// + /// 复用单个 handler 实例。 + /// + Singleton, + + /// + /// 每次 publish 在显式作用域内解析并复用 handler 实例。 + /// + Scoped, + + /// + /// 每次 publish 都重新解析新的 handler 实例。 + /// + Transient + } + + /// + /// 配置 notification lifetime benchmark 的公共输出格式。 + /// + 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)); + } + } + + /// + /// 构建当前生命周期下的 GFramework 与 MediatR notification 对照宿主。 + /// + [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(); + } + } + + /// + /// 释放当前生命周期矩阵持有的 benchmark 宿主资源。 + /// + [GlobalCleanup] + public void Cleanup() + { + try + { + BenchmarkCleanupHelper.DisposeAll(_container, _serviceProvider); + } + finally + { + BenchmarkDispatcherCacheHelper.ClearDispatcherCaches(); + } + } + + /// + /// 直接调用 handler,作为不同生命周期矩阵下的 publish 额外开销 baseline。 + /// + [Benchmark(Baseline = true)] + public ValueTask PublishNotification_Baseline() + { + return _baselineHandler.Handle(_notification, CancellationToken.None); + } + + /// + /// 通过 GFramework.CQRS runtime 发布 notification。 + /// + [Benchmark] + public ValueTask PublishNotification_GFrameworkCqrs() + { + if (Lifetime == HandlerLifetime.Scoped) + { + return PublishScopedGFrameworkNotificationAsync( + _scopedRuntime!, + _scopedContainer!, + _notification, + CancellationToken.None); + } + + return _runtime!.PublishAsync(BenchmarkContext.Instance, _notification, CancellationToken.None); + } + + /// + /// 通过 MediatR 发布 notification,作为外部对照。 + /// + [Benchmark] + public Task PublishNotification_MediatR() + { + if (Lifetime == HandlerLifetime.Scoped) + { + return PublishScopedMediatRNotificationAsync(_serviceProvider, _notification, CancellationToken.None); + } + + return _publisher!.Publish(_notification, CancellationToken.None); + } + + /// + /// 按生命周期把 benchmark notification handler 注册到 GFramework 容器。 + /// + /// 当前 benchmark 拥有并负责释放的容器。 + /// 待比较的 handler 生命周期。 + private static void RegisterGFrameworkHandler(MicrosoftDiContainer container, HandlerLifetime lifetime) + { + ArgumentNullException.ThrowIfNull(container); + + switch (lifetime) + { + case HandlerLifetime.Singleton: + container.RegisterSingleton, BenchmarkNotificationHandler>(); + return; + + case HandlerLifetime.Scoped: + container.RegisterScoped, BenchmarkNotificationHandler>(); + return; + + case HandlerLifetime.Transient: + container.RegisterTransient, BenchmarkNotificationHandler>(); + return; + + default: + throw new ArgumentOutOfRangeException(nameof(lifetime), lifetime, "Unsupported benchmark handler lifetime."); + } + } + + /// + /// 将 benchmark 生命周期映射为 MediatR 组装所需的 。 + /// + /// 待比较的 handler 生命周期。 + 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.") + }; + } + + /// + /// 在真实的 publish 级作用域内执行一次 GFramework.CQRS notification 分发。 + /// + /// 复用的 scoped benchmark runtime。 + /// 负责为每次 publish 激活独立作用域的只读容器适配层。 + /// 要发布的 notification。 + /// 取消令牌。 + /// 代表当前 publish 完成的值任务。 + /// + /// notification lifetime benchmark 只关心 handler 解析和 publish 本身的热路径, + /// 因此这里复用同一个 runtime,但在每次调用前后显式创建并释放新的 DI 作用域, + /// 让 scoped handler 真正绑定到 publish 边界。 + /// + 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); + } + + /// + /// 在真实的 publish 级作用域内执行一次 MediatR notification 分发。 + /// + /// 当前 benchmark 的根 。 + /// 要发布的 notification。 + /// 取消令牌。 + /// 代表当前 publish 完成的任务。 + /// + /// 这里显式从新的 scope 解析 ,确保 Scoped handler 与依赖绑定到 publish 边界。 + /// + 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(); + await publisher.Publish(notification, cancellationToken).ConfigureAwait(false); + } + + /// + /// Benchmark notification。 + /// + /// 通知标识。 + public sealed record BenchmarkNotification(Guid Id) : + GFramework.Cqrs.Abstractions.Cqrs.INotification, + MediatR.INotification; + + /// + /// 同时实现 GFramework.CQRS 与 MediatR 契约的最小 notification handler。 + /// + public sealed class BenchmarkNotificationHandler : + GFramework.Cqrs.Abstractions.Cqrs.INotificationHandler, + MediatR.INotificationHandler + { + /// + /// 处理 GFramework.CQRS notification。 + /// + public ValueTask Handle(BenchmarkNotification notification, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(notification); + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.CompletedTask; + } + + /// + /// 处理 MediatR notification。 + /// + Task MediatR.INotificationHandler.Handle( + BenchmarkNotification notification, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(notification); + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + } +} diff --git a/GFramework.Cqrs.Benchmarks/README.md b/GFramework.Cqrs.Benchmarks/README.md index 408ff3ee..69038b45 100644 --- a/GFramework.Cqrs.Benchmarks/README.md +++ b/GFramework.Cqrs.Benchmarks/README.md @@ -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 benchmark;notification 只覆盖 steady-state 单处理器、生命周期、固定 `4 handler` fan-out - 当前没有 notification fan-out 的生命周期矩阵;`NotificationFanOutBenchmarks` 只覆盖固定 `4 handler` 的已装配宿主 - 当前没有 stream pipeline benchmark;现有 pipeline coverage 仅限 request diff --git a/GFramework.Cqrs.Tests/Cqrs/CqrsRegistrationServiceTests.cs b/GFramework.Cqrs.Tests/Cqrs/CqrsRegistrationServiceTests.cs index e80b5e3f..35f15736 100644 --- a/GFramework.Cqrs.Tests/Cqrs/CqrsRegistrationServiceTests.cs +++ b/GFramework.Cqrs.Tests/Cqrs/CqrsRegistrationServiceTests.cs @@ -87,17 +87,125 @@ internal sealed class CqrsRegistrationServiceTests }); } + /// + /// 验证当 缺失时,协调器会退化到 作为稳定程序集键。 + /// + [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(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? registeredAssemblies = null; + + registrar + .Setup(static currentRegistrar => currentRegistrar.RegisterHandlers(It.IsAny>())) + .Callback>(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>()), + 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")); + }); + } + + /// + /// 验证当 均缺失时, + /// 协调器会退化到 结果作为稳定程序集键。 + /// + [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(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? registeredAssemblies = null; + + registrar + .Setup(static currentRegistrar => currentRegistrar.RegisterHandlers(It.IsAny>())) + .Callback>(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>()), + 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")); + }); + } + /// /// 创建一个带稳定程序集键的程序集 mock,用于模拟不同 实例表示同一程序集的场景。 /// /// 要返回的程序集完整名称。 /// 配置好完整名称的程序集 mock。 private static Mock CreateAssembly(string assemblyFullName) + { + return CreateAssembly(assemblyFullName, assemblySimpleName: null, assemblyDisplayName: assemblyFullName); + } + + /// + /// 创建一个可配置程序集元数据退化路径的程序集 mock,用于验证稳定程序集键的回退顺序。 + /// + /// 要返回的程序集完整名称;为 时模拟缺失完整名称。 + /// 要返回的程序集简单名称;为 时模拟缺失简单名称。 + /// 当需要退化到 时返回的显示名称。 + /// 配置好程序集元数据的程序集 mock。 + private static Mock CreateAssembly(string? assemblyFullName, string? assemblySimpleName, string assemblyDisplayName) { var assembly = new Mock(); + 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; } diff --git a/GFramework.Cqrs/README.md b/GFramework.Cqrs/README.md index 238aa892..4c33f27b 100644 --- a/GFramework.Cqrs/README.md +++ b/GFramework.Cqrs/README.md @@ -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()`、`UseSequentialNotificationPublisher()` 或 `UseTaskWhenAllNotificationPublisher()` 会直接报错,而不是按“后注册覆盖前注册”处理。 - 内置 notification publisher 的推荐选择如下: | 策略 | 推荐场景 | 执行顺序 | 失败语义 | 备注 | @@ -134,7 +138,7 @@ var playerId = await this.SendAsync(new CreatePlayerCommand(new CreatePlayerInpu | `TaskWhenAllNotificationPublisher` | 需要让全部处理器并行完成,并在结束后统一观察失败或取消 | 不保证顺序 | 不会在首个失败时停止其余处理器;会聚合最终异常或取消结果 | 更适合语义补齐,不是性能开关 | | `UseNotificationPublisher(...)` / `UseNotificationPublisher()` | 需要接入仓库外的自定义策略或第三方策略 | 取决于具体实现 | 取决于具体实现 | 前者复用现成实例,后者让容器负责单例生命周期 | - - 若只是为了降低 fixed fan-out publish 的 steady-state 成本,当前 benchmark 并不表明 `TaskWhenAllNotificationPublisher` 会优于默认顺序发布器;它更适合你需要“等待全部处理器完成并统一观察失败”的场景。 + - 若只是为了降低 fixed fan-out publish 的 steady-state 成本,当前 benchmark 并不表明 `TaskWhenAllNotificationPublisher` 会优于默认顺序发布器;它更适合你需要“等待全部处理器完成并统一观察失败”的场景,而不是把 publish 切成另一条 generated 或更快的分发通道。 如果你需要显式保留默认顺序语义,也可以在组合根里直接声明: @@ -191,18 +195,20 @@ container.UseNotificationPublisher(); - 优先尝试消费端程序集上的 `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` 的统一入口;只有在需要更换通知策略、接入额外程序集或搭裸容器测试时,再显式配置组合根。 ## 文档入口 diff --git a/ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md b/ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md index 951e3c88..9a8beb55 100644 --- a/ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md +++ b/ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md @@ -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 改动面。 ## 活跃文档 diff --git a/ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md b/ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md index d399a86c..444ffc87 100644 --- a/ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md +++ b/ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md @@ -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 结果决定后续波次 diff --git a/docs/zh-CN/core/command.md b/docs/zh-CN/core/command.md index 25355a72..c673d62a 100644 --- a/docs/zh-CN/core/command.md +++ b/docs/zh-CN/core/command.md @@ -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) diff --git a/docs/zh-CN/core/query.md b/docs/zh-CN/core/query.md index e01cde2f..97eaa0e4 100644 --- a/docs/zh-CN/core/query.md +++ b/docs/zh-CN/core/query.md @@ -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` - `AbstractQueryHandler` @@ -108,4 +120,9 @@ using GFramework.Core.Extensions; 原因很简单:新查询路径和命令、通知、流式请求共享同一 dispatcher 与行为管道。 +可以按下面的判断来选: + +- 继续保留旧路径:为了兼容已有 `Query` 类型、旧执行器或局部修复场景 +- 迁移到 CQRS:为了把新的读取能力纳入统一 request model,而不是继续扩大 legacy 查询面 + 继续阅读:[cqrs](./cqrs.md)