diff --git a/GFramework.Cqrs.Tests/Cqrs/NotificationPublisherRegistrationExtensionsTests.cs b/GFramework.Cqrs.Tests/Cqrs/NotificationPublisherRegistrationExtensionsTests.cs index f98c8833..3279420f 100644 --- a/GFramework.Cqrs.Tests/Cqrs/NotificationPublisherRegistrationExtensionsTests.cs +++ b/GFramework.Cqrs.Tests/Cqrs/NotificationPublisherRegistrationExtensionsTests.cs @@ -91,6 +91,23 @@ internal sealed class NotificationPublisherRegistrationExtensionsTests Assert.That(container.Get(), Is.SameAs(publisher)); } + /// + /// 验证泛型组合根注册入口会把指定的 publisher 类型注册为容器内唯一的单例策略。 + /// + [Test] + public void UseNotificationPublisher_Generic_Overload_Should_Register_Configured_Type() + { + var container = new MicrosoftDiContainer(); + + var returnedContainer = container.UseNotificationPublisher(); + container.Freeze(); + + Assert.That(returnedContainer, Is.SameAs(container)); + Assert.That(container.HasRegistration(typeof(INotificationPublisher)), Is.True); + Assert.That(container.GetRequired(), Is.TypeOf()); + Assert.That(container.GetRequired(), Is.SameAs(container.GetRequired())); + } + /// /// 验证组合根扩展会阻止重复 notification publisher 注册,避免 runtime 创建阶段才暴露歧义。 /// @@ -105,6 +122,20 @@ internal sealed class NotificationPublisherRegistrationExtensionsTests Throws.InvalidOperationException.With.Message.Contains(nameof(INotificationPublisher))); } + /// + /// 验证当容器已存在 notification publisher 注册时,泛型组合根入口也会拒绝重复策略声明。 + /// + [Test] + public void UseNotificationPublisher_Generic_Overload_Should_Throw_When_NotificationPublisher_Already_Registered() + { + var container = new MicrosoftDiContainer(); + container.UseSequentialNotificationPublisher(); + + Assert.That( + () => container.UseNotificationPublisher(), + Throws.InvalidOperationException.With.Message.Contains(nameof(INotificationPublisher))); + } + /// /// 为本组测试提供最小 notification 类型。 /// diff --git a/GFramework.Cqrs/README.md b/GFramework.Cqrs/README.md index 78a051d4..238aa892 100644 --- a/GFramework.Cqrs/README.md +++ b/GFramework.Cqrs/README.md @@ -132,7 +132,7 @@ var playerId = await this.SendAsync(new CreatePlayerCommand(new CreatePlayerInpu | --- | --- | --- | --- | --- | | `SequentialNotificationPublisher` | 需要保持容器顺序,且希望首个失败立即停止后续分发 | 保证按容器解析顺序逐个执行 | 首个处理器抛出异常时立即停止 | 也是默认回退策略 | | `TaskWhenAllNotificationPublisher` | 需要让全部处理器并行完成,并在结束后统一观察失败或取消 | 不保证顺序 | 不会在首个失败时停止其余处理器;会聚合最终异常或取消结果 | 更适合语义补齐,不是性能开关 | - | `UseNotificationPublisher(...)` 自定义实例 | 需要接入仓库外的自定义策略或第三方策略 | 取决于具体实现 | 取决于具体实现 | 仅在内置顺序 / 并行策略都不满足时使用 | + | `UseNotificationPublisher(...)` / `UseNotificationPublisher()` | 需要接入仓库外的自定义策略或第三方策略 | 取决于具体实现 | 取决于具体实现 | 前者复用现成实例,后者让容器负责单例生命周期 | - 若只是为了降低 fixed fan-out publish 的 steady-state 成本,当前 benchmark 并不表明 `TaskWhenAllNotificationPublisher` 会优于默认顺序发布器;它更适合你需要“等待全部处理器完成并统一观察失败”的场景。 @@ -161,6 +161,14 @@ using GFramework.Cqrs.Notification; container.UseNotificationPublisher(new TaskWhenAllNotificationPublisher()); ``` +如果你希望由容器负责创建并长期复用自定义 publisher,也可以改用泛型重载: + +```csharp +using GFramework.Cqrs.Extensions; + +container.UseNotificationPublisher(); +``` + 对于走标准 `GFramework.Core` 启动路径的架构,这些组合根扩展会被默认基础设施自动复用;如果你直接调用 `CqrsRuntimeFactory.CreateRuntime(...)`,也仍然可以像以前一样显式传入 publisher 实例。 - 流式请求 - 通过 `IStreamRequest` 和 `IStreamRequestHandler<,>` 返回 `IAsyncEnumerable`。 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 f1f46ada..00c89fec 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 @@ -7,10 +7,15 @@ CQRS 迁移与收敛。 ## 当前恢复点 -- 恢复点编号:`CQRS-REWRITE-RP-118` +- 恢复点编号:`CQRS-REWRITE-RP-119` - 当前阶段:`Phase 8` - 当前 PR 锚点:`PR #342` - 当前结论: + - 当前 `RP-119` 继续沿用 `$gframework-batch-boot 50`,并在分支已与 `origin/main` 对齐(`d389eb36`, `2026-05-08 20:08:33 +0800`)后,重新选择 notification publisher 线上一个更小的采用面切片:补齐 `UseNotificationPublisher()` 的组合根采用说明与回归,而不是提前切回 request dispatch 热路径 + - 本轮不修改 `GFramework.Cqrs` runtime 语义,只收口“泛型组合根入口是否真的可用、以及读者是否知道该在什么情况下选它”这两个采用缺口 + - `NotificationPublisherRegistrationExtensionsTests` 现额外覆盖两条行为:泛型重载会把指定 publisher 类型注册为容器内唯一的单例策略;当容器里已存在 `INotificationPublisher` 注册时,泛型重载也会像实例重载一样在组合根阶段拒绝重复声明 + - `GFramework.Cqrs/README.md` 与 `docs/zh-CN/core/cqrs.md` 现在把自定义策略入口统一写成 `UseNotificationPublisher(...)` / `UseNotificationPublisher()`,并明确前者复用现成实例、后者让容器负责单例生命周期,避免用户误以为只能手写实例注册 + - 当前批次提交前的工作树 diff 为 `5 files / 77 lines`,仍远低于 `$gframework-batch-boot 50` 的文件阈值;但这一轮的主停止依据仍是上下文预算与自然评审边界,因此本批完成后应直接收口,而不是顺手再开启新的 runtime 热点实验 - 当前 `RP-118` 已使用 `$gframework-pr-review` 复核 `PR #342` latest-head review:CodeRabbit 当前仍成立的是 `NotificationFanOutBenchmarks` 中 MediatR 分支绕过共享 `HandleCore(...)`、`GFramework.Cqrs/README.md` 的 MD058 表格空行、以及恢复文档的 PR 锚点与 fan-out 历史值表述;Greptile 额外指出的 `UseTaskWhenAllNotificationPublisher()` 示例多余 `using GFramework.Cqrs.Notification;` 也在本轮一并收口 - 本轮不改 `GFramework.Cqrs` runtime 语义,只让 benchmark 的 MediatR handler 与其余对照分支共用同一组空值 / 取消检查,并把 README、中文文档与 `cqrs-rewrite` 恢复文档同步到当前 PR #342 上下文 - 本轮按 `NotificationFanOutBenchmarks` short-job 复跑确认,对称化 MediatR handler 后当前 fixed `4 handler` fan-out 结果约为 `Mediator` `3.598 ns / 0 B`、baseline `7.033 ns / 0 B`、`MediatR` `257.533 ns / 1256 B`、`GFramework.Cqrs` 顺序 `409.557 ns / 408 B`、`TaskWhenAll` `484.531 ns / 496 B` @@ -392,8 +397,8 @@ CQRS 迁移与收敛。 ## 下一推荐步骤 -1. 既然 `RP-117` 已把 notification publisher 的采用路径收口成显式策略矩阵,下一轮若继续留在 notification 线,优先评估是否需要补第三种仓库内置策略或更贴近示例代码的采用文档,而不是再重复翻写同一套边界说明 -2. 当前 benchmark 仍证明 `TaskWhenAllNotificationPublisher` 的价值主要在并行完成与异常聚合语义,而不是吞吐收益;若 notification 文档已经足够,下一轮再回到 request dispatch 常量开销时,应先避开“类型级 `IContextAware` 判定缓存”这条已验证无收益的热点假设 +1. 既然 `RP-119` 已把 `UseNotificationPublisher()` 的测试与采用说明补齐,下一轮若继续留在 notification 线,优先评估是否真的需要第三种仓库内置策略,而不是再重复扩写同一组组合根入口 +2. 若后续批次切回 request dispatch 常量开销,继续避开“类型级 `IContextAware` 判定缓存”这条已验证无收益的热点假设,并优先挑选更可能影响 steady-state 的 generated/provider 吸收点 3. 若 benchmark 对照需要继续贴近 `Mediator` 官方设计,再评估 `Mediator` 的 compile-time lifetime / stream 对照矩阵,或给 stream 引入 scoped host 基线,而不是回头重试已被 benchmark 否决的 `GetAll(Type)` 零行为探测方案 ## 活跃文档 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 0e1361e3..255f90bf 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 @@ -1,5 +1,27 @@ # CQRS 重写迁移追踪 +## 2026-05-09 + +### 阶段:notification publisher 泛型组合根入口收口(CQRS-REWRITE-RP-119) + +- 延续 `$gframework-batch-boot 50`,本轮在 `feat/cqrs-optimization` 已与 `origin/main` 对齐后,没有直接重开 request dispatch 热路径实验,而是先选择 notification publisher 线上一个更小、可直接评审的采用面切片 +- 本轮主线程决策: + - 保持 `GFramework.Cqrs` runtime 代码不变,只补 `UseNotificationPublisher()` 的组合根回归与用户文档说明 + - 在 `NotificationPublisherRegistrationExtensionsTests` 新增两条 targeted 回归,确认泛型重载会注册唯一单例策略,且在容器已存在 `INotificationPublisher` 时同样会拒绝重复声明 + - 在 `GFramework.Cqrs/README.md` 与 `docs/zh-CN/core/cqrs.md` 把自定义入口统一写成 `UseNotificationPublisher(...)` / `UseNotificationPublisher()`,并明确实例重载与泛型重载的生命周期边界 +- 本轮权威验证: + - `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release` + - 结果:通过,`0 warning / 0 error` + - `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --filter "FullyQualifiedName~NotificationPublisherRegistrationExtensionsTests"` + - 结果:通过,`6/6` passed + - `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Tests/Cqrs/NotificationPublisherRegistrationExtensionsTests.cs GFramework.Cqrs/README.md docs/zh-CN/core/cqrs.md ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md` + - 结果:通过 + - `git diff --check` + - 结果:通过 +- 本轮结论: + - notification publisher 的组合根采用面现在不再默认读者只能“手里先有一个实例”;文档与回归都已明确容器托管型自定义 publisher 的标准入口 + - 这批仍然保持在低风险、单模块、易评审边界内,适合在完成验证后直接收口为新的恢复点 + ## 2026-05-08 ### 阶段:PR #342 latest-head review 收口(CQRS-REWRITE-RP-118) diff --git a/docs/zh-CN/core/cqrs.md b/docs/zh-CN/core/cqrs.md index 33056778..d84f094e 100644 --- a/docs/zh-CN/core/cqrs.md +++ b/docs/zh-CN/core/cqrs.md @@ -125,7 +125,7 @@ var playerId = await architecture.Context.SendRequestAsync( | --- | --- | --- | --- | --- | | `UseSequentialNotificationPublisher()` | 需要保持容器顺序,且希望首个失败立即停止 | 保证按容器顺序执行 | 首个处理器异常会中断后续处理器 | 这也是默认回退策略 | | `UseTaskWhenAllNotificationPublisher()` | 需要让全部处理器并行完成,再统一观察异常或取消 | 不保证顺序 | 不会在首个失败时中断其余处理器;全部结束后统一暴露结果 | 更适合语义补齐,不是性能优化开关 | -| `UseNotificationPublisher(...)` | 需要接入自定义或第三方 publisher 策略 | 取决于实现 | 取决于实现 | 仅在内置顺序 / 并行策略都不满足时使用 | +| `UseNotificationPublisher(...)` / `UseNotificationPublisher()` | 需要接入自定义或第三方 publisher 策略 | 取决于实现 | 取决于实现 | 前者复用现成实例,后者让容器负责单例生命周期 | 如果你想在组合根里显式保留默认顺序语义,也可以直接写成: @@ -160,6 +160,14 @@ using GFramework.Cqrs.Notification; container.UseNotificationPublisher(new TaskWhenAllNotificationPublisher()); ``` +如果你的自定义 publisher 需要继续由容器构造和托管,也可以改用泛型注册入口: + +```csharp +using GFramework.Cqrs.Extensions; + +container.UseNotificationPublisher(); +``` + ## Request 与流式变体 除了最常见的 `Command` / `Query` / `Notification`,当前公开面还覆盖两类容易被忽略的入口: