diff --git a/GFramework.Cqrs.Benchmarks/Messaging/NotificationLifetimeBenchmarks.cs b/GFramework.Cqrs.Benchmarks/Messaging/NotificationLifetimeBenchmarks.cs new file mode 100644 index 00000000..f684f35e --- /dev/null +++ b/GFramework.Cqrs.Benchmarks/Messaging/NotificationLifetimeBenchmarks.cs @@ -0,0 +1,321 @@ +// 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(_scopedContainer, _container, _serviceProvider); + } + finally + { + BenchmarkDispatcherCacheHelper.ClearDispatcherCaches(); + } + } + + /// + /// 直接调用 handler,作为不同生命周期矩阵下的 publish 额外开销 baseline。 + /// + /// 代表基线 handler 完成当前 notification 处理的值任务。 + [Benchmark(Baseline = true)] + public ValueTask PublishNotification_Baseline() + { + return _baselineHandler.Handle(_notification, CancellationToken.None); + } + + /// + /// 通过 GFramework.CQRS runtime 发布 notification。 + /// + /// 代表当前 GFramework.CQRS publish 完成的值任务。 + [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,作为外部对照。 + /// + /// 代表当前 MediatR publish 完成的任务。 + [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。 + /// + /// 当前要处理的 notification。 + /// 取消令牌。 + /// 代表当前 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/Messaging/NotificationStartupBenchmarks.cs b/GFramework.Cqrs.Benchmarks/Messaging/NotificationStartupBenchmarks.cs new file mode 100644 index 00000000..5ede14f1 --- /dev/null +++ b/GFramework.Cqrs.Benchmarks/Messaging/NotificationStartupBenchmarks.cs @@ -0,0 +1,289 @@ +// 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; +using ILogger = GFramework.Core.Abstractions.Logging.ILogger; +using GeneratedMediator = Mediator.Mediator; + +namespace GFramework.Cqrs.Benchmarks.Messaging; + +/// +/// 对比 notification 宿主在 GFramework.CQRS、NuGet `Mediator` 与 MediatR 之间的初始化与首次发布成本。 +/// +/// +/// 该矩阵刻意保持“单 notification + 单 handler + 最小宿主”的对称形状, +/// 只观察宿主构建与首个 publish 命中的额外开销,不把 fan-out 或自定义发布策略混入 startup 结论。 +/// +[Config(typeof(Config))] +public class NotificationStartupBenchmarks +{ + private static readonly ILogger RuntimeLogger = CreateLogger(nameof(NotificationStartupBenchmarks)); + private static readonly BenchmarkNotification Notification = new(Guid.NewGuid()); + + private MicrosoftDiContainer _container = null!; + private ICqrsRuntime _runtime = null!; + private ServiceProvider _serviceProvider = null!; + private IPublisher _publisher = null!; + private ServiceProvider _mediatorServiceProvider = null!; + private GeneratedMediator _mediator = null!; + + /// + /// 配置 notification startup benchmark 的公共输出格式。 + /// + private sealed class Config : ManualConfig + { + public Config() + { + AddJob(Job.Default + .WithId("ColdStart") + .WithInvocationCount(1) + .WithUnrollFactor(1)); + AddColumnProvider(DefaultColumnProviders.Instance); + AddColumn(new CustomColumn("Scenario", static (_, _) => "NotificationStartup"), TargetMethodColumn.Method, CategoriesColumn.Default); + AddDiagnoser(MemoryDiagnoser.Default); + AddLogicalGroupRules(BenchmarkLogicalGroupRule.ByCategory); + WithOrderer(new DefaultOrderer(SummaryOrderPolicy.FastestToSlowest, MethodOrderPolicy.Declared)); + } + } + + /// + /// 构建 startup benchmark 复用的最小 notification 宿主对象。 + /// + [GlobalSetup] + public void Setup() + { + Fixture.Setup("NotificationStartup", handlerCount: 1, pipelineCount: 0); + + _serviceProvider = CreateMediatRServiceProvider(); + _publisher = _serviceProvider.GetRequiredService(); + + _mediatorServiceProvider = CreateMediatorServiceProvider(); + _mediator = _mediatorServiceProvider.GetRequiredService(); + + _container = CreateGFrameworkContainer(); + _runtime = CreateGFrameworkRuntime(_container); + } + + /// + /// 在每次 cold-start 迭代前清空 dispatcher 静态缓存,确保每组 benchmark 都重新命中首次绑定路径。 + /// + [IterationSetup] + public void ResetColdStartCaches() + { + BenchmarkDispatcherCacheHelper.ClearDispatcherCaches(); + } + + /// + /// 释放 startup benchmark 复用的宿主对象。 + /// + [GlobalCleanup] + public void Cleanup() + { + BenchmarkCleanupHelper.DisposeAll(_container, _serviceProvider, _mediatorServiceProvider); + } + + /// + /// 返回已构建宿主中的 MediatR publisher,作为 initialization 组的句柄解析 baseline。 + /// + /// 当前 benchmark 复用的 MediatR publisher。 + [Benchmark(Baseline = true)] + [BenchmarkCategory("Initialization")] + public IPublisher Initialization_MediatR() + { + return _publisher; + } + + /// + /// 返回已构建宿主中的 GFramework.CQRS runtime,确保与 MediatR baseline 处于相同初始化阶段。 + /// + /// 当前 benchmark 复用的 GFramework.CQRS runtime。 + [Benchmark] + [BenchmarkCategory("Initialization")] + public ICqrsRuntime Initialization_GFrameworkCqrs() + { + return _runtime; + } + + /// + /// 返回已构建宿主中的 `Mediator` concrete mediator,作为 source-generated 对照组的初始化句柄。 + /// + /// 当前 benchmark 复用的 `Mediator` concrete mediator。 + [Benchmark] + [BenchmarkCategory("Initialization")] + public GeneratedMediator Initialization_Mediator() + { + return _mediator; + } + + /// + /// 在新宿主上首次发布 notification,作为 MediatR 的 cold-start baseline。 + /// + /// 代表首次 publish 完成的任务。 + [Benchmark(Baseline = true)] + [BenchmarkCategory("ColdStart")] + public async Task ColdStart_MediatR() + { + using var serviceProvider = CreateMediatRServiceProvider(); + var publisher = serviceProvider.GetRequiredService(); + await publisher.Publish(Notification, CancellationToken.None).ConfigureAwait(false); + } + + /// + /// 在新 runtime 上首次发布 notification,量化 GFramework.CQRS 的 first-hit 成本。 + /// + /// 代表首次 publish 完成的值任务。 + [Benchmark] + [BenchmarkCategory("ColdStart")] + public async ValueTask ColdStart_GFrameworkCqrs() + { + using var container = CreateGFrameworkContainer(); + var runtime = CreateGFrameworkRuntime(container); + await runtime.PublishAsync(BenchmarkContext.Instance, Notification, CancellationToken.None).ConfigureAwait(false); + } + + /// + /// 在新的 `Mediator` 宿主上首次发布 notification,量化 source-generated concrete path 的 cold-start 成本。 + /// + /// 代表首次 publish 完成的值任务。 + [Benchmark] + [BenchmarkCategory("ColdStart")] + public async ValueTask ColdStart_Mediator() + { + using var serviceProvider = CreateMediatorServiceProvider(); + var mediator = serviceProvider.GetRequiredService(); + await mediator.Publish(Notification, CancellationToken.None).ConfigureAwait(false); + } + + /// + /// 构建只承载当前 benchmark notification 的最小 GFramework.CQRS runtime。 + /// + /// + /// startup benchmark 只需要验证单 handler publish 的首击路径, + /// 因此这里继续使用单点手工注册,避免把更广泛的注册协调逻辑混入结果。 + /// + private static MicrosoftDiContainer CreateGFrameworkContainer() + { + return BenchmarkHostFactory.CreateFrozenGFrameworkContainer(static container => + { + container.RegisterTransient, BenchmarkNotificationHandler>(); + }); + } + + /// + /// 基于已冻结的 benchmark 容器构建最小 GFramework.CQRS runtime。 + /// + /// 当前 benchmark 拥有并负责释放的容器。 + /// 可直接发布 notification 的 runtime。 + private static ICqrsRuntime CreateGFrameworkRuntime(MicrosoftDiContainer container) + { + ArgumentNullException.ThrowIfNull(container); + return GFramework.Cqrs.CqrsRuntimeFactory.CreateRuntime(container, RuntimeLogger); + } + + /// + /// 构建只承载当前 benchmark notification handler 的最小 MediatR 对照宿主。 + /// + /// 可直接解析 的 DI 宿主。 + private static ServiceProvider CreateMediatRServiceProvider() + { + return BenchmarkHostFactory.CreateMediatRServiceProvider( + configure: null, + typeof(NotificationStartupBenchmarks), + static candidateType => candidateType == typeof(BenchmarkNotificationHandler), + ServiceLifetime.Transient); + } + + /// + /// 构建只承载当前 benchmark notification handler 的最小 `Mediator` 对照宿主。 + /// + /// 可直接解析 generated `Mediator.Mediator` 的 DI 宿主。 + private static ServiceProvider CreateMediatorServiceProvider() + { + return BenchmarkHostFactory.CreateMediatorServiceProvider(configure: null); + } + + /// + /// 为 benchmark 创建稳定的 fatal 级 logger,避免把日志成本混入 startup 测量。 + /// + /// logger 分类名。 + /// 当前 benchmark 使用的稳定 logger。 + private static ILogger CreateLogger(string categoryName) + { + LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider + { + MinLevel = LogLevel.Fatal + }; + return LoggerFactoryResolver.Provider.CreateLogger(categoryName); + } + + /// + /// Benchmark notification。 + /// + /// 通知标识。 + public sealed record BenchmarkNotification(Guid Id) : + GFramework.Cqrs.Abstractions.Cqrs.INotification, + Mediator.INotification, + MediatR.INotification; + + /// + /// 同时实现 GFramework.CQRS、NuGet `Mediator` 与 MediatR 契约的最小 notification handler。 + /// + public sealed class BenchmarkNotificationHandler : + GFramework.Cqrs.Abstractions.Cqrs.INotificationHandler, + Mediator.INotificationHandler, + MediatR.INotificationHandler + { + /// + /// 处理 GFramework.CQRS notification。 + /// + /// 当前 notification。 + /// 取消令牌。 + /// 表示处理完成的值任务。 + public ValueTask Handle(BenchmarkNotification notification, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(notification); + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.CompletedTask; + } + + /// + /// 处理 NuGet `Mediator` notification。 + /// + /// 当前 notification。 + /// 取消令牌。 + /// 表示处理完成的值任务。 + ValueTask Mediator.INotificationHandler.Handle( + BenchmarkNotification notification, + CancellationToken cancellationToken) + { + return Handle(notification, cancellationToken); + } + + /// + /// 处理 MediatR notification。 + /// + /// 当前 notification。 + /// 取消令牌。 + /// 表示处理完成的任务。 + Task MediatR.INotificationHandler.Handle( + BenchmarkNotification notification, + CancellationToken cancellationToken) + { + return Handle(notification, cancellationToken).AsTask(); + } + } +} diff --git a/GFramework.Cqrs.Benchmarks/Messaging/RequestStartupBenchmarks.cs b/GFramework.Cqrs.Benchmarks/Messaging/RequestStartupBenchmarks.cs index b37fa20c..2c685fed 100644 --- a/GFramework.Cqrs.Benchmarks/Messaging/RequestStartupBenchmarks.cs +++ b/GFramework.Cqrs.Benchmarks/Messaging/RequestStartupBenchmarks.cs @@ -17,11 +17,12 @@ using GFramework.Cqrs.Abstractions.Cqrs; using MediatR; using Microsoft.Extensions.DependencyInjection; using ILogger = GFramework.Core.Abstractions.Logging.ILogger; +using GeneratedMediator = Mediator.Mediator; namespace GFramework.Cqrs.Benchmarks.Messaging; /// -/// 对比 request 宿主的初始化与首次分发成本,作为后续吸收 `Mediator` comparison benchmark 设计的 startup 基线。 +/// 对比 request 宿主在 GFramework.CQRS、NuGet `Mediator` 与 MediatR 之间的初始化与首次分发成本。 /// [Config(typeof(Config))] public class RequestStartupBenchmarks @@ -31,7 +32,9 @@ public class RequestStartupBenchmarks private MicrosoftDiContainer _container = null!; private ServiceProvider _serviceProvider = null!; + private ServiceProvider _mediatorServiceProvider = null!; private IMediator _mediatr = null!; + private GeneratedMediator _mediator = null!; private ICqrsRuntime _runtime = null!; /// @@ -63,6 +66,8 @@ public class RequestStartupBenchmarks _serviceProvider = CreateMediatRServiceProvider(); _mediatr = _serviceProvider.GetRequiredService(); + _mediatorServiceProvider = CreateMediatorServiceProvider(); + _mediator = _mediatorServiceProvider.GetRequiredService(); _container = CreateGFrameworkContainer(); _runtime = CreateGFrameworkRuntime(_container); } @@ -86,7 +91,7 @@ public class RequestStartupBenchmarks [GlobalCleanup] public void Cleanup() { - BenchmarkCleanupHelper.DisposeAll(_container, _serviceProvider); + BenchmarkCleanupHelper.DisposeAll(_container, _serviceProvider, _mediatorServiceProvider); } /// @@ -109,6 +114,16 @@ public class RequestStartupBenchmarks return _runtime; } + /// + /// 返回已构建宿主中的 `Mediator` concrete mediator,作为 source-generated 对照组的初始化句柄。 + /// + [Benchmark] + [BenchmarkCategory("Initialization")] + public GeneratedMediator Initialization_Mediator() + { + return _mediator; + } + /// /// 在新宿主上首次发送 request,作为 MediatR 的 cold-start baseline。 /// @@ -133,6 +148,18 @@ public class RequestStartupBenchmarks return await runtime.SendAsync(BenchmarkContext.Instance, Request, CancellationToken.None).ConfigureAwait(false); } + /// + /// 在新的 `Mediator` 宿主上首次发送 request,量化 source-generated concrete path 的 cold-start 成本。 + /// + [Benchmark] + [BenchmarkCategory("ColdStart")] + public async ValueTask ColdStart_Mediator() + { + using var serviceProvider = CreateMediatorServiceProvider(); + var mediator = serviceProvider.GetRequiredService(); + return await mediator.Send(Request, CancellationToken.None).ConfigureAwait(false); + } + /// /// 构建只承载当前 benchmark request 的最小 GFramework.CQRS runtime。 /// @@ -170,6 +197,14 @@ public class RequestStartupBenchmarks ServiceLifetime.Transient); } + /// + /// 构建只承载当前 benchmark request 的最小 `Mediator` 对照宿主。 + /// + private static ServiceProvider CreateMediatorServiceProvider() + { + return BenchmarkHostFactory.CreateMediatorServiceProvider(configure: null); + } + /// /// 为 benchmark 创建稳定的 fatal 级 logger,避免把日志成本混入 startup 测量。 /// @@ -188,6 +223,7 @@ public class RequestStartupBenchmarks /// 请求标识。 public sealed record BenchmarkRequest(Guid Id) : GFramework.Cqrs.Abstractions.Cqrs.IRequest, + Mediator.IRequest, MediatR.IRequest; /// @@ -197,10 +233,11 @@ public class RequestStartupBenchmarks public sealed record BenchmarkResponse(Guid Id); /// - /// 同时实现 GFramework.CQRS 与 MediatR 契约的最小 request handler。 + /// 同时实现 GFramework.CQRS、NuGet `Mediator` 与 MediatR 契约的最小 request handler。 /// public sealed class BenchmarkRequestHandler : GFramework.Cqrs.Abstractions.Cqrs.IRequestHandler, + Mediator.IRequestHandler, MediatR.IRequestHandler { /// @@ -211,6 +248,16 @@ public class RequestStartupBenchmarks return ValueTask.FromResult(new BenchmarkResponse(request.Id)); } + /// + /// 处理 NuGet `Mediator` request。 + /// + ValueTask Mediator.IRequestHandler.Handle( + BenchmarkRequest request, + CancellationToken cancellationToken) + { + return Handle(request, cancellationToken); + } + /// /// 处理 MediatR request。 /// diff --git a/GFramework.Cqrs.Benchmarks/Messaging/StreamStartupBenchmarks.cs b/GFramework.Cqrs.Benchmarks/Messaging/StreamStartupBenchmarks.cs new file mode 100644 index 00000000..26947bbd --- /dev/null +++ b/GFramework.Cqrs.Benchmarks/Messaging/StreamStartupBenchmarks.cs @@ -0,0 +1,430 @@ +// 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.Collections.Generic; +using System.Reflection; +using System.Runtime.CompilerServices; +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; + +[assembly: GFramework.Cqrs.CqrsHandlerRegistryAttribute( + typeof(GFramework.Cqrs.Benchmarks.Messaging.StreamStartupBenchmarks.GeneratedRegistry))] + +namespace GFramework.Cqrs.Benchmarks.Messaging; + +/// +/// 对比 stream 宿主在 GFramework.CQRS reflection / generated 与 MediatR 之间的初始化与首次建流命中成本。 +/// +/// +/// 该场景与 保持相同的 `Initialization + ColdStart` 结构, +/// 但 cold-start 边界改为“新宿主 + 首个元素命中”,因为 stream 的首个 MoveNextAsync +/// 才会真正覆盖建流后的首次处理链路。 +/// +[Config(typeof(Config))] +public class StreamStartupBenchmarks +{ + private static readonly ILogger ReflectionRuntimeLogger = CreateLogger(nameof(StreamStartupBenchmarks) + ".Reflection"); + private static readonly ILogger GeneratedRuntimeLogger = CreateLogger(nameof(StreamStartupBenchmarks) + ".Generated"); + private static readonly BenchmarkStreamRequest Request = new(Guid.NewGuid(), 3); + + private MicrosoftDiContainer _reflectionContainer = null!; + private ICqrsRuntime _reflectionRuntime = null!; + private MicrosoftDiContainer _generatedContainer = null!; + private ICqrsRuntime _generatedRuntime = null!; + private ServiceProvider _serviceProvider = null!; + private IMediator _mediatr = null!; + + /// + /// 配置 stream startup benchmark 的公共输出格式。 + /// + private sealed class Config : ManualConfig + { + public Config() + { + AddJob(Job.Default + .WithId("ColdStart") + .WithInvocationCount(1) + .WithUnrollFactor(1)); + AddColumnProvider(DefaultColumnProviders.Instance); + AddColumn(new CustomColumn("Scenario", static (_, _) => "StreamStartup"), TargetMethodColumn.Method, CategoriesColumn.Default); + AddDiagnoser(MemoryDiagnoser.Default); + AddLogicalGroupRules(BenchmarkLogicalGroupRule.ByCategory); + WithOrderer(new DefaultOrderer(SummaryOrderPolicy.FastestToSlowest, MethodOrderPolicy.Declared)); + } + } + + /// + /// 构建 startup benchmark 复用的 reflection / generated / MediatR 宿主对象。 + /// + [GlobalSetup] + public void Setup() + { + Fixture.Setup("StreamStartup", handlerCount: 1, pipelineCount: 0); + + _reflectionContainer = CreateReflectionContainer(); + _reflectionRuntime = CreateRuntime(_reflectionContainer, ReflectionRuntimeLogger); + + _generatedContainer = CreateGeneratedContainer(); + _generatedRuntime = CreateRuntime(_generatedContainer, GeneratedRuntimeLogger); + + _serviceProvider = CreateMediatRServiceProvider(); + _mediatr = _serviceProvider.GetRequiredService(); + } + + /// + /// 在每次 cold-start 迭代前清空 dispatcher 静态缓存,确保首次绑定路径可重复观察。 + /// + [IterationSetup] + public void ResetColdStartCaches() + { + BenchmarkDispatcherCacheHelper.ClearDispatcherCaches(); + } + + /// + /// 释放 startup benchmark 复用的宿主对象。 + /// + [GlobalCleanup] + public void Cleanup() + { + BenchmarkCleanupHelper.DisposeAll(_reflectionContainer, _generatedContainer, _serviceProvider); + } + + /// + /// 返回已构建宿主中的 MediatR mediator,作为 initialization 组的句柄解析 baseline。 + /// + /// 当前 benchmark 复用的 MediatR mediator。 + [Benchmark(Baseline = true)] + [BenchmarkCategory("Initialization")] + public IMediator Initialization_MediatR() + { + return _mediatr; + } + + /// + /// 返回已构建宿主中的 GFramework.CQRS reflection runtime,观察默认 stream binding 宿主句柄解析成本。 + /// + /// 当前 benchmark 复用的 reflection CQRS runtime。 + [Benchmark] + [BenchmarkCategory("Initialization")] + public ICqrsRuntime Initialization_GFrameworkReflection() + { + return _reflectionRuntime; + } + + /// + /// 返回已构建宿主中的 GFramework.CQRS generated runtime,观察 generated stream invoker 宿主句柄解析成本。 + /// + /// 当前 benchmark 复用的 generated CQRS runtime。 + [Benchmark] + [BenchmarkCategory("Initialization")] + public ICqrsRuntime Initialization_GFrameworkGenerated() + { + return _generatedRuntime; + } + + /// + /// 在新宿主上首次创建并推进 stream,作为 MediatR 的 cold-start baseline。 + /// + /// 首个 stream 响应元素。 + [Benchmark(Baseline = true)] + [BenchmarkCategory("ColdStart")] + public async Task ColdStart_MediatR() + { + using var serviceProvider = CreateMediatRServiceProvider(); + var mediator = serviceProvider.GetRequiredService(); + return await ConsumeFirstItemAsync(mediator.CreateStream(Request, CancellationToken.None), CancellationToken.None).ConfigureAwait(false); + } + + /// + /// 在新的 reflection runtime 上首次创建并推进 stream,量化默认 stream binding 的 first-hit 成本。 + /// + /// 首个 stream 响应元素。 + [Benchmark] + [BenchmarkCategory("ColdStart")] + public async ValueTask ColdStart_GFrameworkReflection() + { + using var container = CreateReflectionContainer(); + var runtime = CreateRuntime(container, ReflectionRuntimeLogger); + return await ConsumeFirstItemAsync( + runtime.CreateStream(BenchmarkContext.Instance, Request, CancellationToken.None), + CancellationToken.None) + .ConfigureAwait(false); + } + + /// + /// 在新的 generated runtime 上首次创建并推进 stream,量化 generated stream invoker 路径的 first-hit 成本。 + /// + /// 首个 stream 响应元素。 + [Benchmark] + [BenchmarkCategory("ColdStart")] + public async ValueTask ColdStart_GFrameworkGenerated() + { + using var container = CreateGeneratedContainer(); + var runtime = CreateRuntime(container, GeneratedRuntimeLogger); + return await ConsumeFirstItemAsync( + runtime.CreateStream(BenchmarkContext.Instance, Request, CancellationToken.None), + CancellationToken.None) + .ConfigureAwait(false); + } + + /// + /// 构建只承载当前 benchmark handler 的最小 reflection GFramework.CQRS 容器。 + /// + private static MicrosoftDiContainer CreateReflectionContainer() + { + return BenchmarkHostFactory.CreateFrozenGFrameworkContainer(static container => + { + container.RegisterTransient< + GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler, + BenchmarkStreamHandler>(); + }); + } + + /// + /// 构建只承载当前 benchmark generated registry 的最小 generated GFramework.CQRS 容器。 + /// + private static MicrosoftDiContainer CreateGeneratedContainer() + { + return BenchmarkHostFactory.CreateFrozenGFrameworkContainer(static container => + { + BenchmarkHostFactory.RegisterGeneratedBenchmarkRegistry(container); + }); + } + + /// + /// 基于已冻结的 benchmark 容器构建最小 GFramework.CQRS runtime。 + /// + /// 当前 benchmark 拥有并负责释放的容器。 + /// 当前 runtime 使用的 benchmark logger。 + private static ICqrsRuntime CreateRuntime(MicrosoftDiContainer container, ILogger logger) + { + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(logger); + + return GFramework.Cqrs.CqrsRuntimeFactory.CreateRuntime(container, logger); + } + + /// + /// 构建只承载当前 benchmark handler 的最小 MediatR 对照宿主。 + /// + private static ServiceProvider CreateMediatRServiceProvider() + { + return BenchmarkHostFactory.CreateMediatRServiceProvider( + configure: null, + typeof(StreamStartupBenchmarks), + static candidateType => candidateType == typeof(BenchmarkStreamHandler), + ServiceLifetime.Transient); + } + + /// + /// 推进 stream 到首个元素,并返回该元素作为 cold-start 结果。 + /// + /// 当前 stream 的响应类型。 + /// 待推进的异步响应序列。 + /// 用于向异步枚举器传播取消的令牌。 + /// 首个元素。 + /// stream 未产生任何元素。 + private static async ValueTask ConsumeFirstItemAsync( + IAsyncEnumerable responses, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(responses); + + var enumerator = responses.GetAsyncEnumerator(cancellationToken); + await using (enumerator.ConfigureAwait(false)) + { + if (await enumerator.MoveNextAsync().ConfigureAwait(false)) + { + return enumerator.Current; + } + } + + throw new InvalidOperationException("The benchmark stream must yield at least one response."); + } + + /// + /// 为 benchmark 创建稳定的 fatal 级 logger,避免把日志成本混入 startup 测量。 + /// + private static ILogger CreateLogger(string categoryName) + { + LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider + { + MinLevel = LogLevel.Fatal + }; + return LoggerFactoryResolver.Provider.CreateLogger(categoryName); + } + + /// + /// Benchmark stream request。 + /// + /// 请求标识。 + /// 返回元素数量。 + public sealed record BenchmarkStreamRequest(Guid Id, int ItemCount) : + GFramework.Cqrs.Abstractions.Cqrs.IStreamRequest, + MediatR.IStreamRequest; + + /// + /// Benchmark stream response。 + /// + /// 响应标识。 + public sealed record BenchmarkResponse(Guid Id); + + /// + /// 同时实现 GFramework.CQRS 与 MediatR 契约的最小 stream handler。 + /// + public sealed class BenchmarkStreamHandler : + GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler, + MediatR.IStreamRequestHandler + { + /// + /// 处理 GFramework.CQRS stream request。 + /// + /// 当前 stream 请求。 + /// 用于中断异步枚举的取消令牌。 + /// 按请求元素数量延迟生成的异步响应序列。 + public IAsyncEnumerable Handle( + BenchmarkStreamRequest request, + CancellationToken cancellationToken) + { + return EnumerateAsync(request, cancellationToken); + } + + /// + /// 处理 MediatR stream request。 + /// + /// 当前 stream 请求。 + /// 用于中断异步枚举的取消令牌。 + /// 按请求元素数量延迟生成的异步响应序列。 + IAsyncEnumerable MediatR.IStreamRequestHandler.Handle( + BenchmarkStreamRequest request, + CancellationToken cancellationToken) + { + return EnumerateAsync(request, cancellationToken); + } + + /// + /// 生成固定长度的 benchmark stream,确保 cold-start 与 steady-state 维度共用同一份响应形状。 + /// + /// 当前 stream 请求。 + /// 用于向异步枚举器传播取消的令牌。 + /// 按请求数量生成的异步响应序列。 + private static async IAsyncEnumerable EnumerateAsync( + BenchmarkStreamRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + for (var index = 0; index < request.ItemCount; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return new BenchmarkResponse(request.Id); + await Task.CompletedTask.ConfigureAwait(false); + } + } + } + + /// + /// 为 stream startup benchmark 提供 hand-written generated registry, + /// 以便独立比较 generated stream invoker 的初始化与首次命中成本。 + /// + public sealed class GeneratedRegistry : + GFramework.Cqrs.ICqrsHandlerRegistry, + GFramework.Cqrs.ICqrsStreamInvokerProvider, + GFramework.Cqrs.IEnumeratesCqrsStreamInvokerDescriptors + { + private static readonly GFramework.Cqrs.CqrsStreamInvokerDescriptor Descriptor = + new( + typeof(GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler< + BenchmarkStreamRequest, + BenchmarkResponse>), + typeof(GeneratedRegistry).GetMethod( + nameof(InvokeBenchmarkStreamHandler), + BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidOperationException("Missing generated stream startup benchmark method.")); + + private static readonly IReadOnlyList Descriptors = + [ + new GFramework.Cqrs.CqrsStreamInvokerDescriptorEntry( + typeof(BenchmarkStreamRequest), + typeof(BenchmarkResponse), + Descriptor) + ]; + + /// + /// 把 startup benchmark handler 注册为 transient,保持与 cold-start 对照宿主一致的 handler 生命周期。 + /// + /// 承载 generated handler 注册结果的目标服务集合。 + /// 记录 generated registry 注册过程的日志器。 + public void Register(IServiceCollection services, ILogger logger) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + services.AddTransient( + typeof(GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler< + BenchmarkStreamRequest, + BenchmarkResponse>), + typeof(BenchmarkStreamHandler)); + logger.Debug("Registered generated stream startup benchmark handler."); + } + + /// + /// 返回当前 provider 暴露的全部 generated stream invoker 描述符。 + /// + /// 当前 startup benchmark 的 generated stream invoker 描述符集合。 + public IReadOnlyList GetDescriptors() + { + return Descriptors; + } + + /// + /// 为目标流式请求/响应类型对返回 generated stream invoker 描述符。 + /// + /// 待匹配的 stream 请求类型。 + /// 待匹配的 stream 响应类型。 + /// 匹配成功时返回的 generated stream invoker 描述符。 + /// 命中当前 benchmark 请求/响应类型对时返回 ;否则返回 + public bool TryGetDescriptor( + Type requestType, + Type responseType, + out GFramework.Cqrs.CqrsStreamInvokerDescriptor? descriptor) + { + if (requestType == typeof(BenchmarkStreamRequest) && + responseType == typeof(BenchmarkResponse)) + { + descriptor = Descriptor; + return true; + } + + descriptor = null; + return false; + } + + /// + /// 模拟 generated stream invoker provider 为 startup benchmark 产出的开放静态调用入口。 + /// + /// 当前 benchmark 注册的 stream handler 实例。 + /// 当前 benchmark 的 stream 请求对象。 + /// 用于中断异步枚举的取消令牌。 + /// 由 handler 产生的异步响应序列。 + public static object InvokeBenchmarkStreamHandler(object handler, object request, CancellationToken cancellationToken) + { + var typedHandler = (GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler< + BenchmarkStreamRequest, + BenchmarkResponse>)handler; + var typedRequest = (BenchmarkStreamRequest)request; + return typedHandler.Handle(typedRequest, cancellationToken); + } + } +} diff --git a/GFramework.Cqrs.Benchmarks/README.md b/GFramework.Cqrs.Benchmarks/README.md index ee518bc9..69038b45 100644 --- a/GFramework.Cqrs.Benchmarks/README.md +++ b/GFramework.Cqrs.Benchmarks/README.md @@ -1,39 +1,53 @@ # GFramework.Cqrs.Benchmarks -该模块承载 `GFramework.Cqrs` 的独立性能基准工程,用于持续比较运行时 dispatch、publish、cold-start 与后续 generator / pipeline 收口的成本变化。 +该模块承载 `GFramework.Cqrs` 的独立性能基准工程,用于在当前 HEAD 上复核 request、stream、notification 的 steady-state 与 startup 成本边界。 ## 目的 -- 为 `GFramework.Cqrs` 建立独立于 NUnit 集成测试的 BenchmarkDotNet 基线 -- 参考 `ai-libs/Mediator/benchmarks` 的场景组织方式,逐步补齐 request、notification、stream 与初始化成本对比 -- 为后续吸收 `Mediator` 的 dispatch 设计、fixture 组织和对比矩阵提供可重复验证入口 +- 为 `GFramework.Cqrs` 提供独立于测试工程的 BenchmarkDotNet 复核入口 +- 让 request、stream、notification 的热路径与 cold-start 变化有可重复的对照矩阵 +- 在不引入“未来已存在”假设的前提下,明确当前 benchmark 已覆盖什么、还没有覆盖什么 -## 当前内容 +## 当前 coverage -- `Program.cs` - - benchmark 命令行入口 -- `Messaging/Fixture.cs` - - 运行前输出并校验场景配置 -- `Messaging/RequestBenchmarks.cs` - - direct handler、NuGet `Mediator` source-generated concrete path、已接上 handwritten generated request invoker provider 的默认 `GFramework.Cqrs` runtime 与 `MediatR` 的 request steady-state dispatch 对比 -- `Messaging/RequestLifetimeBenchmarks.cs` - - `Singleton / Scoped / Transient` 三类 handler 生命周期下,direct handler、已对齐 generated-provider 宿主接线的默认 `GFramework.Cqrs` runtime 与 `MediatR` 的 request steady-state dispatch 对比;其中 `Scoped` 通过真实 request 级作用域宿主执行,不再把 scoped handler 退化为根容器解析 -- `Messaging/StreamLifetimeBenchmarks.cs` - - `Singleton / Scoped / Transient` 三类 handler 生命周期下,direct handler、`GFramework.Cqrs` reflection stream binding、接上 generated stream registry 的 `GFramework.Cqrs` runtime 与 `MediatR` 的 stream 分层对照,并同时提供 `FirstItem / DrainAll` 两种观测口径 -- `Messaging/RequestPipelineBenchmarks.cs` - - `0 / 1 / 4` 个 pipeline 行为下,direct handler、已接上 handwritten generated request invoker provider 的 `GFramework.Cqrs` runtime 与 `MediatR` 的 request steady-state dispatch 对比 -- `Messaging/RequestStartupBenchmarks.cs` - - `Initialization` 与 `ColdStart` 两组 request startup 成本对比,补齐与 `Mediator` comparison benchmark 更接近的 startup 维度 -- `Messaging/RequestInvokerBenchmarks.cs` - - direct handler、`GFramework.Cqrs` reflection runtime、handwritten generated-invoker runtime 与 `MediatR` 的 request steady-state dispatch 对比 -- `Messaging/StreamInvokerBenchmarks.cs` - - direct handler、`GFramework.Cqrs` reflection runtime、handwritten generated-invoker runtime 与 `MediatR` 的 stream 对比,并同时提供 `FirstItem / DrainAll` 两种观测口径 -- `Messaging/NotificationBenchmarks.cs` - - `GFramework.Cqrs` runtime、NuGet `Mediator` source-generated concrete path 与 `MediatR` 的单处理器 notification publish 对比 -- `Messaging/NotificationFanOutBenchmarks.cs` - - fixed `4 handler` notification fan-out 的 baseline、`GFramework.Cqrs` 默认顺序发布器、内置 `TaskWhenAllNotificationPublisher`、NuGet `Mediator` source-generated concrete path 与 `MediatR` publish 对比 -- `Messaging/StreamingBenchmarks.cs` - - direct handler、已接上 handwritten generated stream invoker provider 的 `GFramework.Cqrs` runtime 与 `MediatR` 的 stream request 对比,并同时提供 `FirstItem / DrainAll` 两种观测口径 +当前工程已经覆盖以下矩阵: + +- request steady-state + - `Messaging/RequestBenchmarks.cs` + - direct handler、默认 `GFramework.Cqrs` runtime、NuGet `Mediator` source-generated concrete path、`MediatR` + - `Messaging/RequestLifetimeBenchmarks.cs` + - `Singleton / Scoped / Transient` 三类 handler 生命周期下,baseline、默认 generated-provider 宿主接线的 `GFramework.Cqrs` runtime 与 `MediatR` + - `Messaging/RequestPipelineBenchmarks.cs` + - `0 / 1 / 4` 个 pipeline 行为下,baseline、默认 generated-provider 宿主接线的 `GFramework.Cqrs` runtime 与 `MediatR` + - `Messaging/RequestInvokerBenchmarks.cs` + - baseline、`GFramework.Cqrs` reflection request binding、`GFramework.Cqrs` generated request invoker、`MediatR` +- request startup + - `Messaging/RequestStartupBenchmarks.cs` + - `Initialization` 与 `ColdStart` 两组下,`GFramework.Cqrs`、NuGet `Mediator`、`MediatR` +- stream steady-state + - `Messaging/StreamingBenchmarks.cs` + - baseline、默认 generated-provider 宿主接线的 `GFramework.Cqrs` runtime 与 `MediatR` + - 同时提供 `FirstItem` 与 `DrainAll` 两种观测口径 + - `Messaging/StreamLifetimeBenchmarks.cs` + - `Singleton / Scoped / Transient` 三类 handler 生命周期下,baseline、`GFramework.Cqrs` reflection stream binding、`GFramework.Cqrs` generated stream registry、`MediatR` + - 同时提供 `FirstItem` 与 `DrainAll` 两种观测口径 + - `Messaging/StreamInvokerBenchmarks.cs` + - baseline、`GFramework.Cqrs` reflection stream binding、`GFramework.Cqrs` generated stream invoker、`MediatR` + - 同时提供 `FirstItem` 与 `DrainAll` 两种观测口径 +- stream startup + - `Messaging/StreamStartupBenchmarks.cs` + - `Initialization` 与 `ColdStart` 两组下,`GFramework.Cqrs` reflection、`GFramework.Cqrs` generated、`MediatR` + - 其中 `ColdStart` 的边界是“新宿主 + 首个元素命中”,不是完整枚举整个 stream +- 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` ## 最小使用方式 @@ -56,33 +70,33 @@ dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.cspro dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*StreamLifetimeBenchmarks.Stream_*" ``` -如果需要在两个终端里并发复核不同的过滤 benchmark,请为每个进程追加不同的 `--artifacts-suffix `,把 `BenchmarkDotNet` auto-generated build 与 artifacts 输出隔离到不同目录;这只是运行入口的目录隔离约定,不是 benchmark 业务逻辑本身的要求。例如: +## 并发运行约束 + +当两个 benchmark 进程需要并发运行时,必须为每个进程追加不同的 `--artifacts-suffix `。当前入口会把这个 suffix 解析成独立的 `BenchmarkDotNet.Artifacts//` 目录,并在该目录下复制隔离的 benchmark host,避免多个进程写入同一份 auto-generated build 与 artifacts 输出。 + +例如: ```bash dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --artifacts-suffix req-lifetime-a --filter "*RequestLifetimeBenchmarks.SendRequest_*" dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --artifacts-suffix stream-lifetime-b --filter "*StreamLifetimeBenchmarks.Stream_*" ``` -## 当前约束 +如果不并发运行,就不需要额外传入 `--artifacts-suffix`。`BenchmarkDotNet.Artifacts/` 仍然是本地生成输出,默认不作为常规提交内容。 -- `BenchmarkDotNet.Artifacts/` 属于本地生成输出,默认加入仓库忽略,不作为常规提交内容 -- 当两个带 `--filter` 的 benchmark 进程需要并发运行时,必须为它们分别传入不同的 `--artifacts-suffix `,避免多个 `BenchmarkDotNet` 进程写入同一份 auto-generated build / artifacts 目录;这个约束只服务于本地输出隔离,不代表 benchmark 场景之间存在额外业务依赖 -- `RequestLifetimeBenchmarks` 现在复用与默认 generated-provider 路径一致的 benchmark 宿主接线;它比较的是生命周期切换后的 handler 解析与 dispatch 成本,不单独引入另一套 runtime 发现口径 -- `RequestLifetimeBenchmarks` 的 `Scoped` 场景会复用单个 scoped runtime,但在每次 request 分发时仍显式创建并释放真实 DI 作用域,用来观察 scoped handler 绑定到 request 边界后的解析与 dispatch 成本,而不是把 runtime 构造常量成本混进生命周期对照 -- `StreamLifetimeBenchmarks` 现在按 direct handler、`GFramework.Cqrs` reflection、`GFramework.Cqrs` generated、`MediatR` 四层口径组织,并额外区分 `FirstItem` 与 `DrainAll` 两种观测方式,用于把 stream 建流/首个元素成本与完整枚举成本拆开观察 -- `StreamingBenchmarks` 与 `StreamInvokerBenchmarks` 都同时暴露 `FirstItem` 与 `DrainAll`;阅读结果时应把它们分别理解为“建流到首个元素”的固定成本观测与“完整枚举整个 stream”的总成本观测 -- `StreamInvokerBenchmarks` 当前的 `DrainAll` short-job 输出只适合做 smoke 复核,确认矩阵和路径可以正常跑通;它不应直接写成 reflection、generated 或 `MediatR` 之间的稳定性能结论,若要做排序判断,应复跑默认作业或更完整的 benchmark 批次 -- 只要变更影响 `GFramework.Cqrs` request dispatch、DI 解析热路径、invoker/provider、pipeline 或 benchmark 宿主,就应至少复跑能覆盖该路径的过滤场景;request 热路径通常先看: - - `RequestBenchmarks.SendRequest_*` - - `RequestLifetimeBenchmarks.SendRequest_*` -- 只要变更影响 stream dispatch、建流绑定或相关宿主接线,就应补跑: - - `StreamingBenchmarks.Stream_*` - - `StreamLifetimeBenchmarks.Stream_*` -- 当前性能目标不是超过 source-generated `Mediator`,而是让默认 request steady-state 路径尽量接近它,并至少稳定快于基于反射 / 扫描的 `MediatR` +## 结果解读边界 -## 后续扩展方向 +- `RequestLifetimeBenchmarks` 的 `Scoped` 场景会在每次 request 分发时显式创建并释放真实 DI 作用域;它观察的是 scoped handler 的解析与 dispatch 成本,不把 runtime 构造常量成本混入生命周期对照 +- `NotificationLifetimeBenchmarks` 的 `Scoped` 场景也采用真实 DI 作用域;它比较的是 publish 路径上的生命周期额外开销,不是根容器解析退化后的近似值 +- `StreamingBenchmarks`、`StreamLifetimeBenchmarks`、`StreamInvokerBenchmarks` 同时暴露 `FirstItem` 与 `DrainAll` + - `FirstItem` 适合观察“建流到首个元素”的固定成本 + - `DrainAll` 适合观察完整枚举整个 stream 的总成本 +- `StreamStartupBenchmarks` 的 `ColdStart` 只推进到首个元素,因此它回答的是“新宿主下首次建流命中”的边界,不回答完整枚举总成本 +- 当前 HEAD 没有单独固化的 short-job benchmark 类或 checked-in short-job 结果;如果手动使用 short job / short run 只做 smoke 复核,应把它理解为“确认矩阵与路径能跑通” +- 特别是 `StreamInvokerBenchmarks` 的 `DrainAll` 在 short-job smoke 下不应直接写成 reflection、generated 或 `MediatR` 之间的稳定排序结论;若要比较名次或小幅差值,应复跑默认作业或更完整的批次 -- 若继续优化 stream lifetime,可优先复核 `Transient + FirstItem` 下 generated 与 reflection 的小幅差值是否稳定,再决定继续压 generated 宿主的建流瞬时成本,还是把后续对照切回 `StreamInvokerBenchmarks` / `Mediator` concrete runtime 批次 -- request / stream 的真实 source-generator 产物与 handwritten generated provider 对照 -- `Mediator` 的 transient / scoped compile-time lifetime 矩阵对照 -- generated invoker provider 与纯反射 dispatch / 建流对比继续扩展到更多场景 +## 当前缺口 + +- 当前没有 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 fan-out 的生命周期矩阵;`NotificationFanOutBenchmarks` 只覆盖固定 `4 handler` 的已装配宿主 +- 当前没有 stream pipeline benchmark;现有 pipeline coverage 仅限 request diff --git a/GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherCacheTests.cs b/GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherCacheTests.cs index 0a71dac9..952d31b9 100644 --- a/GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherCacheTests.cs +++ b/GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherCacheTests.cs @@ -309,6 +309,60 @@ internal sealed class CqrsDispatcherCacheTests }); } + /// + /// 验证同一 request dispatch binding 先走零行为直连路径时不会提前创建 pipeline executor, + /// 后续另一 dispatcher 命中相同 binding 且存在行为时,仍会按实际行为数量补建缓存 executor。 + /// + [Test] + public async Task Dispatcher_Should_Create_Request_Pipeline_Executor_Only_When_Shared_Binding_Sees_Behaviors() + { + var requestBindings = GetCacheField("RequestDispatchBindings"); + var behaviorType = typeof(IPipelineBehavior); + using var zeroBehaviorContainer = CreateFrozenContainer( + new DispatcherCacheFixtureOptions + { + IncludeRequestPipelineCacheBehavior = false + }); + var zeroBehaviorContext = new ArchitectureContext(zeroBehaviorContainer); + var behaviorContext = new ArchitectureContext(_container!); + var zeroBehaviorDispatcher = GetDispatcherFromContext(zeroBehaviorContext); + var behaviorDispatcher = GetDispatcherFromContext(behaviorContext); + + await zeroBehaviorContext.SendRequestAsync(new DispatcherPipelineCacheRequest()); + + var bindingAfterZeroBehaviorDispatch = GetPairCacheValue( + requestBindings, + typeof(DispatcherPipelineCacheRequest), + typeof(int)); + var executorAfterZeroBehaviorDispatch = GetRequestPipelineExecutorValue( + requestBindings, + typeof(DispatcherPipelineCacheRequest), + typeof(int), + 1); + + await behaviorContext.SendRequestAsync(new DispatcherPipelineCacheRequest()); + + var bindingAfterBehaviorDispatch = GetPairCacheValue( + requestBindings, + typeof(DispatcherPipelineCacheRequest), + typeof(int)); + var executorAfterBehaviorDispatch = GetRequestPipelineExecutorValue( + requestBindings, + typeof(DispatcherPipelineCacheRequest), + typeof(int), + 1); + + Assert.Multiple(() => + { + Assert.That(bindingAfterZeroBehaviorDispatch, Is.Not.Null); + Assert.That(bindingAfterBehaviorDispatch, Is.SameAs(bindingAfterZeroBehaviorDispatch)); + Assert.That(executorAfterZeroBehaviorDispatch, Is.Null); + Assert.That(executorAfterBehaviorDispatch, Is.Not.Null); + AssertRequestBehaviorPresenceEquals(zeroBehaviorDispatcher, behaviorType, false); + AssertRequestBehaviorPresenceEquals(behaviorDispatcher, behaviorType, true); + }); + } + /// /// 验证 stream pipeline executor 会按行为数量在 binding 内首次创建并在后续建流中复用。 /// @@ -374,6 +428,60 @@ internal sealed class CqrsDispatcherCacheTests }); } + /// + /// 验证同一 stream dispatch binding 先走零行为直连路径时不会提前创建 pipeline executor, + /// 后续另一 dispatcher 命中相同 binding 且存在行为时,仍会按实际行为数量补建缓存 executor。 + /// + [Test] + public async Task Dispatcher_Should_Create_Stream_Pipeline_Executor_Only_When_Shared_Binding_Sees_Behaviors() + { + var streamBindings = GetCacheField("StreamDispatchBindings"); + var behaviorType = typeof(IStreamPipelineBehavior); + using var zeroBehaviorContainer = CreateFrozenContainer( + new DispatcherCacheFixtureOptions + { + IncludeStreamPipelineCacheBehavior = false + }); + var zeroBehaviorContext = new ArchitectureContext(zeroBehaviorContainer); + var behaviorContext = new ArchitectureContext(_container!); + var zeroBehaviorDispatcher = GetDispatcherFromContext(zeroBehaviorContext); + var behaviorDispatcher = GetDispatcherFromContext(behaviorContext); + + await DrainAsync(zeroBehaviorContext.CreateStream(new DispatcherCacheStreamRequest())); + + var bindingAfterZeroBehaviorDispatch = GetPairCacheValue( + streamBindings, + typeof(DispatcherCacheStreamRequest), + typeof(int)); + var executorAfterZeroBehaviorDispatch = GetStreamPipelineExecutorValue( + streamBindings, + typeof(DispatcherCacheStreamRequest), + typeof(int), + 1); + + await DrainAsync(behaviorContext.CreateStream(new DispatcherCacheStreamRequest())); + + var bindingAfterBehaviorDispatch = GetPairCacheValue( + streamBindings, + typeof(DispatcherCacheStreamRequest), + typeof(int)); + var executorAfterBehaviorDispatch = GetStreamPipelineExecutorValue( + streamBindings, + typeof(DispatcherCacheStreamRequest), + typeof(int), + 1); + + Assert.Multiple(() => + { + Assert.That(bindingAfterZeroBehaviorDispatch, Is.Not.Null); + Assert.That(bindingAfterBehaviorDispatch, Is.SameAs(bindingAfterZeroBehaviorDispatch)); + Assert.That(executorAfterZeroBehaviorDispatch, Is.Null); + Assert.That(executorAfterBehaviorDispatch, Is.Not.Null); + AssertStreamBehaviorPresenceEquals(zeroBehaviorDispatcher, behaviorType, false); + AssertStreamBehaviorPresenceEquals(behaviorDispatcher, behaviorType, true); + }); + } + /// /// 验证复用缓存的 request pipeline executor 后,行为顺序和最终处理器顺序保持不变。 /// @@ -492,6 +600,71 @@ internal sealed class CqrsDispatcherCacheTests }); } + /// + /// 验证同一 request dispatch binding 先在零行为 dispatcher 上命中后, + /// 后续切换到存在行为的 dispatcher 仍会重新解析 behavior/handler,并为当前上下文重新注入架构实例。 + /// + [Test] + public async Task Dispatcher_Should_Reinject_Current_Request_Context_When_Shared_Binding_Switches_From_Zero_Pipeline() + { + DispatcherPipelineContextRefreshState.Reset(); + + var requestBindings = GetCacheField("RequestDispatchBindings"); + using var zeroBehaviorContainer = CreateFrozenContainer( + new DispatcherCacheFixtureOptions + { + IncludeRequestPipelineContextRefreshBehavior = false + }); + var zeroBehaviorContext = new ArchitectureContext(zeroBehaviorContainer); + var behaviorContext = new ArchitectureContext(_container!); + + await zeroBehaviorContext.SendRequestAsync(new DispatcherPipelineContextRefreshRequest("without-behavior")); + + var bindingAfterZeroBehaviorDispatch = GetPairCacheValue( + requestBindings, + typeof(DispatcherPipelineContextRefreshRequest), + typeof(int)); + var executorAfterZeroBehaviorDispatch = GetRequestPipelineExecutorValue( + requestBindings, + typeof(DispatcherPipelineContextRefreshRequest), + typeof(int), + 1); + + await behaviorContext.SendRequestAsync(new DispatcherPipelineContextRefreshRequest("with-behavior")); + + var bindingAfterBehaviorDispatch = GetPairCacheValue( + requestBindings, + typeof(DispatcherPipelineContextRefreshRequest), + typeof(int)); + var executorAfterBehaviorDispatch = GetRequestPipelineExecutorValue( + requestBindings, + typeof(DispatcherPipelineContextRefreshRequest), + typeof(int), + 1); + var behaviorSnapshots = DispatcherPipelineContextRefreshState.BehaviorSnapshots.ToArray(); + var handlerSnapshots = DispatcherPipelineContextRefreshState.HandlerSnapshots.ToArray(); + + Assert.Multiple(() => + { + Assert.That(bindingAfterZeroBehaviorDispatch, Is.Not.Null); + Assert.That(bindingAfterBehaviorDispatch, Is.SameAs(bindingAfterZeroBehaviorDispatch)); + Assert.That(executorAfterZeroBehaviorDispatch, Is.Null); + Assert.That(executorAfterBehaviorDispatch, Is.Not.Null); + + Assert.That(behaviorSnapshots, Has.Length.EqualTo(1)); + Assert.That(behaviorSnapshots[0].DispatchId, Is.EqualTo("with-behavior")); + Assert.That(behaviorSnapshots[0].Context, Is.SameAs(behaviorContext)); + + Assert.That(handlerSnapshots, Has.Length.EqualTo(2)); + Assert.That(handlerSnapshots[0].DispatchId, Is.EqualTo("without-behavior")); + Assert.That(handlerSnapshots[0].Context, Is.SameAs(zeroBehaviorContext)); + Assert.That(handlerSnapshots[1].DispatchId, Is.EqualTo("with-behavior")); + Assert.That(handlerSnapshots[1].Context, Is.SameAs(behaviorContext)); + Assert.That(handlerSnapshots[1].Context, Is.Not.SameAs(handlerSnapshots[0].Context)); + Assert.That(handlerSnapshots[1].InstanceId, Is.Not.EqualTo(handlerSnapshots[0].InstanceId)); + }); + } + /// /// 验证缓存的 notification dispatch binding 在重复分发时仍会重新解析 handler, /// 并为当次实例重新注入当前架构上下文。 @@ -632,6 +805,108 @@ internal sealed class CqrsDispatcherCacheTests }); } + /// + /// 验证同一 stream dispatch binding 先在零行为 dispatcher 上命中后, + /// 后续切换到存在行为的 dispatcher 仍会重新解析 behavior/handler,并为当前上下文重新注入架构实例。 + /// + [Test] + public async Task Dispatcher_Should_Reinject_Current_Stream_Context_When_Shared_Binding_Switches_From_Zero_Pipeline() + { + DispatcherStreamContextRefreshState.Reset(); + + var streamBindings = GetCacheField("StreamDispatchBindings"); + using var zeroBehaviorContainer = CreateFrozenContainer( + new DispatcherCacheFixtureOptions + { + IncludeStreamPipelineContextRefreshBehavior = false + }); + var zeroBehaviorContext = new ArchitectureContext(zeroBehaviorContainer); + var behaviorContext = new ArchitectureContext(_container!); + + await DrainAsync(zeroBehaviorContext.CreateStream(new DispatcherStreamContextRefreshRequest("without-behavior"))); + + var bindingAfterZeroBehaviorDispatch = GetPairCacheValue( + streamBindings, + typeof(DispatcherStreamContextRefreshRequest), + typeof(int)); + var executorAfterZeroBehaviorDispatch = GetStreamPipelineExecutorValue( + streamBindings, + typeof(DispatcherStreamContextRefreshRequest), + typeof(int), + 1); + + await DrainAsync(behaviorContext.CreateStream(new DispatcherStreamContextRefreshRequest("with-behavior"))); + + var bindingAfterBehaviorDispatch = GetPairCacheValue( + streamBindings, + typeof(DispatcherStreamContextRefreshRequest), + typeof(int)); + var executorAfterBehaviorDispatch = GetStreamPipelineExecutorValue( + streamBindings, + typeof(DispatcherStreamContextRefreshRequest), + typeof(int), + 1); + var behaviorSnapshots = DispatcherStreamContextRefreshState.BehaviorSnapshots.ToArray(); + var handlerSnapshots = DispatcherStreamContextRefreshState.HandlerSnapshots.ToArray(); + + Assert.Multiple(() => + { + Assert.That(bindingAfterZeroBehaviorDispatch, Is.Not.Null); + Assert.That(bindingAfterBehaviorDispatch, Is.SameAs(bindingAfterZeroBehaviorDispatch)); + Assert.That(executorAfterZeroBehaviorDispatch, Is.Null); + Assert.That(executorAfterBehaviorDispatch, Is.Not.Null); + + Assert.That(behaviorSnapshots, Has.Length.EqualTo(1)); + Assert.That(behaviorSnapshots[0].DispatchId, Is.EqualTo("with-behavior")); + Assert.That(behaviorSnapshots[0].Context, Is.SameAs(behaviorContext)); + + Assert.That(handlerSnapshots, Has.Length.EqualTo(2)); + Assert.That(handlerSnapshots[0].DispatchId, Is.EqualTo("without-behavior")); + Assert.That(handlerSnapshots[0].Context, Is.SameAs(zeroBehaviorContext)); + Assert.That(handlerSnapshots[1].DispatchId, Is.EqualTo("with-behavior")); + Assert.That(handlerSnapshots[1].Context, Is.SameAs(behaviorContext)); + Assert.That(handlerSnapshots[1].Context, Is.Not.SameAs(handlerSnapshots[0].Context)); + Assert.That(handlerSnapshots[1].InstanceId, Is.Not.EqualTo(handlerSnapshots[0].InstanceId)); + }); + } + + /// + /// 描述缓存测试 fixture 需要启用的可选 pipeline 行为集合, + /// 用于构造“同一静态 binding 对应不同 dispatcher 注册可见性”的组合场景。 + /// + private sealed class DispatcherCacheFixtureOptions + { + /// + /// 获取是否注册 。 + /// + public bool IncludeRequestPipelineCacheBehavior { get; init; } = true; + + /// + /// 获取是否注册 。 + /// + public bool IncludeRequestPipelineContextRefreshBehavior { get; init; } = true; + + /// + /// 获取是否注册 request 顺序验证所需的两层 pipeline 行为。 + /// + public bool IncludeRequestPipelineOrderBehaviors { get; init; } = true; + + /// + /// 获取是否注册 。 + /// + public bool IncludeStreamPipelineCacheBehavior { get; init; } = true; + + /// + /// 获取是否注册 。 + /// + public bool IncludeStreamPipelineContextRefreshBehavior { get; init; } = true; + + /// + /// 获取是否注册 stream 顺序验证所需的两层 pipeline 行为。 + /// + public bool IncludeStreamPipelineOrderBehaviors { get; init; } = true; + } + /// /// 通过反射读取 dispatcher 的静态缓存对象。 /// @@ -679,10 +954,11 @@ internal sealed class CqrsDispatcherCacheTests /// 创建与当前 fixture 注册形状一致、但拥有独立 runtime 实例的冻结容器, /// 用于验证 dispatcher 的实例级缓存不会跨容器共享。 /// - private static MicrosoftDiContainer CreateFrozenContainer() + /// 控制当前隔离容器要启用哪些可选 pipeline 行为的配置。 + private static MicrosoftDiContainer CreateFrozenContainer(DispatcherCacheFixtureOptions? options = null) { var container = new MicrosoftDiContainer(); - ConfigureDispatcherCacheFixture(container); + ConfigureDispatcherCacheFixture(container, options); container.Freeze(); return container; @@ -692,16 +968,44 @@ internal sealed class CqrsDispatcherCacheTests /// 组装当前 fixture 依赖的 CQRS 容器注册形状,确保默认上下文与隔离容器复用同一份装配基线。 /// /// 待补齐 CQRS 注册的目标容器。 - private static void ConfigureDispatcherCacheFixture(MicrosoftDiContainer container) + /// 控制是否跳过特定 pipeline 行为注册的可选配置。 + private static void ConfigureDispatcherCacheFixture( + MicrosoftDiContainer container, + DispatcherCacheFixtureOptions? options = null) { - container.RegisterCqrsPipelineBehavior(); - container.RegisterCqrsPipelineBehavior(); - container.RegisterCqrsPipelineBehavior(); - container.RegisterCqrsPipelineBehavior(); - container.RegisterCqrsStreamPipelineBehavior(); - container.RegisterCqrsStreamPipelineBehavior(); - container.RegisterCqrsStreamPipelineBehavior(); - container.RegisterCqrsStreamPipelineBehavior(); + options ??= new DispatcherCacheFixtureOptions(); + + if (options.IncludeRequestPipelineCacheBehavior) + { + container.RegisterCqrsPipelineBehavior(); + } + + if (options.IncludeRequestPipelineContextRefreshBehavior) + { + container.RegisterCqrsPipelineBehavior(); + } + + if (options.IncludeRequestPipelineOrderBehaviors) + { + container.RegisterCqrsPipelineBehavior(); + container.RegisterCqrsPipelineBehavior(); + } + + if (options.IncludeStreamPipelineCacheBehavior) + { + container.RegisterCqrsStreamPipelineBehavior(); + } + + if (options.IncludeStreamPipelineContextRefreshBehavior) + { + container.RegisterCqrsStreamPipelineBehavior(); + } + + if (options.IncludeStreamPipelineOrderBehaviors) + { + container.RegisterCqrsStreamPipelineBehavior(); + container.RegisterCqrsStreamPipelineBehavior(); + } CqrsTestRuntime.RegisterHandlers( container, diff --git a/GFramework.Cqrs.Tests/Cqrs/CqrsGeneratedRequestInvokerProviderTests.cs b/GFramework.Cqrs.Tests/Cqrs/CqrsGeneratedRequestInvokerProviderTests.cs index 5c79175a..227f101d 100644 --- a/GFramework.Cqrs.Tests/Cqrs/CqrsGeneratedRequestInvokerProviderTests.cs +++ b/GFramework.Cqrs.Tests/Cqrs/CqrsGeneratedRequestInvokerProviderTests.cs @@ -448,6 +448,166 @@ internal sealed class CqrsGeneratedRequestInvokerProviderTests Assert.That(results, Is.EqualTo([3, 4])); } + /// + /// 验证当 generated request invoker provider 的 descriptor 枚举抛出异常时, + /// registrar 会跳过 generated descriptor 预热并回退到反射路径。 + /// + [Test] + public async Task SendAsync_Should_Fall_Back_To_Runtime_Path_When_Request_Descriptor_Enumeration_Throws() + { + var generatedAssembly = CreateGeneratedAssembly( + typeof(ThrowingEnumeratingRequestInvokerProviderRegistry), + "GFramework.Cqrs.Tests.Cqrs.ThrowingEnumeratingRequestInvokerAssembly, Version=1.0.0.0"); + var container = new MicrosoftDiContainer(); + + CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object); + container.Freeze(); + + var context = new ArchitectureContext(container); + var response = await context.SendRequestAsync(new GeneratedRequestInvokerRequest("payload")).ConfigureAwait(false); + Assert.That(response, Is.EqualTo("runtime:payload")); + } + + /// + /// 验证当 generated stream invoker provider 的 descriptor 枚举抛出异常时, + /// registrar 会跳过 generated descriptor 预热并回退到反射建流路径。 + /// + [Test] + public async Task CreateStream_Should_Fall_Back_To_Runtime_Path_When_Stream_Descriptor_Enumeration_Throws() + { + var generatedAssembly = CreateGeneratedAssembly( + typeof(ThrowingEnumeratingStreamInvokerProviderRegistry), + "GFramework.Cqrs.Tests.Cqrs.ThrowingEnumeratingStreamInvokerAssembly, Version=1.0.0.0"); + var container = new MicrosoftDiContainer(); + + CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object); + container.Freeze(); + + var context = new ArchitectureContext(container); + var results = await DrainAsync(context.CreateStream(new GeneratedStreamInvokerRequest(3))).ConfigureAwait(false); + Assert.That(results, Is.EqualTo([3, 4])); + } + + /// + /// 验证当 request descriptor 枚举返回重复 request-response pair 时, + /// registrar 会稳定保留首个有效描述符,并忽略后续重复项。 + /// + [Test] + public async Task SendAsync_Should_Use_First_Generated_Request_Descriptor_When_Duplicates_Are_Enumerated() + { + var generatedAssembly = CreateGeneratedAssembly( + typeof(DuplicateEnumeratingRequestInvokerProviderRegistry), + "GFramework.Cqrs.Tests.Cqrs.DuplicateEnumeratingRequestInvokerAssembly, Version=1.0.0.0"); + var container = new MicrosoftDiContainer(); + + CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object); + container.Freeze(); + + var context = new ArchitectureContext(container); + var response = await context.SendRequestAsync(new GeneratedRequestInvokerRequest("payload")).ConfigureAwait(false); + Assert.That(response, Is.EqualTo("generated:payload")); + } + + /// + /// 验证当 stream descriptor 枚举返回重复 request-response pair 时, + /// registrar 会稳定保留首个有效描述符,并忽略后续重复项。 + /// + [Test] + public async Task CreateStream_Should_Use_First_Generated_Stream_Descriptor_When_Duplicates_Are_Enumerated() + { + var generatedAssembly = CreateGeneratedAssembly( + typeof(DuplicateEnumeratingStreamInvokerProviderRegistry), + "GFramework.Cqrs.Tests.Cqrs.DuplicateEnumeratingStreamInvokerAssembly, Version=1.0.0.0"); + var container = new MicrosoftDiContainer(); + + CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object); + container.Freeze(); + + var context = new ArchitectureContext(container); + var results = await DrainAsync(context.CreateStream(new GeneratedStreamInvokerRequest(3))).ConfigureAwait(false); + Assert.That(results, Is.EqualTo([30, 31])); + } + + /// + /// 验证当 request descriptor 枚举项与 provider 的 TryGetDescriptor 结果不一致时, + /// registrar 会忽略该坏 descriptor,并继续回退到反射路径。 + /// + [Test] + public async Task SendAsync_Should_Fall_Back_To_Runtime_Path_When_Enumerated_Request_Descriptor_Does_Not_Match_Provider() + { + var generatedAssembly = CreateGeneratedAssembly( + typeof(MismatchedEnumeratingRequestInvokerProviderRegistry), + "GFramework.Cqrs.Tests.Cqrs.MismatchedEnumeratingRequestInvokerAssembly, Version=1.0.0.0"); + var container = new MicrosoftDiContainer(); + + CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object); + container.Freeze(); + + var context = new ArchitectureContext(container); + var response = await context.SendRequestAsync(new GeneratedRequestInvokerRequest("payload")).ConfigureAwait(false); + Assert.That(response, Is.EqualTo("runtime:payload")); + } + + /// + /// 验证当首个 request descriptor 无效、后续同键 descriptor 有效时, + /// registrar 不会因为过早去重而丢掉本可注册的 generated descriptor。 + /// + [Test] + public async Task SendAsync_Should_Use_Later_Valid_Generated_Request_Descriptor_When_First_Duplicate_Is_Invalid() + { + var generatedAssembly = CreateGeneratedAssembly( + typeof(InvalidThenValidDuplicateRequestInvokerProviderRegistry), + "GFramework.Cqrs.Tests.Cqrs.InvalidThenValidDuplicateRequestInvokerAssembly, Version=1.0.0.0"); + var container = new MicrosoftDiContainer(); + + CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object); + container.Freeze(); + + var context = new ArchitectureContext(container); + var response = await context.SendRequestAsync(new GeneratedRequestInvokerRequest("payload")).ConfigureAwait(false); + Assert.That(response, Is.EqualTo("generated:payload")); + } + + /// + /// 验证当 stream descriptor 枚举项与 provider 的 TryGetDescriptor 结果不一致时, + /// registrar 会忽略该坏 descriptor,并继续回退到反射建流路径。 + /// + [Test] + public async Task CreateStream_Should_Fall_Back_To_Runtime_Path_When_Enumerated_Stream_Descriptor_Does_Not_Match_Provider() + { + var generatedAssembly = CreateGeneratedAssembly( + typeof(MismatchedEnumeratingStreamInvokerProviderRegistry), + "GFramework.Cqrs.Tests.Cqrs.MismatchedEnumeratingStreamInvokerAssembly, Version=1.0.0.0"); + var container = new MicrosoftDiContainer(); + + CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object); + container.Freeze(); + + var context = new ArchitectureContext(container); + var results = await DrainAsync(context.CreateStream(new GeneratedStreamInvokerRequest(3))).ConfigureAwait(false); + Assert.That(results, Is.EqualTo([3, 4])); + } + + /// + /// 验证当首个 stream descriptor 无效、后续同键 descriptor 有效时, + /// registrar 不会因为过早去重而丢掉本可注册的 generated descriptor。 + /// + [Test] + public async Task CreateStream_Should_Use_Later_Valid_Generated_Stream_Descriptor_When_First_Duplicate_Is_Invalid() + { + var generatedAssembly = CreateGeneratedAssembly( + typeof(InvalidThenValidDuplicateStreamInvokerProviderRegistry), + "GFramework.Cqrs.Tests.Cqrs.InvalidThenValidDuplicateStreamInvokerAssembly, Version=1.0.0.0"); + var container = new MicrosoftDiContainer(); + + CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object); + container.Freeze(); + + var context = new ArchitectureContext(container); + var results = await DrainAsync(context.CreateStream(new GeneratedStreamInvokerRequest(3))).ConfigureAwait(false); + Assert.That(results, Is.EqualTo([30, 31])); + } + /// /// 模拟返回实例 request invoker 方法的 generated registry。 /// @@ -860,6 +1020,529 @@ internal sealed class CqrsGeneratedRequestInvokerProviderTests } } + /// + /// 模拟 descriptor 枚举阶段抛出异常的 request invoker provider。 + /// + private sealed class ThrowingEnumeratingRequestInvokerProviderRegistry : + ICqrsHandlerRegistry, + ICqrsRequestInvokerProvider, + IEnumeratesCqrsRequestInvokerDescriptors + { + private static readonly CqrsRequestInvokerDescriptor Descriptor = new( + typeof(IRequestHandler), + typeof(GeneratedRequestInvokerProviderRegistry).GetMethod( + "InvokeGenerated", + BindingFlags.NonPublic | BindingFlags.Static)!); + + /// + public void Register(IServiceCollection services, ILogger logger) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + services.AddTransient( + typeof(IRequestHandler), + typeof(GeneratedRequestInvokerRequestHandler)); + } + + /// + public bool TryGetDescriptor( + Type requestType, + Type responseType, + out CqrsRequestInvokerDescriptor? descriptor) + { + ArgumentNullException.ThrowIfNull(requestType); + ArgumentNullException.ThrowIfNull(responseType); + + if (requestType == typeof(GeneratedRequestInvokerRequest) && responseType == typeof(string)) + { + descriptor = Descriptor; + return true; + } + + descriptor = null; + return false; + } + + /// + public IReadOnlyList GetDescriptors() + { + throw new InvalidOperationException("request descriptors failed"); + } + } + + /// + /// 模拟 descriptor 枚举阶段抛出异常的 stream invoker provider。 + /// + private sealed class ThrowingEnumeratingStreamInvokerProviderRegistry : + ICqrsHandlerRegistry, + ICqrsStreamInvokerProvider, + IEnumeratesCqrsStreamInvokerDescriptors + { + private static readonly CqrsStreamInvokerDescriptor Descriptor = new( + typeof(IStreamRequestHandler), + typeof(GeneratedStreamInvokerProviderRegistry).GetMethod( + "InvokeGenerated", + BindingFlags.NonPublic | BindingFlags.Static)!); + + /// + public void Register(IServiceCollection services, ILogger logger) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + services.AddTransient( + typeof(IStreamRequestHandler), + typeof(GeneratedStreamInvokerRequestHandler)); + } + + /// + public bool TryGetDescriptor( + Type requestType, + Type responseType, + out CqrsStreamInvokerDescriptor? descriptor) + { + ArgumentNullException.ThrowIfNull(requestType); + ArgumentNullException.ThrowIfNull(responseType); + + if (requestType == typeof(GeneratedStreamInvokerRequest) && responseType == typeof(int)) + { + descriptor = Descriptor; + return true; + } + + descriptor = null; + return false; + } + + /// + public IReadOnlyList GetDescriptors() + { + throw new InvalidOperationException("stream descriptors failed"); + } + } + + /// + /// 模拟返回重复 request descriptor 条目的 generated registry。 + /// + private sealed class DuplicateEnumeratingRequestInvokerProviderRegistry : + ICqrsHandlerRegistry, + ICqrsRequestInvokerProvider, + IEnumeratesCqrsRequestInvokerDescriptors + { + private static readonly CqrsRequestInvokerDescriptor PrimaryDescriptor = new( + typeof(IRequestHandler), + typeof(GeneratedRequestInvokerProviderRegistry).GetMethod( + "InvokeGenerated", + BindingFlags.NonPublic | BindingFlags.Static)!); + + private static readonly CqrsRequestInvokerDescriptor SecondaryDescriptor = new( + typeof(IRequestHandler), + typeof(DuplicateEnumeratingRequestInvokerProviderRegistry).GetMethod( + nameof(InvokeAlternativeGenerated), + BindingFlags.NonPublic | BindingFlags.Static)!); + + /// + public void Register(IServiceCollection services, ILogger logger) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + services.AddTransient( + typeof(IRequestHandler), + typeof(GeneratedRequestInvokerRequestHandler)); + } + + /// + public bool TryGetDescriptor( + Type requestType, + Type responseType, + out CqrsRequestInvokerDescriptor? descriptor) + { + ArgumentNullException.ThrowIfNull(requestType); + ArgumentNullException.ThrowIfNull(responseType); + + if (requestType == typeof(GeneratedRequestInvokerRequest) && responseType == typeof(string)) + { + descriptor = PrimaryDescriptor; + return true; + } + + descriptor = null; + return false; + } + + /// + public IReadOnlyList GetDescriptors() + { + return + [ + new CqrsRequestInvokerDescriptorEntry(typeof(GeneratedRequestInvokerRequest), typeof(string), PrimaryDescriptor), + new CqrsRequestInvokerDescriptorEntry(typeof(GeneratedRequestInvokerRequest), typeof(string), SecondaryDescriptor) + ]; + } + + private static ValueTask InvokeAlternativeGenerated( + object handler, + object request, + CancellationToken cancellationToken) + { + return ValueTask.FromResult("duplicate:payload"); + } + } + + /// + /// 模拟返回重复 stream descriptor 条目的 generated registry。 + /// + private sealed class DuplicateEnumeratingStreamInvokerProviderRegistry : + ICqrsHandlerRegistry, + ICqrsStreamInvokerProvider, + IEnumeratesCqrsStreamInvokerDescriptors + { + private static readonly CqrsStreamInvokerDescriptor PrimaryDescriptor = new( + typeof(IStreamRequestHandler), + typeof(GeneratedStreamInvokerProviderRegistry).GetMethod( + "InvokeGenerated", + BindingFlags.NonPublic | BindingFlags.Static)!); + + private static readonly CqrsStreamInvokerDescriptor SecondaryDescriptor = new( + typeof(IStreamRequestHandler), + typeof(DuplicateEnumeratingStreamInvokerProviderRegistry).GetMethod( + nameof(InvokeAlternativeGenerated), + BindingFlags.NonPublic | BindingFlags.Static)!); + + /// + public void Register(IServiceCollection services, ILogger logger) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + services.AddTransient( + typeof(IStreamRequestHandler), + typeof(GeneratedStreamInvokerRequestHandler)); + } + + /// + public bool TryGetDescriptor( + Type requestType, + Type responseType, + out CqrsStreamInvokerDescriptor? descriptor) + { + ArgumentNullException.ThrowIfNull(requestType); + ArgumentNullException.ThrowIfNull(responseType); + + if (requestType == typeof(GeneratedStreamInvokerRequest) && responseType == typeof(int)) + { + descriptor = PrimaryDescriptor; + return true; + } + + descriptor = null; + return false; + } + + /// + public IReadOnlyList GetDescriptors() + { + return + [ + new CqrsStreamInvokerDescriptorEntry(typeof(GeneratedStreamInvokerRequest), typeof(int), PrimaryDescriptor), + new CqrsStreamInvokerDescriptorEntry(typeof(GeneratedStreamInvokerRequest), typeof(int), SecondaryDescriptor) + ]; + } + + private static object InvokeAlternativeGenerated(object handler, object request, CancellationToken cancellationToken) + { + return new[] { 900, 901 }.ToAsyncEnumerable(); + } + } + + /// + /// 模拟枚举出的 request descriptor 与 provider 显式查询结果不一致的 generated registry。 + /// + private sealed class MismatchedEnumeratingRequestInvokerProviderRegistry : + ICqrsHandlerRegistry, + ICqrsRequestInvokerProvider, + IEnumeratesCqrsRequestInvokerDescriptors + { + private static readonly CqrsRequestInvokerDescriptor ProviderDescriptor = new( + typeof(IRequestHandler), + typeof(GeneratedRequestInvokerProviderRegistry).GetMethod( + "InvokeGenerated", + BindingFlags.NonPublic | BindingFlags.Static)!); + + private static readonly CqrsRequestInvokerDescriptor EnumeratedDescriptor = new( + typeof(IRequestHandler), + typeof(MismatchedEnumeratingRequestInvokerProviderRegistry).GetMethod( + nameof(InvokeAlternativeGenerated), + BindingFlags.NonPublic | BindingFlags.Static)!); + + /// + public void Register(IServiceCollection services, ILogger logger) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + services.AddTransient( + typeof(IRequestHandler), + typeof(GeneratedRequestInvokerRequestHandler)); + } + + /// + public bool TryGetDescriptor( + Type requestType, + Type responseType, + out CqrsRequestInvokerDescriptor? descriptor) + { + ArgumentNullException.ThrowIfNull(requestType); + ArgumentNullException.ThrowIfNull(responseType); + + if (requestType == typeof(GeneratedRequestInvokerRequest) && responseType == typeof(string)) + { + descriptor = ProviderDescriptor; + return true; + } + + descriptor = null; + return false; + } + + /// + public IReadOnlyList GetDescriptors() + { + return + [ + new CqrsRequestInvokerDescriptorEntry( + typeof(GeneratedRequestInvokerRequest), + typeof(string), + EnumeratedDescriptor) + ]; + } + + private static ValueTask InvokeAlternativeGenerated( + object handler, + object request, + CancellationToken cancellationToken) + { + return ValueTask.FromResult("mismatched:payload"); + } + } + + /// + /// 模拟首个 request descriptor 无效、后续同键 descriptor 有效的 generated registry。 + /// + private sealed class InvalidThenValidDuplicateRequestInvokerProviderRegistry : + ICqrsHandlerRegistry, + ICqrsRequestInvokerProvider, + IEnumeratesCqrsRequestInvokerDescriptors + { + private static readonly CqrsRequestInvokerDescriptor InvalidDescriptor = new( + typeof(IRequestHandler), + typeof(InvalidThenValidDuplicateRequestInvokerProviderRegistry).GetMethod( + nameof(InvokeAlternativeGenerated), + BindingFlags.NonPublic | BindingFlags.Static)!); + + private static readonly CqrsRequestInvokerDescriptor ValidDescriptor = new( + typeof(IRequestHandler), + typeof(GeneratedRequestInvokerProviderRegistry).GetMethod( + "InvokeGenerated", + BindingFlags.NonPublic | BindingFlags.Static)!); + + /// + public void Register(IServiceCollection services, ILogger logger) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + services.AddTransient( + typeof(IRequestHandler), + typeof(GeneratedRequestInvokerRequestHandler)); + } + + /// + public bool TryGetDescriptor( + Type requestType, + Type responseType, + out CqrsRequestInvokerDescriptor? descriptor) + { + ArgumentNullException.ThrowIfNull(requestType); + ArgumentNullException.ThrowIfNull(responseType); + + if (requestType == typeof(GeneratedRequestInvokerRequest) && responseType == typeof(string)) + { + descriptor = ValidDescriptor; + return true; + } + + descriptor = null; + return false; + } + + /// + public IReadOnlyList GetDescriptors() + { + return + [ + new CqrsRequestInvokerDescriptorEntry( + typeof(GeneratedRequestInvokerRequest), + typeof(string), + InvalidDescriptor), + new CqrsRequestInvokerDescriptorEntry( + typeof(GeneratedRequestInvokerRequest), + typeof(string), + ValidDescriptor) + ]; + } + + private static ValueTask InvokeAlternativeGenerated( + object handler, + object request, + CancellationToken cancellationToken) + { + return ValueTask.FromResult("invalid-first:payload"); + } + } + + /// + /// 模拟枚举出的 stream descriptor 与 provider 显式查询结果不一致的 generated registry。 + /// + private sealed class MismatchedEnumeratingStreamInvokerProviderRegistry : + ICqrsHandlerRegistry, + ICqrsStreamInvokerProvider, + IEnumeratesCqrsStreamInvokerDescriptors + { + private static readonly CqrsStreamInvokerDescriptor ProviderDescriptor = new( + typeof(IStreamRequestHandler), + typeof(GeneratedStreamInvokerProviderRegistry).GetMethod( + "InvokeGenerated", + BindingFlags.NonPublic | BindingFlags.Static)!); + + private static readonly CqrsStreamInvokerDescriptor EnumeratedDescriptor = new( + typeof(IStreamRequestHandler), + typeof(MismatchedEnumeratingStreamInvokerProviderRegistry).GetMethod( + nameof(InvokeAlternativeGenerated), + BindingFlags.NonPublic | BindingFlags.Static)!); + + /// + public void Register(IServiceCollection services, ILogger logger) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + services.AddTransient( + typeof(IStreamRequestHandler), + typeof(GeneratedStreamInvokerRequestHandler)); + } + + /// + public bool TryGetDescriptor( + Type requestType, + Type responseType, + out CqrsStreamInvokerDescriptor? descriptor) + { + ArgumentNullException.ThrowIfNull(requestType); + ArgumentNullException.ThrowIfNull(responseType); + + if (requestType == typeof(GeneratedStreamInvokerRequest) && responseType == typeof(int)) + { + descriptor = ProviderDescriptor; + return true; + } + + descriptor = null; + return false; + } + + /// + public IReadOnlyList GetDescriptors() + { + return + [ + new CqrsStreamInvokerDescriptorEntry( + typeof(GeneratedStreamInvokerRequest), + typeof(int), + EnumeratedDescriptor) + ]; + } + + private static object InvokeAlternativeGenerated(object handler, object request, CancellationToken cancellationToken) + { + return new[] { 700, 701 }.ToAsyncEnumerable(); + } + } + + /// + /// 模拟首个 stream descriptor 无效、后续同键 descriptor 有效的 generated registry。 + /// + private sealed class InvalidThenValidDuplicateStreamInvokerProviderRegistry : + ICqrsHandlerRegistry, + ICqrsStreamInvokerProvider, + IEnumeratesCqrsStreamInvokerDescriptors + { + private static readonly CqrsStreamInvokerDescriptor InvalidDescriptor = new( + typeof(IStreamRequestHandler), + typeof(InvalidThenValidDuplicateStreamInvokerProviderRegistry).GetMethod( + nameof(InvokeAlternativeGenerated), + BindingFlags.NonPublic | BindingFlags.Static)!); + + private static readonly CqrsStreamInvokerDescriptor ValidDescriptor = new( + typeof(IStreamRequestHandler), + typeof(GeneratedStreamInvokerProviderRegistry).GetMethod( + "InvokeGenerated", + BindingFlags.NonPublic | BindingFlags.Static)!); + + /// + public void Register(IServiceCollection services, ILogger logger) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(logger); + + services.AddTransient( + typeof(IStreamRequestHandler), + typeof(GeneratedStreamInvokerRequestHandler)); + } + + /// + public bool TryGetDescriptor( + Type requestType, + Type responseType, + out CqrsStreamInvokerDescriptor? descriptor) + { + ArgumentNullException.ThrowIfNull(requestType); + ArgumentNullException.ThrowIfNull(responseType); + + if (requestType == typeof(GeneratedStreamInvokerRequest) && responseType == typeof(int)) + { + descriptor = ValidDescriptor; + return true; + } + + descriptor = null; + return false; + } + + /// + public IReadOnlyList GetDescriptors() + { + return + [ + new CqrsStreamInvokerDescriptorEntry( + typeof(GeneratedStreamInvokerRequest), + typeof(int), + InvalidDescriptor), + new CqrsStreamInvokerDescriptorEntry( + typeof(GeneratedStreamInvokerRequest), + typeof(int), + ValidDescriptor) + ]; + } + + private static object InvokeAlternativeGenerated(object handler, object request, CancellationToken cancellationToken) + { + return new[] { 800, 801 }.ToAsyncEnumerable(); + } + } + /// /// 创建带有 generated request invoker registry 元数据的程序集替身。 /// diff --git a/GFramework.Cqrs.Tests/Cqrs/CqrsNotificationPublisherTests.cs b/GFramework.Cqrs.Tests/Cqrs/CqrsNotificationPublisherTests.cs index d862652d..485c11e7 100644 --- a/GFramework.Cqrs.Tests/Cqrs/CqrsNotificationPublisherTests.cs +++ b/GFramework.Cqrs.Tests/Cqrs/CqrsNotificationPublisherTests.cs @@ -91,6 +91,70 @@ internal sealed class CqrsNotificationPublisherTests Assert.That(handler.ObservedContext, Is.SameAs(architectureContext.Object)); } + /// + /// 验证当容器里可见多个通知发布策略时,dispatcher 会拒绝在歧义状态下继续发布。 + /// + [Test] + public void PublishAsync_Should_Throw_When_Multiple_NotificationPublishers_Are_Registered() + { + var runtime = CreateRuntime( + container => + { + container + .Setup(currentContainer => currentContainer.GetAll(typeof(INotificationHandler))) + .Returns([new RecordingNotificationHandler("only", [])]); + container + .Setup(currentContainer => currentContainer.GetAll(typeof(INotificationPublisher))) + .Returns( + [ + new TrackingNotificationPublisher(), + new TrackingNotificationPublisher() + ]); + }); + + Assert.That( + async () => await runtime.PublishAsync(new FakeCqrsContext(), new PublisherNotification()).ConfigureAwait(false), + Throws.InvalidOperationException.With.Message.EqualTo( + $"Multiple {typeof(INotificationPublisher).FullName} instances are registered. Remove duplicate notification publisher strategies before publishing notifications.")); + } + + /// + /// 验证 dispatcher 在首次发布时解析通知发布器后,会复用同一实例并停止继续查询容器。 + /// + [Test] + public async Task PublishAsync_Should_Cache_Resolved_NotificationPublisher_After_First_Publish() + { + var firstPublisher = new TrackingNotificationPublisher(); + var secondPublisher = new TrackingNotificationPublisher(); + var notificationPublisherLookupCount = 0; + var runtime = CreateRuntime( + container => + { + container + .Setup(currentContainer => currentContainer.GetAll(typeof(INotificationHandler))) + .Returns([new RecordingNotificationHandler("only", [])]); + container + .Setup(currentContainer => currentContainer.GetAll(typeof(INotificationPublisher))) + .Returns(() => + { + notificationPublisherLookupCount++; + return notificationPublisherLookupCount switch + { + 1 => [firstPublisher], + 2 => [secondPublisher], + _ => throw new AssertionException("Notification publisher should be resolved at most once.") + }; + }); + }); + + await runtime.PublishAsync(new FakeCqrsContext(), new PublisherNotification()).ConfigureAwait(false); + await runtime.PublishAsync(new FakeCqrsContext(), new PublisherNotification()).ConfigureAwait(false); + + Assert.That(notificationPublisherLookupCount, Is.EqualTo(1)); + Assert.That(firstPublisher.PublishCallCount, Is.EqualTo(2)); + Assert.That(secondPublisher.PublishCallCount, Is.Zero); + } + /// /// 验证内置 `TaskWhenAll` 发布器会继续调度所有处理器,而不是沿用默认顺序发布器的失败即停语义。 /// @@ -262,6 +326,11 @@ internal sealed class CqrsNotificationPublisherTests /// public bool WasCalled { get; private set; } + /// + /// 获取当前发布器累计执行发布的次数。 + /// + public int PublishCallCount { get; private set; } + /// /// 记录当前发布器已被调用,并继续按当前顺序执行所有处理器。 /// @@ -275,6 +344,7 @@ internal sealed class CqrsNotificationPublisherTests where TNotification : INotification { WasCalled = true; + PublishCallCount++; foreach (var handler in context.Handlers) { 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/Internal/CqrsHandlerRegistrar.cs b/GFramework.Cqrs/Internal/CqrsHandlerRegistrar.cs index a332927c..740d85a8 100644 --- a/GFramework.Cqrs/Internal/CqrsHandlerRegistrar.cs +++ b/GFramework.Cqrs/Internal/CqrsHandlerRegistrar.cs @@ -16,6 +16,13 @@ namespace GFramework.Cqrs.Internal; /// internal static class CqrsHandlerRegistrar { + /// + /// 描述 generated invoker descriptor 在 registrar 预热阶段使用的 request/response 类型对键。 + /// + /// 请求运行时类型。 + /// 响应运行时类型。 + private readonly record struct InvokerDescriptorKey(Type RequestType, Type ResponseType); + // 卸载安全的进程级缓存:程序集元数据只按弱键复用。 // 若程序集来自 collectible AssemblyLoadContext,被回收后会重新分析,而不会被静态缓存永久钉住。 private static readonly WeakKeyCache AssemblyMetadataCache = @@ -321,8 +328,49 @@ internal static class CqrsHandlerRegistrar if (provider is not IEnumeratesCqrsRequestInvokerDescriptors descriptorSource) return; - foreach (var descriptorEntry in descriptorSource.GetDescriptors()) + IReadOnlyList? descriptors; + try { + descriptors = descriptorSource.GetDescriptors(); + } + catch (Exception exception) + { + logger.Warn( + $"Failed to enumerate generated CQRS request invoker descriptors from provider {provider.GetType().FullName} in assembly {assemblyName}. Falling back to runtime reflection for request invokers: {exception.Message}"); + return; + } + + if (descriptors is null) + { + logger.Warn( + $"Ignoring generated CQRS request invoker descriptors from provider {provider.GetType().FullName} in assembly {assemblyName} because GetDescriptors() returned null."); + return; + } + + var registeredKeys = new HashSet(); + foreach (var descriptorEntry in descriptors) + { + if (descriptorEntry is null) + { + logger.Warn( + $"Ignoring null generated CQRS request invoker descriptor entry from provider {provider.GetType().FullName} in assembly {assemblyName}."); + continue; + } + + var descriptorKey = new InvokerDescriptorKey( + descriptorEntry.RequestType, + descriptorEntry.ResponseType); + + if (!TryValidateEnumeratedRequestInvokerDescriptor(provider, descriptorEntry, assemblyName, logger)) + continue; + + if (!registeredKeys.Add(descriptorKey)) + { + logger.Warn( + $"Ignoring duplicate generated CQRS request invoker descriptor for {descriptorEntry.RequestType.FullName} -> {descriptorEntry.ResponseType.FullName} from provider {provider.GetType().FullName} in assembly {assemblyName}."); + continue; + } + CqrsDispatcher.RegisterGeneratedRequestInvokerDescriptor( descriptorEntry.RequestType, descriptorEntry.ResponseType, @@ -376,8 +424,49 @@ internal static class CqrsHandlerRegistrar if (provider is not IEnumeratesCqrsStreamInvokerDescriptors descriptorSource) return; - foreach (var descriptorEntry in descriptorSource.GetDescriptors()) + IReadOnlyList? descriptors; + try { + descriptors = descriptorSource.GetDescriptors(); + } + catch (Exception exception) + { + logger.Warn( + $"Failed to enumerate generated CQRS stream invoker descriptors from provider {provider.GetType().FullName} in assembly {assemblyName}. Falling back to runtime reflection for stream invokers: {exception.Message}"); + return; + } + + if (descriptors is null) + { + logger.Warn( + $"Ignoring generated CQRS stream invoker descriptors from provider {provider.GetType().FullName} in assembly {assemblyName} because GetDescriptors() returned null."); + return; + } + + var registeredKeys = new HashSet(); + foreach (var descriptorEntry in descriptors) + { + if (descriptorEntry is null) + { + logger.Warn( + $"Ignoring null generated CQRS stream invoker descriptor entry from provider {provider.GetType().FullName} in assembly {assemblyName}."); + continue; + } + + var descriptorKey = new InvokerDescriptorKey( + descriptorEntry.RequestType, + descriptorEntry.ResponseType); + + if (!TryValidateEnumeratedStreamInvokerDescriptor(provider, descriptorEntry, assemblyName, logger)) + continue; + + if (!registeredKeys.Add(descriptorKey)) + { + logger.Warn( + $"Ignoring duplicate generated CQRS stream invoker descriptor for {descriptorEntry.RequestType.FullName} -> {descriptorEntry.ResponseType.FullName} from provider {provider.GetType().FullName} in assembly {assemblyName}."); + continue; + } + CqrsDispatcher.RegisterGeneratedStreamInvokerDescriptor( descriptorEntry.RequestType, descriptorEntry.ResponseType, @@ -387,6 +476,96 @@ internal static class CqrsHandlerRegistrar } } + /// + /// 校验 request descriptor 枚举项是否与 provider 的显式查询结果保持一致。 + /// + /// 当前正在预热的 request invoker provider。 + /// 当前枚举到的描述符条目。 + /// 当前程序集的稳定名称。 + /// 日志记录器。 + /// 当该枚举项可安全写入 dispatcher 缓存时返回 ;否则返回 + private static bool TryValidateEnumeratedRequestInvokerDescriptor( + ICqrsRequestInvokerProvider provider, + CqrsRequestInvokerDescriptorEntry descriptorEntry, + string assemblyName, + ILogger logger) + { + try + { + if (!provider.TryGetDescriptor( + descriptorEntry.RequestType, + descriptorEntry.ResponseType, + out var resolvedDescriptor) || + resolvedDescriptor is null) + { + logger.Warn( + $"Ignoring generated CQRS request invoker descriptor for {descriptorEntry.RequestType.FullName} -> {descriptorEntry.ResponseType.FullName} from provider {provider.GetType().FullName} in assembly {assemblyName} because TryGetDescriptor did not return a matching descriptor."); + return false; + } + + if (!resolvedDescriptor.InvokerMethod.Equals(descriptorEntry.Descriptor.InvokerMethod) || + resolvedDescriptor.HandlerType != descriptorEntry.Descriptor.HandlerType) + { + logger.Warn( + $"Ignoring generated CQRS request invoker descriptor for {descriptorEntry.RequestType.FullName} -> {descriptorEntry.ResponseType.FullName} from provider {provider.GetType().FullName} in assembly {assemblyName} because the enumerated descriptor does not match TryGetDescriptor."); + return false; + } + + return true; + } + catch (Exception exception) + { + logger.Warn( + $"Ignoring generated CQRS request invoker descriptor for {descriptorEntry.RequestType.FullName} -> {descriptorEntry.ResponseType.FullName} from provider {provider.GetType().FullName} in assembly {assemblyName} because TryGetDescriptor threw: {exception.Message}"); + return false; + } + } + + /// + /// 校验 stream descriptor 枚举项是否与 provider 的显式查询结果保持一致。 + /// + /// 当前正在预热的 stream invoker provider。 + /// 当前枚举到的描述符条目。 + /// 当前程序集的稳定名称。 + /// 日志记录器。 + /// 当该枚举项可安全写入 dispatcher 缓存时返回 ;否则返回 + private static bool TryValidateEnumeratedStreamInvokerDescriptor( + ICqrsStreamInvokerProvider provider, + CqrsStreamInvokerDescriptorEntry descriptorEntry, + string assemblyName, + ILogger logger) + { + try + { + if (!provider.TryGetDescriptor( + descriptorEntry.RequestType, + descriptorEntry.ResponseType, + out var resolvedDescriptor) || + resolvedDescriptor is null) + { + logger.Warn( + $"Ignoring generated CQRS stream invoker descriptor for {descriptorEntry.RequestType.FullName} -> {descriptorEntry.ResponseType.FullName} from provider {provider.GetType().FullName} in assembly {assemblyName} because TryGetDescriptor did not return a matching descriptor."); + return false; + } + + if (!resolvedDescriptor.InvokerMethod.Equals(descriptorEntry.Descriptor.InvokerMethod) || + resolvedDescriptor.HandlerType != descriptorEntry.Descriptor.HandlerType) + { + logger.Warn( + $"Ignoring generated CQRS stream invoker descriptor for {descriptorEntry.RequestType.FullName} -> {descriptorEntry.ResponseType.FullName} from provider {provider.GetType().FullName} in assembly {assemblyName} because the enumerated descriptor does not match TryGetDescriptor."); + return false; + } + + return true; + } + catch (Exception exception) + { + logger.Warn( + $"Ignoring generated CQRS stream invoker descriptor for {descriptorEntry.RequestType.FullName} -> {descriptorEntry.ResponseType.FullName} from provider {provider.GetType().FullName} in assembly {assemblyName} because TryGetDescriptor threw: {exception.Message}"); + return false; + } + } + /// /// 将 generated registry 的 fallback 元数据转换为统一的注册结果,并记录下一阶段是定向补扫还是整程序集扫描。 /// 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/archive/todos/cqrs-rewrite-migration-tracking-history-through-rp131.md b/ai-plan/public/cqrs-rewrite/archive/todos/cqrs-rewrite-migration-tracking-history-through-rp131.md index b5f5960e..714ba701 100644 --- a/ai-plan/public/cqrs-rewrite/archive/todos/cqrs-rewrite-migration-tracking-history-through-rp131.md +++ b/ai-plan/public/cqrs-rewrite/archive/todos/cqrs-rewrite-migration-tracking-history-through-rp131.md @@ -5,6 +5,50 @@ 围绕 `GFramework` 当前的双轨 CQRS 现状,继续完成以“去外部依赖、降低反射、收口公开入口”为目标的 CQRS 迁移与收敛。 +## 归档使用说明 + +- 本文件只覆盖 `CQRS-REWRITE-RP-076` 到 `CQRS-REWRITE-RP-131` 的历史恢复材料,用于 boot 或人工恢复时快速定位旧阶段锚点。 +- `RP-131` 是本归档的截止恢复点,不是当前 active 入口;继续恢复时应回到 active `todos/` 与 `traces/`,再从 archive 补读所需历史。 +- 若只需要历史结论,优先查本文件的阶段索引;若需要当轮验证细节、worker 边界或判断过程,再跳到配套 archive trace。 + +## 归档截止点 + +- 截止恢复点:`CQRS-REWRITE-RP-131` +- 截止阶段:benchmark 并发运行隔离入口 +- 截止结论: + - benchmark 入口已支持 `--artifacts-suffix `,并通过独立 host 工作目录隔离 BenchmarkDotNet 并发运行产物。 + - 截止轮次的写面仍收敛在 `GFramework.Cqrs.Benchmarks` 单模块,没有重新扩散到 runtime、测试或 active tracking。 +- 截止后的历史跳转: + - 需要 `RP-129` 到 `RP-131` 的 benchmark 隔离 / scoped-host / worker 波次细节:查看下方 `2026-05-11`。 + - 需要 `RP-123` 到 `RP-128` 的 PR review 收口与 stream/request benchmark 对齐:查看下方 `2026-05-09`。 + - 需要 `RP-113` 到 `RP-122` 的 notification publisher 与 request 热路径收敛:查看下方 `2026-05-08`。 + - 需要 `RP-101` 到 `RP-112` 的 benchmark 基线、PR review 收口与 bridge 之后的性能阶段:查看下方 `2026-05-07`。 + - 需要 `RP-076` 到 `RP-100` 的较早 generator gate / legacy bridge / benchmark 引入历史:查看下方 `2026-04-30`、`2026-05-04`、`2026-05-06`。 + +## 阶段索引 + +- `RP-129` ~ `RP-131` + - 主题:benchmark 多 worker 波次、stream scoped-host、并发运行隔离入口。 + - 入口:`2026-05-11`。 +- `RP-123` ~ `RP-128` + - 主题:`PR #344` / `PR #345` review 收口,stream lifetime 与 generated binding 对齐。 + - 入口:`2026-05-09`。 +- `RP-113` ~ `RP-122` + - 主题:notification publisher 公开策略、组合根配置、fan-out benchmark、request 零管道热路径缓存。 + - 入口:`2026-05-08`。 +- `RP-101` ~ `RP-112` + - 主题:request / stream benchmark 对照、性能门槛、`PR #339` ~ `PR #341` review 收口。 + - 入口:`2026-05-07`。 +- `RP-076` ~ `RP-100` + - 主题:active 入口历史收敛、generator contract gate、legacy Core CQRS bridge、benchmark 基础设施引入。 + - 入口:`2026-04-30`、`2026-05-04`、`2026-05-06`。 + +## 跳转约定 + +- 想找某个恢复点,直接搜索 `CQRS-REWRITE-RP-xxx`。 +- 想找某一轮最小验证,优先搜索该阶段下的“验证”或“本轮验证”。 +- 想找下一批从哪里接续,优先搜索该阶段下的“下一恢复点”或“当前下一步”。 + ## 当前恢复点 - 恢复点编号:`CQRS-REWRITE-RP-131` diff --git a/ai-plan/public/cqrs-rewrite/archive/traces/cqrs-rewrite-migration-trace-history-through-rp131.md b/ai-plan/public/cqrs-rewrite/archive/traces/cqrs-rewrite-migration-trace-history-through-rp131.md index b7d9eec2..7e5b2803 100644 --- a/ai-plan/public/cqrs-rewrite/archive/traces/cqrs-rewrite-migration-trace-history-through-rp131.md +++ b/ai-plan/public/cqrs-rewrite/archive/traces/cqrs-rewrite-migration-trace-history-through-rp131.md @@ -1,5 +1,38 @@ # CQRS 重写迁移追踪 +## 归档导航 + +- 本 trace 归档承接 `CQRS-REWRITE-RP-076` 到 `CQRS-REWRITE-RP-131` 的执行细节,作用是给 boot / 人工恢复提供“按阶段回看”的证据面,而不是重新充当 active trace。 +- 若只需要知道历史阶段做了什么、下一步曾指向哪里,先看对应日期块的首段与“下一恢复点”。 +- 若只需要验证锚点,优先看各阶段下的“本轮验证”“本轮验证结果”或“验证(RP-xxx)”。 + +## 快速索引 + +- `2026-05-11` + - `RP-129` ~ `RP-131` + - 主题:benchmark 多 worker 波次、stream scoped-host、并发运行隔离入口。 +- `2026-05-09` + - `RP-123` ~ `RP-128` + - 主题:`PR #344` / `PR #345` review 收口,stream lifetime 与 generated binding 对齐。 +- `2026-05-08` + - `RP-101` ~ `RP-122` + - 主题:notification publisher、request 热路径、benchmark 对照与 review 收口。 +- `2026-05-07` + - `RP-093` ~ `RP-100` + - 主题:legacy bridge、stream pipeline seam、性能门槛与 benchmark 对照。 +- `2026-05-06` + - `RP-083` ~ `RP-091` + - 主题:generator gate 回归、benchmark 基础设施、startup / invoker 对照。 +- `2026-05-04` 与 `2026-04-30` + - `RP-076` ~ `RP-082` + - 主题:active 入口历史收敛、generator contract gate 与早期 PR 锚点整理。 + +## 跳转约定 + +- 搜索 `CQRS-REWRITE-RP-xxx` 可直接定位单个恢复点。 +- 搜索“下一恢复点”可快速看到该阶段向后衔接的建议。 +- 搜索“本轮结论”可快速判断该阶段是否值得继续细读。 + ## 2026-05-11 ### 阶段:benchmark 并发运行隔离入口(CQRS-REWRITE-RP-131) 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..a019a586 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,99 @@ CQRS 迁移与收敛。 ## 当前恢复点 -- 恢复点编号:`CQRS-REWRITE-RP-132` +- 恢复点编号:`CQRS-REWRITE-RP-134` - 当前阶段:`Phase 8` -- 当前 PR 锚点:`PR #347` +- 当前 PR 锚点:`PR #348` - 当前结论: - - 已用 `$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/**` 顶部导航与跳转约定 + - 当前 `PR #348` latest-head review 再次复核后: + - 跳过 `NotificationLifetimeBenchmarks.HandlerLifetime` 的 `[GenerateEnumExtensions]` 建议,原因是仓库没有“所有枚举统一生成扩展”的约定,且 benchmark 局部枚举不在该能力的强制范围内 + - 接受并修复 `NotificationLifetimeBenchmarks` 的 scoped 容器释放与公开 XML 文档缺口 + - 接受并修复 `CqrsHandlerRegistrar` 对 generated descriptor 的“先去重后校验”缺陷,并补回归测试锁定“首条无效、后条有效”的同键场景 + - 接受并修复 generated descriptor 校验对 `MethodInfo` 使用 `ReferenceEquals` 的过严比较,改为按方法语义等价匹配 + - 当前尚未提交的收尾切片仅剩: + - `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` +- 当前 PR:`PR #348` - 当前写面: - - `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 +114,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 #348` 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..0afbabfb 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 @@ -7,6 +7,29 @@ SPDX-License-Identifier: Apache-2.0 ## 2026-05-11 +### 阶段:PR #348 latest-head review 再收口(CQRS-REWRITE-RP-134) + +- 重新执行 `$gframework-pr-review` 抓取当前分支 `feat/cqrs-optimization` 对应的 `PR #348` +- 本轮 latest-head open AI thread 复核结论: + - `NotificationLifetimeBenchmarks.HandlerLifetime` 补 `[GenerateEnumExtensions]` 仍判定为泛化误报 + - 仓库没有“产品/benchmark 枚举默认都启用该特性”的现行约定 + - benchmark 项目也未接入 `GFramework.Core.SourceGenerators.Abstractions`,不应为局部对照枚举平白扩大 generator 依赖面 + - `NotificationLifetimeBenchmarks` 的 `_scopedContainer` 释放缺口与公开 benchmark API 的 XML 契约缺口仍成立,接受修复 + - `CqrsHandlerRegistrar` 中 generated descriptor 的“先去重后校验”缺陷仍成立,接受修复并补测试 + - `CqrsHandlerRegistrar` 对 `MethodInfo` 使用 `ReferenceEquals` 的过严比较仍成立,接受修复 + - active tracking / trace 的当前 PR 锚点仍停留在 `PR #347`,接受同步到 `PR #348` +- 本轮主线程实施: + - `NotificationLifetimeBenchmarks` + - `Cleanup()` 将 `_scopedContainer` 一并交给 `BenchmarkCleanupHelper.DisposeAll(...)` + - 为公开 benchmark 方法与公开 handler 方法补齐缺失的 `` / `` XML 契约 + - `CqrsHandlerRegistrar` + - request / stream generated descriptor 预热路径改为“先 `TryValidate...`,后写入 `registeredKeys`” + - descriptor 对齐判断从 `ReferenceEquals(resolvedDescriptor.InvokerMethod, ...)` 调整为 `resolvedDescriptor.InvokerMethod.Equals(...)` + - `CqrsGeneratedRequestInvokerProviderTests` + - 新增 request / stream 两个回归用例,锁定“首条同键 descriptor 无效、后条有效时,仍应接受后条有效 generated descriptor” + - `ai-plan/public/cqrs-rewrite/**` + - 将 active tracking / trace 的当前 PR 锚点同步到 `PR #348` + ### 阶段:PR #347 latest-head review 收口(CQRS-REWRITE-RP-132) - 使用 `$gframework-pr-review` 重新抓取当前分支 `feat/cqrs-optimization` 对应的 `PR #347` @@ -64,3 +87,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/cqrs.md b/docs/zh-CN/core/cqrs.md index d84f094e..78cb7636 100644 --- a/docs/zh-CN/core/cqrs.md +++ b/docs/zh-CN/core/cqrs.md @@ -118,6 +118,7 @@ var playerId = await architecture.Context.SendRequestAsync( - 已解析处理器按容器顺序逐个执行 - 首个处理器抛出异常时立即停止后续分发 - 如果容器在 runtime 创建前已显式注册 `INotificationPublisher`,默认 runtime 会复用该策略;未注册时回退到内置 `SequentialNotificationPublisher` +- 默认 runtime 只消费一个 `INotificationPublisher`;如果容器里已经存在该注册,再调用 `UseNotificationPublisher*` 系列扩展会直接报错,而不是按“后注册覆盖前注册”处理 如果你需要在组合根里明确表达“为什么选这条策略”,可以按下面的矩阵判断: @@ -125,7 +126,7 @@ var playerId = await architecture.Context.SendRequestAsync( | --- | --- | --- | --- | --- | | `UseSequentialNotificationPublisher()` | 需要保持容器顺序,且希望首个失败立即停止 | 保证按容器顺序执行 | 首个处理器异常会中断后续处理器 | 这也是默认回退策略 | | `UseTaskWhenAllNotificationPublisher()` | 需要让全部处理器并行完成,再统一观察异常或取消 | 不保证顺序 | 不会在首个失败时中断其余处理器;全部结束后统一暴露结果 | 更适合语义补齐,不是性能优化开关 | -| `UseNotificationPublisher(...)` / `UseNotificationPublisher()` | 需要接入自定义或第三方 publisher 策略 | 取决于实现 | 取决于实现 | 前者复用现成实例,后者让容器负责单例生命周期 | +| `UseNotificationPublisher(...)` / `UseNotificationPublisher()` | 需要接入自定义或第三方 publisher 策略 | 取决于实现 | 取决于实现 | 前者复用现成实例,后者让容器负责单例生命周期;两者都要求容器此前尚未注册 `INotificationPublisher` | 如果你想在组合根里显式保留默认顺序语义,也可以直接写成: @@ -216,11 +217,12 @@ protected override void OnInitialize() 2. 存在生成注册器时优先使用 `ICqrsHandlerRegistry` 3. 当生成注册器同时暴露 generated request invoker provider 时,runtime 会把 request/response 类型对对应的 descriptor 预先接线到 dispatcher 缓存,后续请求分发优先消费这些 generated request invoker 元数据 4. 当生成注册器同时暴露 generated stream invoker provider 时,runtime 会以同样方式优先消费 stream request 对应的 generated stream invoker descriptor;只有当前类型对未命中时,才回退到既有反射 stream binding -5. 生成注册器不可用时记录告警并回退到反射路径;只有“未命中 generated descriptor”才会走反射绑定,已命中的不兼容元数据会直接抛出异常 -6. 当生成注册器携带 `CqrsReflectionFallbackAttribute` 元数据时,运行时会先完成生成注册器注册,再补剩余 handler -7. `CqrsReflectionFallbackAttribute` 可以同时携带 `Type[]` 和 `string[]` 两类清单;运行时会优先复用直接 `Type` 条目,只对名称条目做定向 `Assembly.GetType(...)` 查找 -8. 只有旧版空 marker 或生成注册器不可用时,才会回到整程序集反射扫描 -9. 同一程序集按稳定键去重,避免重复注册 +5. generated invoker 只覆盖 request 与 stream 两类单次分发元数据;notification handler 仍通过已注册的 `INotificationHandler<>` 集合和选定的 `INotificationPublisher` 参与分发,不存在对应的 generated notification invoker 通道 +6. 生成注册器不可用时记录告警并回退到反射路径;只有“未命中 generated descriptor”才会走反射 binding 创建,已成功登记到缓存的类型对不会再回退到另一条 generated 通道 +7. 当生成注册器携带 `CqrsReflectionFallbackAttribute` 元数据时,运行时会先完成生成注册器注册,再补剩余 handler +8. `CqrsReflectionFallbackAttribute` 可以同时携带 `Type[]` 和 `string[]` 两类清单;运行时会优先复用直接 `Type` 条目,只对名称条目做定向 `Assembly.GetType(...)` 查找 +9. 只有 fallback 元数据为空、仍是旧版空 marker 语义,或生成注册器整体不可用时,才会回到整程序集反射扫描 +10. 同一程序集按稳定键去重,避免重复注册 换句话说,声明 fallback 特性本身不等于“整包反射扫描”。当前推荐理解是:生成注册器负责能静态表达的部分,fallback 只补它覆盖不到的 handler。 @@ -231,7 +233,7 @@ protected override void OnInitialize() - stream invoker provider / descriptor - 面向 `CreateStream(...)` 触发的流式请求分发 -两者的共同点都是“优先消费 generated invoker 元数据,未命中时保留既有反射绑定作为兜底”,而不是要求业务侧切换到另一套 runtime 入口。 +两者的共同点都是“优先消费 generated invoker 元数据,未命中时保留既有反射绑定作为兜底”,而不是要求业务侧切换到另一套 runtime 入口。通知发布不在这组 generated invoker 能力里;它始终沿用 runtime 解析出的 handler 集合与当前 publisher 策略。 对接入方来说,更关键的 reader-facing 语义是:安装 `Cqrs.SourceGenerators` 后,不要求“所有 handler 都能被生成代码直接引用”才有收益。 即使仍有 fallback,runtime 也会先消费 generated registry,再只对剩余 handler 做定向补扫;只有旧版 marker 语义或空 fallback 元数据才会退回整程序集扫描。 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) diff --git a/docs/zh-CN/source-generators/cqrs-handler-registry-generator.md b/docs/zh-CN/source-generators/cqrs-handler-registry-generator.md index e96815f2..8a58e54a 100644 --- a/docs/zh-CN/source-generators/cqrs-handler-registry-generator.md +++ b/docs/zh-CN/source-generators/cqrs-handler-registry-generator.md @@ -40,6 +40,7 @@ runtime 在注册 handlers 时优先走静态注册表;当运行时合同允 这意味着运行时会先使用生成注册器完成可静态表达的映射;对 request 与 stream 分发来说,也会优先消费 generated invoker descriptor。只有当前类型对没有 generated metadata,或 registry / fallback 无法覆盖时,才继续回到既有反射 binding 或补扫路径,而不是退回整程序集盲扫。 如果这些 fallback handlers 本身仍可直接引用,生成器会优先发射 `typeof(...)` 形式的 fallback 元数据;当 runtime 允许同一程序集声明多个 fallback 特性实例时,mixed 场景也会拆成 `Type` 元数据和字符串元数据两段,进一步减少 runtime 再做字符串类型名回查的成本。 +这里的 generated invoker 只覆盖 `IRequestHandler<,>` 与 `IStreamRequestHandler<,>`。`INotificationHandler<>` 仍然只参与 registry / fallback 注册;通知分发本身继续由 runtime 解析出的 handler 集合和 `INotificationPublisher` 策略决定。 ## 最小接入路径 @@ -87,9 +88,10 @@ RegisterCqrsHandlersFromAssemblies( 2. 优先激活生成的 `ICqrsHandlerRegistry` 3. 若生成注册器同时提供 request invoker provider / descriptor,registrar 会把这些 request invoker 元数据预先登记到 dispatcher 缓存 4. 若生成注册器同时提供 stream invoker provider / descriptor,runtime 也会优先消费对应的 generated stream invoker 元数据;未命中时仍回退到既有反射 stream binding -5. 若生成元数据损坏、registry 不可激活,记录告警并回退到反射路径 -6. 若存在 `CqrsReflectionFallbackAttribute`,优先按其中携带的 `Type` 或类型名补扫剩余 handler;若元数据为空或只保留 marker 语义,则退回整程序集补扫 -7. 同一程序集按稳定键去重,避免重复注册 +5. generated invoker provider 不是独立入口;它只是让 dispatcher 在已知 `requestType + responseType` 类型对时优先命中编译期 descriptor,未命中时仍保持原有 runtime 分发入口 +6. 若生成元数据损坏、registry 不可激活,记录告警并回退到反射路径 +7. 若存在 `CqrsReflectionFallbackAttribute`,优先按其中携带的 `Type` 或类型名补扫剩余 handler;只有元数据为空、只保留 marker 语义,或 registry 整体不可用时,才退回整程序集补扫 +8. 同一程序集按稳定键去重,避免重复注册 这个行为由 [运行时注册流程测试](https://github.com/GeWuYou/GFramework/blob/main/GFramework.Cqrs.Tests/Cqrs/CqrsHandlerRegistrarTests.cs) @@ -127,6 +129,8 @@ RegisterCqrsHandlersFromAssemblies( - 其余场景统一回退到字符串元数据,避免 mixed 场景漏注册 - 只有在 runtime 提供 `CqrsReflectionFallbackAttribute` 合同时,才允许发射依赖 fallback 的结果 +`fallback` 在这里表示“补齐生成注册器没有直接接线的剩余 handler”,不是“生成器一出现就重新扫描整个程序集”。只要 attribute 里已经带了明确 `Type` 或类型名,runtime 就会先走这份定向清单。 + ## 生成策略层级 把这个生成器理解成“静态注册 or 整程序集扫描”的二选一,会低估它的收益。当前策略实际上分成四层: @@ -141,6 +145,7 @@ RegisterCqrsHandlersFromAssemblies( - 只有前面几层都无法覆盖的剩余 handler,才交给 `CqrsReflectionFallbackAttribute` 这意味着安装生成器后,并不要求“所有 handler 都可直接引用”才有收益。很多只能部分静态表达的项目,仍然可以把大部分注册路径前移到编译期,再对少数复杂类型做定向补扫。 +其中 request / stream 的 generated invoker descriptor 只在前两类 runtime seam 同时存在、且当前 handler 能安全生成静态 invoker 时才会出现;否则对应请求仍然走已存在的反射 binding 创建路径,不会影响 registry 本身继续工作。 ## 哪些场景通常不会直接退回整程序集扫描