feat(cqrs): 公开顺序 notification publisher 策略

- 新增公开 SequentialNotificationPublisher,并让默认 runtime 回退复用该策略

- 增加顺序 notification publisher 组合根注册入口,并更新测试文档与恢复点
This commit is contained in:
gewuyou 2026-05-08 17:57:57 +08:00
parent 310791db5a
commit 59ec255878
7 changed files with 93 additions and 10 deletions

View File

@ -50,6 +50,32 @@ internal sealed class NotificationPublisherRegistrationExtensionsTests
Assert.That(publishTask.Exception, Is.Not.Null);
}
/// <summary>
/// 验证显式注册内置 <see cref="SequentialNotificationPublisher" /> 后,
/// 默认 runtime 基础设施会保留“首个失败立即停止后续处理器”的顺序语义。
/// </summary>
[Test]
public void UseSequentialNotificationPublisher_Should_Preserve_Stop_On_First_Failure_Semantics()
{
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider();
var trailingHandler = new RecordingNotificationHandler();
var container = new MicrosoftDiContainer();
container.UseSequentialNotificationPublisher();
container.Register<INotificationHandler<TestNotification>>(new ThrowingNotificationHandler());
container.Register<INotificationHandler<TestNotification>>(trailingHandler);
CqrsTestRuntime.RegisterInfrastructure(container);
container.Freeze();
var context = new ArchitectureContext(container);
Assert.That(
async () => await context.PublishAsync(new TestNotification()).ConfigureAwait(false),
Throws.InvalidOperationException.With.Message.EqualTo("boom"));
Assert.That(trailingHandler.WasInvoked, Is.False);
Assert.That(container.GetRequired<INotificationPublisher>(), Is.TypeOf<SequentialNotificationPublisher>());
}
/// <summary>
/// 验证显式传入实例的组合根注册入口会把同一个 publisher 实例绑定到容器。
/// </summary>

View File

@ -77,6 +77,23 @@ public static class NotificationPublisherRegistrationExtensions
return UseNotificationPublisher(container, new TaskWhenAllNotificationPublisher());
}
/// <summary>
/// 将内置 <see cref="SequentialNotificationPublisher" /> 注册为当前容器唯一的 notification publisher 策略。
/// </summary>
/// <param name="container">目标依赖注入容器。</param>
/// <returns>同一个 <paramref name="container" />,便于在组合根中继续链式配置。</returns>
/// <exception cref="ArgumentNullException"><paramref name="container" /> 为 <see langword="null" />。</exception>
/// <exception cref="InvalidOperationException">
/// 当前容器已存在 <see cref="INotificationPublisher" /> 注册,无法再切换为另一个策略。
/// </exception>
/// <remarks>
/// 该策略适合处理器之间存在顺序依赖,或调用方希望在首个失败处立即停止后续分发的场景。
/// </remarks>
public static IIocContainer UseSequentialNotificationPublisher(this IIocContainer container)
{
return UseNotificationPublisher(container, new SequentialNotificationPublisher());
}
/// <summary>
/// 在组合根阶段阻止多个 notification publisher 策略同时注册,避免 runtime 创建时出现歧义。
/// </summary>

View File

@ -2,18 +2,17 @@
// SPDX-License-Identifier: Apache-2.0
using GFramework.Cqrs.Abstractions.Cqrs;
using GFramework.Cqrs.Notification;
namespace GFramework.Cqrs.Internal;
namespace GFramework.Cqrs.Notification;
/// <summary>
/// 默认的通知发布器实现
/// 以内置顺序策略逐个分发通知处理器
/// </summary>
/// <remarks>
/// 该实现完整保留当前 CQRS runtime 的既有通知语义:按已解析顺序逐个执行处理器,
/// 并在首个处理器抛出异常时立即停止后续发布。
/// <para>该实现完整保留默认 CQRS runtime 的既有通知语义:按已解析顺序逐个执行处理器。</para>
/// <para>当任意处理器抛出异常时,后续处理器不会继续执行,因此更适合存在顺序依赖或希望尽早暴露首个失败的场景。</para>
/// </remarks>
internal sealed class SequentialNotificationPublisher : INotificationPublisher
public sealed class SequentialNotificationPublisher : INotificationPublisher
{
/// <summary>
/// 按既定顺序逐个执行当前通知的处理器。

View File

@ -127,9 +127,17 @@ var playerId = await this.SendAsync(new CreatePlayerCommand(new CreatePlayerInpu
- 通知会分发给所有已注册 `INotificationHandler<>`;零处理器时默认静默完成。
- 默认通知发布器会按容器解析顺序逐个执行处理器,并在首个处理器抛出异常时立即停止后续分发。
- 若需要等待所有处理器并行完成,可以在创建 runtime 时显式传入 `TaskWhenAllNotificationPublisher`;该策略不保证执行顺序,并会在全部处理器结束后聚合异常或取消结果。
- 若容器在 runtime 创建前已显式注册 `INotificationPublisher`,默认 runtime 会复用该策略;未注册时回退到内置顺序发布器
- 若容器在 runtime 创建前已显式注册 `INotificationPublisher`,默认 runtime 会复用该策略;未注册时回退到内置 `SequentialNotificationPublisher`
- 若只是为了降低 fixed fan-out publish 的 steady-state 成本,当前 benchmark 并不表明 `TaskWhenAllNotificationPublisher` 会优于默认顺序发布器;它更适合你需要“等待全部处理器完成并统一观察失败”的场景。
如果你需要显式保留默认顺序语义,也可以在组合根里直接声明:
```csharp
using GFramework.Cqrs.Extensions;
container.UseSequentialNotificationPublisher();
```
如果你需要切换到内置并行 notification publisher推荐在组合根里显式声明这条策略
```csharp

View File

@ -7,10 +7,13 @@ CQRS 迁移与收敛。
## 当前恢复点
- 恢复点编号:`CQRS-REWRITE-RP-115`
- 恢复点编号:`CQRS-REWRITE-RP-116`
- 当前阶段:`Phase 8`
- 当前 PR 锚点:`PR #341`
- 当前结论:
- 当前 `RP-116` 已继续沿用 `$gframework-batch-boot 50`,并把刚收口的 notification publisher 配置面补成对称的内置策略集合:`SequentialNotificationPublisher` 现在作为公开类型提供,组合根新增 `UseSequentialNotificationPublisher()`,不再只存在“一个显式并行策略 + 一个隐式默认回退”
- 这一批让用户能够在文档、配置和测试里显式表达“我要顺序失败即停”与“我要并行等待全部完成”这两条内置语义,而不需要把默认顺序策略理解成 runtime 内部细节;这进一步降低了 notification publisher seam 的心智负担
- 当前分支相对 `origin/main` 的累计 branch diff 提交 `RP-115` 后约为 `11 files`,仍明显低于 `$gframework-batch-boot 50` 的停止阈值;因此这批继续保持 notification 配置面内的低风险、可评审切片
- 当前 `RP-115` 已继续沿用 `$gframework-batch-boot 50`,并把 notification publisher 线从“已具备 seam + benchmark 事实”继续收口到组合根配置面:新增 `GFramework.Cqrs.Extensions.NotificationPublisherRegistrationExtensions`,提供 `UseNotificationPublisher(...)` / `UseNotificationPublisher<TPublisher>()` / `UseTaskWhenAllNotificationPublisher()` 三个显式入口,避免用户再手写 `Register<INotificationPublisher>(new ...)`
- 这一批同时把重复策略注册前移到组合根阶段显式阻止,并在回归里确认 `UseTaskWhenAllNotificationPublisher()` 经过默认 runtime 基础设施后仍会命中“失败不阻断其余 handler”的并行语义这让 notification publisher 的采用路径从“知道内部 seam 如何接线”收口为“知道该在容器里选哪条策略”
- 用户文档现同步写明 `TaskWhenAllNotificationPublisher` 更适合“并行完成 + 统一观察失败”的语义诉求,而不是 fixed fan-out steady-state publish 优化;这与 `RP-114` 的 benchmark 结论保持一致,减少使用者把它误解成默认的性能升级开关
@ -372,7 +375,7 @@ CQRS 迁移与收敛。
## 下一推荐步骤
1. 既然 `RP-115` 已把 notification publisher 选择面收口到显式组合根扩展,下一轮若继续留在 notification 线,优先评估是否需要补第二个内置策略或更细的配置文档,而不是再让用户直接依赖裸 `INotificationPublisher` 注册细节
1. 既然 `RP-116` 已把顺序 / 并行两条内置策略都收口成显式组合根入口,下一轮若继续留在 notification 线,优先评估是否需要补更细的采用文档或第三种策略,而不是再让用户直接依赖裸 `INotificationPublisher` 注册细节
2. 当前 benchmark 已证明 `TaskWhenAllNotificationPublisher` 的价值主要在并行完成与异常聚合语义,而不是吞吐收益;若 notification 配置面已经足够,下一轮优先回到 request dispatch 常量开销,而不是新增 generated notification invoker/provider 这类 steady-state 收益信号偏弱的 runtime seam
3. 若 benchmark 对照需要继续贴近 `Mediator` 官方设计,再评估 `Mediator` 的 compile-time lifetime / stream 对照矩阵,或给 stream 引入 scoped host 基线,而不是回头重试已被 benchmark 否决的 `GetAll(Type)` 零行为探测方案

View File

@ -2,6 +2,28 @@
## 2026-05-08
### 阶段:公开顺序 notification publisher 策略CQRS-REWRITE-RP-116
- 延续 `$gframework-batch-boot 50`,本轮继续留在 notification publisher 配置面,但不再新增第三方 benchmark 或 runtime seam
- 当前分支相对 `origin/main``7ca21af9`, `2026-05-08 16:12:20 +0800`)的累计 branch diff 在 `RP-115` 提交后约为 `11 files`,明显低于 `50` 文件阈值
- `RP-115` 已把采用路径收口到显式组合根扩展,但当前仍只有 `TaskWhenAllNotificationPublisher` 是公开内置策略;默认顺序语义仍主要靠“未注册时的隐式回退”表达
- 本轮主线程决策:
- 新增公开 `GFramework.Cqrs/Notification/SequentialNotificationPublisher.cs`,并让 `CqrsRuntimeFactory` 默认回退直接使用这条公开顺序策略
- 删除 `GFramework.Cqrs/Internal/SequentialNotificationPublisher.cs` 的内部副本,避免默认顺序语义同时存在“内部实现”和“公开实现”两套类型来源
- 为 `NotificationPublisherRegistrationExtensions` 增加 `UseSequentialNotificationPublisher()`,并在回归与用户文档中把“显式顺序策略”与“显式并行策略”作为对称选择面呈现
- 本轮权威验证:
- `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|FullyQualifiedName~CqrsNotificationPublisherTests"`
- 结果:通过,`10/10` passed
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs/Notification/SequentialNotificationPublisher.cs GFramework.Cqrs/CqrsRuntimeFactory.cs GFramework.Cqrs/Extensions/NotificationPublisherRegistrationExtensions.cs 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 的公开配置面现已从“一个显式策略 + 一个隐式默认回退”收口成两条对称的内置策略选择:`UseSequentialNotificationPublisher()``UseTaskWhenAllNotificationPublisher()`
- 若后续继续 notification 线,更合理的下一刀会是补更细的采用文档或新的策略语义,而不是继续让顺序 / 并行这两条基础选择停留在隐式约定上
### 阶段notification publisher 组合根配置面CQRS-REWRITE-RP-115
- 延续 `$gframework-batch-boot 50`,本轮不再回到 benchmark 宿主,而是沿着 `RP-114` 已明确的性能/语义事实继续收口用户接入缺口:

View File

@ -117,7 +117,15 @@ var playerId = await architecture.Context.SendRequestAsync(
- 零处理器时静默完成
- 已解析处理器按容器顺序逐个执行
- 首个处理器抛出异常时立即停止后续分发
- 如果容器在 runtime 创建前已显式注册 `INotificationPublisher`,默认 runtime 会复用该策略;未注册时回退到内置顺序发布器
- 如果容器在 runtime 创建前已显式注册 `INotificationPublisher`,默认 runtime 会复用该策略;未注册时回退到内置 `SequentialNotificationPublisher`
如果你想在组合根里显式保留默认顺序语义,也可以直接写成:
```csharp
using GFramework.Cqrs.Extensions;
container.UseSequentialNotificationPublisher();
```
如果你需要等待所有通知处理器并行完成,而不是沿用默认顺序语义,可以显式切换到内置
`TaskWhenAllNotificationPublisher`