mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-12 22:03:30 +08:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4837aa2a23 | ||
|
|
2dd9435cea | ||
|
|
e3532fc2c8 | ||
|
|
092946e91a | ||
|
|
c32a1ec4ae | ||
|
|
555c7c07ac | ||
|
|
ab422b05db | ||
|
|
a016e3d4a4 | ||
|
|
f346110a8a | ||
|
|
2b2bec6532 | ||
|
|
e746fede7e | ||
|
|
d85b85c614 | ||
|
|
d7e142f03f | ||
|
|
370ea20de8 | ||
|
|
40efb4f763 | ||
|
|
4cc7060fcf | ||
|
|
ee8d65279e | ||
|
|
5e9b903d0f | ||
|
|
75e7785592 | ||
|
|
ef4d3d5ddf | ||
|
|
4e98b63e9c | ||
|
|
babd132e81 | ||
|
|
f650bc5776 | ||
|
|
683034579e | ||
|
|
e156b5f000 | ||
|
|
af4e988a5b | ||
|
|
7fa9d5ff17 | ||
|
|
8990749d91 | ||
|
|
337ffbd580 | ||
|
|
d9e47abdb6 | ||
|
|
3b2e6899d5 | ||
|
|
cacf238911 | ||
|
|
1420bd4340 | ||
|
|
158f98a465 | ||
|
|
0baa662ae4 | ||
|
|
5f9589ed3c | ||
|
|
594798dcb9 | ||
|
|
11a6b6abe4 | ||
|
|
79ae5f0b5a | ||
|
|
ebbef321ad | ||
|
|
699d0b4896 | ||
|
|
6d5d4be20b | ||
|
|
9ffe3ba237 | ||
|
|
b7fa3eee29 | ||
|
|
228e954d2d | ||
|
|
d4735aec25 | ||
|
|
a07d1c4076 | ||
|
|
9107e23268 | ||
|
|
f9c9561f40 | ||
|
|
f9dd105bcc | ||
|
|
d85828c533 | ||
|
|
17e7f64e71 | ||
|
|
56dc4fd343 | ||
|
|
3fbc563d59 | ||
|
|
4ccc36aac9 | ||
|
|
a36b5978c4 | ||
|
|
000c3e4c45 | ||
|
|
6af600d7b9 | ||
|
|
6a582d0b0b | ||
|
|
3e1ce089af |
@ -1,6 +1,6 @@
|
||||
# GFramework Skills
|
||||
|
||||
公开入口目前包含 `gframework-doc-refresh` 与 `gframework-batch-boot`。
|
||||
公开入口目前包含 `gframework-doc-refresh`、`gframework-batch-boot` 与 `gframework-multi-agent-batch`。
|
||||
|
||||
## 公开入口
|
||||
|
||||
@ -66,6 +66,30 @@
|
||||
/gframework-batch-boot keep refactoring repetitive source-generator tests in bounded batches
|
||||
```
|
||||
|
||||
### `gframework-multi-agent-batch`
|
||||
|
||||
当用户希望主 Agent 负责拆分任务、派发互不冲突的 subagent 切片、核对进度、维护 `ai-plan`、验收结果并持续推进时,使用该入口。
|
||||
|
||||
适用场景:
|
||||
|
||||
- 复杂任务已经明确可以拆成多个互不冲突的写面
|
||||
- 主 Agent 需要持续 review / integrate,而不是把执行权完全交给单个 worker
|
||||
- 需要把 delegated scope、验证结果与下一恢复点同步写回 `ai-plan`
|
||||
- 任务仍要受 branch diff、context budget 与 reviewability 边界约束
|
||||
|
||||
推荐调用:
|
||||
|
||||
```bash
|
||||
/gframework-multi-agent-batch <task>
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
/gframework-multi-agent-batch continue the current cqrs optimization by delegating non-conflicting benchmark and runtime slices
|
||||
/gframework-multi-agent-batch coordinate parallel subagents, keep ai-plan updated, and stop when reviewability starts to degrade
|
||||
```
|
||||
|
||||
## 共享资源
|
||||
|
||||
- `_shared/DOCUMENTATION_STANDARDS.md`
|
||||
|
||||
@ -11,6 +11,9 @@ Use this skill when `gframework-boot` is necessary but not sufficient because th
|
||||
batches until a clear stop condition is met.
|
||||
|
||||
Treat `AGENTS.md` as the source of truth. This skill extends `gframework-boot`; it does not replace it.
|
||||
If the task's defining requirement is that the main agent must keep acting as dispatcher, reviewer, `ai-plan` owner,
|
||||
and final integrator for multiple parallel workers, prefer `gframework-multi-agent-batch` and use this skill's stop
|
||||
condition guidance as a secondary reference.
|
||||
|
||||
Context budget is a first-class stop signal. Do not keep batching merely because a file-count threshold still has
|
||||
headroom if the active conversation, loaded repo artifacts, validation output, and pending recovery updates suggest the
|
||||
@ -27,6 +30,7 @@ agent is approaching its safe working-context limit.
|
||||
- the work is repetitive, sliceable, or likely to require multiple similar iterations
|
||||
- each batch can be given an explicit ownership boundary
|
||||
- a stop condition can be measured locally
|
||||
- the task does not primarily need the orchestration-heavy main-agent workflow captured by `gframework-multi-agent-batch`
|
||||
3. Before any delegation, define the batch objective in one sentence:
|
||||
- warning family reduction
|
||||
- repeated test refactor pattern
|
||||
|
||||
@ -9,6 +9,8 @@ description: Repository-specific boot workflow for the GFramework repo. Use when
|
||||
|
||||
Use this skill to bootstrap work in the GFramework repository with minimal user prompting.
|
||||
Treat `AGENTS.md` as the source of truth. Use this skill to enforce a startup sequence, not to replace repository rules.
|
||||
If the task clearly requires the main agent to keep coordinating multiple parallel subagents while maintaining
|
||||
`ai-plan` and reviewing each result, switch to `gframework-multi-agent-batch` after the boot context is established.
|
||||
|
||||
## Startup Workflow
|
||||
|
||||
@ -45,6 +47,8 @@ Treat `AGENTS.md` as the source of truth. Use this skill to enforce a startup se
|
||||
- Use `explorer` with `gpt-5.1-codex-mini` for narrow read-only questions, tracing, inventory, and comparisons
|
||||
- Use `worker` with `gpt-5.4` only for bounded implementation tasks with explicit ownership
|
||||
- Do not delegate purely for ceremony; delegate only when it materially shortens the task or controls context growth
|
||||
- If the user explicitly wants the main agent to keep orchestrating multiple workers through several review/integration
|
||||
cycles, prefer `gframework-multi-agent-batch` over ad-hoc delegation
|
||||
13. Before editing files, tell the user what you read, how you classified the task, whether subagents will be used,
|
||||
and the first implementation step.
|
||||
14. Proceed with execution, validation, and documentation updates required by `AGENTS.md`.
|
||||
|
||||
114
.agents/skills/gframework-multi-agent-batch/SKILL.md
Normal file
114
.agents/skills/gframework-multi-agent-batch/SKILL.md
Normal file
@ -0,0 +1,114 @@
|
||||
---
|
||||
name: gframework-multi-agent-batch
|
||||
description: Repository-specific multi-agent orchestration workflow for the GFramework repo. Use when the main agent should keep coordinating multiple parallel subagents, maintain ai-plan recovery artifacts, review subagent results, and continue bounded multi-agent waves until reviewability, context budget, or branch-diff limits say to stop.
|
||||
---
|
||||
|
||||
# GFramework Multi-Agent Batch
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill when `gframework-boot` has already established repository context, and the task now benefits from the
|
||||
main agent acting as the persistent coordinator for multiple parallel subagents.
|
||||
|
||||
Treat `AGENTS.md` as the source of truth. This skill expands the repository's multi-agent coordination rules; it does
|
||||
not replace them.
|
||||
|
||||
This skill is for orchestration-heavy work, not for every task that merely happens to use one subagent. Prefer it when
|
||||
the main agent must keep splitting bounded write slices, monitoring progress, updating `ai-plan`, validating accepted
|
||||
results, and deciding whether another delegation wave is still safe.
|
||||
|
||||
## Use When
|
||||
|
||||
Adopt this workflow only when all of the following are true:
|
||||
|
||||
1. The task is complex enough that multiple parallel slices materially shorten the work.
|
||||
2. The candidate write sets can be kept disjoint.
|
||||
3. The main agent still needs to own review, validation, integration, and `ai-plan` updates.
|
||||
4. Another wave is still likely to fit the branch-diff, context-budget, and reviewability budget.
|
||||
|
||||
Prefer `gframework-batch-boot` instead when the task is mainly repetitive bulk progress with a single obvious slice
|
||||
pattern and little need for continuous multi-worker orchestration.
|
||||
|
||||
## Startup Workflow
|
||||
|
||||
1. Execute the normal `gframework-boot` startup sequence first:
|
||||
- read `AGENTS.md`
|
||||
- read `.ai/environment/tools.ai.yaml`
|
||||
- read `ai-plan/public/README.md`
|
||||
- read the mapped active topic `todos/` and `traces/`
|
||||
2. Confirm that the active topic and current branch still match the work you are about to delegate.
|
||||
3. Define the current wave in one sentence:
|
||||
- benchmark-host alignment
|
||||
- runtime hotspot reduction
|
||||
- documentation synchronization
|
||||
- other bounded multi-slice work
|
||||
4. Identify the critical path and keep it local.
|
||||
5. Split only the non-blocking work into disjoint ownership slices.
|
||||
6. Estimate whether one more delegation wave is still safe:
|
||||
- include current branch diff vs baseline
|
||||
- loaded `ai-plan` context
|
||||
- expected validation output
|
||||
- expected integration overhead
|
||||
|
||||
## Worker Design Rules
|
||||
|
||||
For each `worker` subagent, specify:
|
||||
|
||||
- the concrete objective
|
||||
- the exact owned files or subsystem
|
||||
- files or areas the worker must not touch
|
||||
- required validation commands
|
||||
- expected output format
|
||||
- a reminder that other agents may be editing the repo
|
||||
|
||||
Prefer `explorer` subagents when the result is read-only ranking, tracing, or candidate discovery.
|
||||
|
||||
Do not launch two workers whose write sets overlap unless the overlap is trivial and the main agent has already decided
|
||||
how to serialize or reconcile that overlap.
|
||||
|
||||
## Main-Agent Loop
|
||||
|
||||
While workers run, the main agent should only do non-overlapping work:
|
||||
|
||||
- inspect the next candidate slices
|
||||
- recompute branch-diff and context-budget posture
|
||||
- review finished worker output
|
||||
- queue follow-up validation
|
||||
- keep `ai-plan/public/**` current when accepted scope or next steps change
|
||||
|
||||
After each completed worker task:
|
||||
|
||||
1. Review the reported ownership, validation, and changed files.
|
||||
2. Confirm the worker stayed inside its boundary.
|
||||
3. Run or rerun the required validation locally if the slice is accepted.
|
||||
4. Record accepted delegated scope, validation milestones, and the next recovery point in the active `ai-plan` files.
|
||||
5. Reassess whether another wave is still reviewable and safe.
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
Stop the current multi-agent wave when any of the following becomes true:
|
||||
|
||||
- the next wave would likely push the main agent near or beyond a safe context budget
|
||||
- the remaining work no longer splits into clean disjoint ownership slices
|
||||
- branch diff vs baseline is approaching the current reviewability budget
|
||||
- integrating another worker would degrade clarity more than it would save time
|
||||
- validation failures show that the next step belongs on the critical path and should stay local
|
||||
|
||||
If a branch-size threshold is also in play, treat it as a coarse repository-scope signal, not the sole decision rule.
|
||||
|
||||
## Task Tracking
|
||||
|
||||
When this workflow is active, the main agent must keep the active `ai-plan` topic current with:
|
||||
|
||||
- delegated scope that has been accepted
|
||||
- validation results
|
||||
- current branch-diff posture if it affects stop decisions
|
||||
- the next recommended resume step
|
||||
|
||||
The main agent should keep active entries concise enough that `boot` can still recover the current wave quickly.
|
||||
|
||||
## Example Triggers
|
||||
|
||||
- `Use $gframework-multi-agent-batch to coordinate non-conflicting subagents for this complex CQRS task.`
|
||||
- `Keep delegating bounded parallel slices, update ai-plan, and verify each worker result before continuing.`
|
||||
- `Run a multi-agent wave where the main agent owns review, validation, and integration.`
|
||||
@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "GFramework Multi-Agent Batch"
|
||||
short_description: "Coordinate bounded parallel subagents with ai-plan tracking"
|
||||
default_prompt: "Use $gframework-multi-agent-batch to coordinate multiple bounded parallel subagents in this GFramework repository while the main agent owns ai-plan updates, validation, review, and integration."
|
||||
52
AGENTS.md
52
AGENTS.md
@ -102,9 +102,13 @@ All AI agents and contributors must follow these rules when writing, reviewing,
|
||||
|
||||
## Repository Boot Skill
|
||||
|
||||
- The repository-maintained Codex boot skill lives at `.codex/skills/gframework-boot/`.
|
||||
- The repository-maintained Codex boot skill lives at `.agents/skills/gframework-boot/`.
|
||||
- The repository-maintained multi-agent coordination skill lives at `.agents/skills/gframework-multi-agent-batch/`.
|
||||
- Prefer invoking `$gframework-boot` when the user uses short startup prompts such as `boot`、`continue`、`next step`、
|
||||
`按 boot 开始`、`先看 AGENTS`、`继续当前任务`.
|
||||
- Prefer invoking `$gframework-multi-agent-batch` when the user explicitly wants the main agent to delegate bounded
|
||||
parallel work, track subagent progress, maintain `ai-plan`, verify subagent output, and keep coordinating until the
|
||||
current multi-agent batch reaches a natural stop boundary.
|
||||
- The boot skill is a startup convenience layer, not a replacement for this document. If the skill and `AGENTS.md`
|
||||
diverge, follow `AGENTS.md` first and update the skill in the same change.
|
||||
- The boot skill MUST read `AGENTS.md`、`.ai/environment/tools.ai.yaml`、`ai-plan/public/README.md` and the relevant
|
||||
@ -131,6 +135,52 @@ All AI agents and contributors must follow these rules when writing, reviewing,
|
||||
- The main agent remains responsible for reviewing and integrating subagent output. Unreviewed subagent conclusions do
|
||||
not count as final results.
|
||||
|
||||
### Multi-Agent Coordination Rules
|
||||
|
||||
The terms below describe the default guardrails for multi-agent batches and how they affect worker-launch decisions.
|
||||
|
||||
- `branch-diff budget`: the maximum acceptable branch diff size in files or lines before another worker wave becomes
|
||||
harder to review as a single PR.
|
||||
- `reviewability budget`: the cumulative complexity limit beyond which accepting more parallel slices would materially
|
||||
reduce review quality, even if the raw file count still looks acceptable.
|
||||
- `context-budget`: the main agent's remaining capacity to track active workers, validation, and integration state
|
||||
without losing critical execution context.
|
||||
- When any of these budgets approaches its safe limit, the main agent SHOULD stop launching more workers and close the
|
||||
current wave first.
|
||||
- `$gframework-multi-agent-batch` contains the fuller workflow and stop-condition guidance for applying these budgets in
|
||||
practice.
|
||||
|
||||
- Prefer the repository's multi-agent coordination mode when the user explicitly wants the main agent to keep
|
||||
orchestrating parallel subagents, or when the work naturally splits into `2+` disjoint write slices that can proceed
|
||||
in parallel without blocking the next local step.
|
||||
- In that mode, the main agent MUST keep ownership of:
|
||||
- critical-path selection
|
||||
- baseline and stop-condition tracking
|
||||
- `ai-plan` updates
|
||||
- validation planning and final validation
|
||||
- review and acceptance of every subagent result
|
||||
- the final integration and completion decision
|
||||
- Before spawning any `worker` subagent, the main agent MUST:
|
||||
- identify the immediate blocking step and keep it local
|
||||
- define disjoint file or subsystem ownership for each worker
|
||||
- state the required validation commands and expected output format
|
||||
- check that the expected write set still fits the current branch-diff and reviewability budget
|
||||
- While workers run, the main agent MUST avoid overlapping edits and focus on non-conflicting work such as:
|
||||
- ranking the next candidate slices
|
||||
- reviewing completed worker output
|
||||
- recomputing branch-diff and context-budget posture
|
||||
- keeping `ai-plan/public/**` recovery artifacts current
|
||||
- Before accepting a worker result, the main agent MUST confirm:
|
||||
- the worker stayed within its owned files or subsystem
|
||||
- the reported validation is sufficient for that slice
|
||||
- any accepted findings or follow-up scope are recorded in the active `ai-plan` todo or trace when the task is
|
||||
complex or multi-step
|
||||
- Do not continue launching workers merely because a file-count threshold still has room. Stop the current wave when
|
||||
ownership boundaries start to overlap, reviewability materially degrades, or the context-budget signal says the main
|
||||
agent should close the batch.
|
||||
- When a complex task uses multiple workers, the main agent SHOULD prefer the public workflow documented by
|
||||
`$gframework-multi-agent-batch` unless a more task-specific skill already provides stricter rules.
|
||||
|
||||
## Commenting Rules (MUST)
|
||||
|
||||
All generated or modified code MUST include clear and meaningful comments where required by the rules below.
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Reflection;
|
||||
using GFramework.Core.Abstractions.Architectures;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Core.Abstractions.Utility;
|
||||
using GFramework.Core.Architectures;
|
||||
using GFramework.Core.Logging;
|
||||
using GFramework.Cqrs;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs;
|
||||
using GFramework.Cqrs.Notification;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GFramework.Core.Tests.Architectures;
|
||||
@ -27,6 +30,7 @@ public class ArchitectureModulesBehaviorTests
|
||||
{
|
||||
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider();
|
||||
GameContext.Clear();
|
||||
AdditionalAssemblyNotificationHandlerState.Reset();
|
||||
TrackingPipelineBehavior<ModuleBehaviorRequest, string>.InvocationCount = 0;
|
||||
TrackingStreamPipelineBehavior<ModuleStreamBehaviorRequest, int>.InvocationCount = 0;
|
||||
}
|
||||
@ -37,6 +41,7 @@ public class ArchitectureModulesBehaviorTests
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
AdditionalAssemblyNotificationHandlerState.Reset();
|
||||
GameContext.Clear();
|
||||
TrackingPipelineBehavior<ModuleBehaviorRequest, string>.InvocationCount = 0;
|
||||
TrackingStreamPipelineBehavior<ModuleStreamBehaviorRequest, int>.InvocationCount = 0;
|
||||
@ -156,6 +161,35 @@ public class ArchitectureModulesBehaviorTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证标准架构启动路径会复用通过 <see cref="Architecture.Configurator" /> 声明的自定义 notification publisher,
|
||||
/// 而不是在 <see cref="GFramework.Core.Services.Modules.CqrsRuntimeModule" /> 创建 runtime 时提前固化默认顺序策略。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task InitializeAsync_Should_Reuse_Custom_NotificationPublisher_From_Configurator()
|
||||
{
|
||||
var generatedAssembly = CreateGeneratedHandlerAssembly();
|
||||
var architecture = new ConfiguredNotificationPublisherArchitecture(generatedAssembly.Object);
|
||||
|
||||
await architecture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var probe = architecture.Context.GetService<ArchitectureNotificationPublisherProbe>();
|
||||
|
||||
await architecture.Context.PublishAsync(new AdditionalAssemblyNotification());
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(probe.WasCalled, Is.True);
|
||||
Assert.That(AdditionalAssemblyNotificationHandlerState.InvocationCount, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
await architecture.DestroyAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于测试模块行为的最小架构实现。
|
||||
/// </summary>
|
||||
@ -191,6 +225,31 @@ public class ArchitectureModulesBehaviorTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过标准架构启动路径声明自定义 notification publisher 的最小架构。
|
||||
/// </summary>
|
||||
private sealed class ConfiguredNotificationPublisherArchitecture(Assembly generatedAssembly) : Architecture
|
||||
{
|
||||
/// <summary>
|
||||
/// 在服务钩子阶段注册 probe 与自定义 publisher,
|
||||
/// 以模拟真实项目在组合根里通过 <see cref="IServiceCollection" /> 覆盖默认策略的路径。
|
||||
/// </summary>
|
||||
public override Action<IServiceCollection>? Configurator => services =>
|
||||
{
|
||||
services.AddSingleton<ArchitectureNotificationPublisherProbe>();
|
||||
services.AddSingleton<INotificationPublisher, ArchitectureTrackingNotificationPublisher>();
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 在用户初始化阶段显式接入额外程序集里的 notification handler,
|
||||
/// 让测试聚焦“publisher 是否被复用”,而不是依赖当前测试文件自己的 handler 扫描形状。
|
||||
/// </summary>
|
||||
protected override void OnInitialize()
|
||||
{
|
||||
RegisterCqrsHandlersFromAssembly(generatedAssembly);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录模块安装调用情况的测试模块。
|
||||
/// </summary>
|
||||
@ -225,6 +284,69 @@ public class ArchitectureModulesBehaviorTests
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个仅暴露程序集级 CQRS registry 元数据的 mocked Assembly。
|
||||
/// 该测试替身模拟扩展程序集已经提供 notification handler registry,而架构只需在初始化时显式接入该程序集。
|
||||
/// </summary>
|
||||
/// <returns>包含程序集级 notification handler registry 元数据的 mocked Assembly。</returns>
|
||||
private static Mock<Assembly> CreateGeneratedHandlerAssembly()
|
||||
{
|
||||
var generatedAssembly = new Mock<Assembly>();
|
||||
generatedAssembly
|
||||
.SetupGet(static assembly => assembly.FullName)
|
||||
.Returns("GFramework.Core.Tests.Architectures.ExplicitAdditionalHandlers, Version=1.0.0.0");
|
||||
generatedAssembly
|
||||
.Setup(static assembly => assembly.GetCustomAttributes(typeof(CqrsHandlerRegistryAttribute), false))
|
||||
.Returns([new CqrsHandlerRegistryAttribute(typeof(AdditionalAssemblyNotificationHandlerRegistry))]);
|
||||
return generatedAssembly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录自定义 notification publisher 是否真正参与了标准架构启动路径下的 publish 调用。
|
||||
/// </summary>
|
||||
private sealed class ArchitectureNotificationPublisherProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取 probe 是否已被 publisher 标记为执行过。
|
||||
/// </summary>
|
||||
public bool WasCalled { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录当前 publish 调用已经命中了自定义 publisher。
|
||||
/// </summary>
|
||||
public void MarkCalled()
|
||||
{
|
||||
WasCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 依赖容器内 probe 的自定义 notification publisher。
|
||||
/// 该类型通过显式标记 + 正常转发处理器执行,验证标准架构启动路径不会把自定义策略短路成默认顺序发布器。
|
||||
/// </summary>
|
||||
private sealed class ArchitectureTrackingNotificationPublisher(
|
||||
ArchitectureNotificationPublisherProbe probe) : INotificationPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录自定义 publisher 已参与当前发布调用,并继续按处理器解析顺序转发执行。
|
||||
/// </summary>
|
||||
public async ValueTask PublishAsync<TNotification>(
|
||||
NotificationPublishContext<TNotification> context,
|
||||
CancellationToken cancellationToken = default)
|
||||
where TNotification : INotification
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
probe.MarkCalled();
|
||||
|
||||
foreach (var handler in context.Handlers)
|
||||
{
|
||||
await context.InvokeHandlerAsync(handler, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 物化异步流为只读列表,便于断言 stream pipeline 行为的最终可观察结果。
|
||||
/// </summary>
|
||||
|
||||
@ -6,7 +6,6 @@ using GFramework.Core.Abstractions.Ioc;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Cqrs;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs;
|
||||
using GFramework.Cqrs.Notification;
|
||||
using LegacyICqrsRuntime = GFramework.Core.Abstractions.Cqrs.ICqrsRuntime;
|
||||
|
||||
namespace GFramework.Core.Services.Modules;
|
||||
@ -46,8 +45,7 @@ public sealed class CqrsRuntimeModule : IServiceModule
|
||||
var dispatcherLogger = LoggerFactoryResolver.Provider.CreateLogger("CqrsDispatcher");
|
||||
var registrarLogger = LoggerFactoryResolver.Provider.CreateLogger("DefaultCqrsHandlerRegistrar");
|
||||
var registrationLogger = LoggerFactoryResolver.Provider.CreateLogger("DefaultCqrsRegistrationService");
|
||||
var notificationPublisher = container.Get<INotificationPublisher>();
|
||||
var runtime = CqrsRuntimeFactory.CreateRuntime(container, dispatcherLogger, notificationPublisher);
|
||||
var runtime = CqrsRuntimeFactory.CreateRuntime(container, dispatcherLogger);
|
||||
var registrar = CqrsRuntimeFactory.CreateHandlerRegistrar(container, registrarLogger);
|
||||
|
||||
container.Register(runtime);
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Core.Ioc;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs;
|
||||
@ -158,21 +161,130 @@ internal static class BenchmarkHostFactory
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在真实的 request 级作用域内执行一次 GFramework.CQRS request 分发。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">请求响应类型。</typeparam>
|
||||
/// <param name="runtime">复用的 scoped benchmark runtime。</param>
|
||||
/// <param name="scopedContainer">负责为每次 request 激活独立作用域的只读容器适配层。</param>
|
||||
/// <param name="context">当前 CQRS 分发上下文。</param>
|
||||
/// <param name="request">要发送的 request。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>当前 request 的响应结果。</returns>
|
||||
/// <remarks>
|
||||
/// 该入口只服务 request lifetime benchmark:它会复用同一个 dispatcher/runtime 实例,
|
||||
/// 但在每次调用前后显式创建并释放新的 DI 作用域,
|
||||
/// 让 `Scoped` handler 在真实 request 边界内解析,而不是退化为根容器解析或额外计入 runtime 构造成本。
|
||||
/// </remarks>
|
||||
internal static async ValueTask<TResponse> SendScopedGFrameworkRequestAsync<TResponse>(
|
||||
ICqrsRuntime runtime,
|
||||
ScopedBenchmarkContainer scopedContainer,
|
||||
ICqrsContext context,
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IRequest<TResponse> request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(scopedContainer);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
using var scopeLease = scopedContainer.EnterScope();
|
||||
return await runtime.SendAsync(context, request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在真实的 request 级作用域内执行一次 MediatR request 分发。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">请求响应类型。</typeparam>
|
||||
/// <param name="rootServiceProvider">当前 benchmark 的根 <see cref="ServiceProvider" />。</param>
|
||||
/// <param name="request">要发送的 request。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>当前 request 的响应结果。</returns>
|
||||
/// <remarks>
|
||||
/// 这里显式从新的 scope 解析 <see cref="IMediator" />,确保 `Scoped` handler 与其依赖绑定到 request 边界。
|
||||
/// </remarks>
|
||||
internal static async Task<TResponse> SendScopedMediatRRequestAsync<TResponse>(
|
||||
ServiceProvider rootServiceProvider,
|
||||
MediatR.IRequest<TResponse> request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rootServiceProvider);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
using var scope = rootServiceProvider.CreateScope();
|
||||
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
return await mediator.Send(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在真实的 request 级作用域内创建一次 GFramework.CQRS stream,并让该作用域覆盖整个异步枚举周期。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">stream 响应元素类型。</typeparam>
|
||||
/// <param name="runtime">复用的 scoped benchmark runtime。</param>
|
||||
/// <param name="scopedContainer">负责为每次 stream 激活独立作用域的只读容器适配层。</param>
|
||||
/// <param name="context">当前 CQRS 分发上下文。</param>
|
||||
/// <param name="request">要创建 stream 的 request。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>绑定到单次显式作用域的异步响应序列。</returns>
|
||||
/// <remarks>
|
||||
/// stream 与 request 的区别在于:handler 解析发生在建流时,但 scoped 依赖必须一直存活到枚举完成。
|
||||
/// 因此这里返回一个包装后的 async iterator,把 scope 的释放时机推迟到调用方结束枚举之后,
|
||||
/// 避免 `Scoped` handler 退化成“建流后立刻释放 scope,再在根容器语义下继续枚举”的错误模型。
|
||||
/// </remarks>
|
||||
internal static IAsyncEnumerable<TResponse> CreateScopedGFrameworkStream<TResponse>(
|
||||
ICqrsRuntime runtime,
|
||||
ScopedBenchmarkContainer scopedContainer,
|
||||
ICqrsContext context,
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequest<TResponse> request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(scopedContainer);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
return EnumerateScopedGFrameworkStreamAsync(runtime, scopedContainer, context, request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在真实的 request 级作用域内创建一次 MediatR stream,并让该作用域覆盖整个异步枚举周期。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">stream 响应元素类型。</typeparam>
|
||||
/// <param name="rootServiceProvider">当前 benchmark 的根 <see cref="ServiceProvider" />。</param>
|
||||
/// <param name="request">要创建 stream 的 request。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>绑定到单次显式作用域的异步响应序列。</returns>
|
||||
/// <remarks>
|
||||
/// 这里与 scoped request helper 保持同一组边界约束,但把 scope 生命周期延长到 stream 完整枚举结束,
|
||||
/// 确保 `Scoped` handler 与依赖不会在首个元素产出前后被提前释放。
|
||||
/// </remarks>
|
||||
internal static IAsyncEnumerable<TResponse> CreateScopedMediatRStream<TResponse>(
|
||||
ServiceProvider rootServiceProvider,
|
||||
MediatR.IStreamRequest<TResponse> request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rootServiceProvider);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
return EnumerateScopedMediatRStreamAsync(rootServiceProvider, request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建承载 NuGet `Mediator` source-generated concrete mediator 的最小对照宿主。
|
||||
/// </summary>
|
||||
/// <param name="configure">补充当前场景的显式服务注册。</param>
|
||||
/// <returns>可直接解析 generated `Mediator.Mediator` 的 DI 宿主。</returns>
|
||||
/// <remarks>
|
||||
/// 当前 benchmark 只把 `Mediator` 作为单例 steady-state 对照组接入,
|
||||
/// 因为它的 lifetime 由 source generator 在编译期塑形;若后续需要 `Transient` / `Scoped` 矩阵,
|
||||
/// 应按 `Mediator` 官方 benchmark 的做法拆成独立 build config,而不是在同一编译产物里混用多个 lifetime。
|
||||
/// `Mediator` 的 DI lifetime 由 source generator 在编译期固定到整个位于当前项目中的生成产物上。
|
||||
/// 因此 benchmark 工程必须统一使用一套 compile-time 常量配置;这里显式收敛为 `Singleton`,
|
||||
/// 避免同一编译产物里混入多个 `AddMediator` lifetime 形状后,在 BenchmarkDotNet 自动生成宿主中触发
|
||||
/// “generated code lifetime 与 runtime options 不一致”的启动失败。
|
||||
/// </remarks>
|
||||
internal static ServiceProvider CreateMediatorServiceProvider(Action<IServiceCollection>? configure)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
configure?.Invoke(services);
|
||||
services.AddMediator();
|
||||
services.AddMediator(static options => options.ServiceLifetime = ServiceLifetime.Singleton);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
@ -191,4 +303,43 @@ internal static class BenchmarkHostFactory
|
||||
interfaceType.IsGenericType &&
|
||||
interfaceType.GetGenericTypeDefinition() == openGenericContract);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在单个显式作用域内创建并枚举 GFramework.CQRS stream。
|
||||
/// </summary>
|
||||
private static async IAsyncEnumerable<TResponse> EnumerateScopedGFrameworkStreamAsync<TResponse>(
|
||||
ICqrsRuntime runtime,
|
||||
ScopedBenchmarkContainer scopedContainer,
|
||||
ICqrsContext context,
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequest<TResponse> request,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
using var scopeLease = scopedContainer.EnterScope();
|
||||
var stream = runtime.CreateStream(context, request, cancellationToken);
|
||||
|
||||
await foreach (var response in stream.ConfigureAwait(false))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return response;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在单个显式作用域内创建并枚举 MediatR stream。
|
||||
/// </summary>
|
||||
private static async IAsyncEnumerable<TResponse> EnumerateScopedMediatRStreamAsync<TResponse>(
|
||||
ServiceProvider rootServiceProvider,
|
||||
MediatR.IStreamRequest<TResponse> request,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = rootServiceProvider.CreateScope();
|
||||
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
var stream = mediator.CreateStream(request, cancellationToken);
|
||||
|
||||
await foreach (var response in stream.ConfigureAwait(false))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
[assembly: GFramework.Cqrs.CqrsHandlerRegistryAttribute(
|
||||
typeof(GFramework.Cqrs.Benchmarks.Messaging.GeneratedRequestLifetimeBenchmarkRegistry))]
|
||||
|
||||
namespace GFramework.Cqrs.Benchmarks.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// 为 request 生命周期矩阵 benchmark 提供 hand-written generated registry,
|
||||
/// 以便在默认 generated-provider 宿主路径上比较不同 handler 生命周期的 dispatch 成本。
|
||||
/// </summary>
|
||||
public sealed class GeneratedRequestLifetimeBenchmarkRegistry :
|
||||
GFramework.Cqrs.ICqrsHandlerRegistry,
|
||||
GFramework.Cqrs.ICqrsRequestInvokerProvider,
|
||||
GFramework.Cqrs.IEnumeratesCqrsRequestInvokerDescriptors
|
||||
{
|
||||
private static readonly GFramework.Cqrs.CqrsRequestInvokerDescriptor Descriptor =
|
||||
new(
|
||||
typeof(IRequestHandler<
|
||||
RequestLifetimeBenchmarks.BenchmarkRequest,
|
||||
RequestLifetimeBenchmarks.BenchmarkResponse>),
|
||||
typeof(GeneratedRequestLifetimeBenchmarkRegistry).GetMethod(
|
||||
nameof(InvokeBenchmarkRequestHandler),
|
||||
BindingFlags.Public | BindingFlags.Static)
|
||||
?? throw new InvalidOperationException("Missing generated request lifetime benchmark method."));
|
||||
|
||||
private static readonly IReadOnlyList<GFramework.Cqrs.CqrsRequestInvokerDescriptorEntry> Descriptors =
|
||||
[
|
||||
new GFramework.Cqrs.CqrsRequestInvokerDescriptorEntry(
|
||||
typeof(RequestLifetimeBenchmarks.BenchmarkRequest),
|
||||
typeof(RequestLifetimeBenchmarks.BenchmarkResponse),
|
||||
Descriptor)
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// 参与程序集注册入口,但不在这里直接写入 handler 生命周期。
|
||||
/// </summary>
|
||||
/// <param name="services">当前 generated registry 拥有的服务集合。</param>
|
||||
/// <param name="logger">用于记录 generated registry 注册行为的日志器。</param>
|
||||
/// <remarks>
|
||||
/// 生命周期矩阵需要让 benchmark 主体显式控制 `Singleton / Transient` 变量。
|
||||
/// 因此 registry 只负责暴露 generated descriptor,不在这里抢先注册 handler,避免把默认单例注册混入比较结果。
|
||||
/// </remarks>
|
||||
public void Register(IServiceCollection services, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
logger.Debug("Registered generated request lifetime benchmark descriptors.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回当前 provider 暴露的全部 generated request invoker 描述符。
|
||||
/// </summary>
|
||||
/// <returns>当前 benchmark 需要的 request invoker 描述符集合。</returns>
|
||||
public IReadOnlyList<GFramework.Cqrs.CqrsRequestInvokerDescriptorEntry> GetDescriptors()
|
||||
{
|
||||
return Descriptors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为目标请求/响应类型对返回 generated request invoker 描述符。
|
||||
/// </summary>
|
||||
/// <param name="requestType">待匹配的请求类型。</param>
|
||||
/// <param name="responseType">待匹配的响应类型。</param>
|
||||
/// <param name="descriptor">命中时返回的 generated descriptor。</param>
|
||||
/// <returns>命中当前 benchmark 的请求/响应类型对时返回 <see langword="true" />。</returns>
|
||||
public bool TryGetDescriptor(
|
||||
Type requestType,
|
||||
Type responseType,
|
||||
out GFramework.Cqrs.CqrsRequestInvokerDescriptor? descriptor)
|
||||
{
|
||||
if (requestType == typeof(RequestLifetimeBenchmarks.BenchmarkRequest) &&
|
||||
responseType == typeof(RequestLifetimeBenchmarks.BenchmarkResponse))
|
||||
{
|
||||
descriptor = Descriptor;
|
||||
return true;
|
||||
}
|
||||
|
||||
descriptor = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 generated request invoker provider 为生命周期矩阵 benchmark 产出的开放静态调用入口。
|
||||
/// </summary>
|
||||
/// <param name="handler">当前请求对应的 handler 实例。</param>
|
||||
/// <param name="request">待分发的 request。</param>
|
||||
/// <param name="cancellationToken">调用方传入的取消令牌。</param>
|
||||
/// <returns>交给目标 request handler 处理后的响应任务。</returns>
|
||||
public static ValueTask<RequestLifetimeBenchmarks.BenchmarkResponse> InvokeBenchmarkRequestHandler(
|
||||
object handler,
|
||||
object request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var typedHandler = (IRequestHandler<
|
||||
RequestLifetimeBenchmarks.BenchmarkRequest,
|
||||
RequestLifetimeBenchmarks.BenchmarkResponse>)handler;
|
||||
var typedRequest = (RequestLifetimeBenchmarks.BenchmarkRequest)request;
|
||||
return typedHandler.Handle(typedRequest, cancellationToken);
|
||||
}
|
||||
}
|
||||
@ -26,8 +26,8 @@ public sealed class GeneratedStreamLifetimeBenchmarkRegistry :
|
||||
private static readonly GFramework.Cqrs.CqrsStreamInvokerDescriptor Descriptor =
|
||||
new(
|
||||
typeof(IStreamRequestHandler<
|
||||
StreamLifetimeBenchmarks.BenchmarkStreamRequest,
|
||||
StreamLifetimeBenchmarks.BenchmarkResponse>),
|
||||
StreamLifetimeBenchmarks.GeneratedBenchmarkStreamRequest,
|
||||
StreamLifetimeBenchmarks.GeneratedBenchmarkResponse>),
|
||||
typeof(GeneratedStreamLifetimeBenchmarkRegistry).GetMethod(
|
||||
nameof(InvokeBenchmarkStreamHandler),
|
||||
BindingFlags.Public | BindingFlags.Static)
|
||||
@ -36,8 +36,8 @@ public sealed class GeneratedStreamLifetimeBenchmarkRegistry :
|
||||
private static readonly IReadOnlyList<GFramework.Cqrs.CqrsStreamInvokerDescriptorEntry> Descriptors =
|
||||
[
|
||||
new GFramework.Cqrs.CqrsStreamInvokerDescriptorEntry(
|
||||
typeof(StreamLifetimeBenchmarks.BenchmarkStreamRequest),
|
||||
typeof(StreamLifetimeBenchmarks.BenchmarkResponse),
|
||||
typeof(StreamLifetimeBenchmarks.GeneratedBenchmarkStreamRequest),
|
||||
typeof(StreamLifetimeBenchmarks.GeneratedBenchmarkResponse),
|
||||
Descriptor)
|
||||
];
|
||||
|
||||
@ -78,8 +78,8 @@ public sealed class GeneratedStreamLifetimeBenchmarkRegistry :
|
||||
Type responseType,
|
||||
out GFramework.Cqrs.CqrsStreamInvokerDescriptor? descriptor)
|
||||
{
|
||||
if (requestType == typeof(StreamLifetimeBenchmarks.BenchmarkStreamRequest) &&
|
||||
responseType == typeof(StreamLifetimeBenchmarks.BenchmarkResponse))
|
||||
if (requestType == typeof(StreamLifetimeBenchmarks.GeneratedBenchmarkStreamRequest) &&
|
||||
responseType == typeof(StreamLifetimeBenchmarks.GeneratedBenchmarkResponse))
|
||||
{
|
||||
descriptor = Descriptor;
|
||||
return true;
|
||||
@ -102,9 +102,9 @@ public sealed class GeneratedStreamLifetimeBenchmarkRegistry :
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var typedHandler = (IStreamRequestHandler<
|
||||
StreamLifetimeBenchmarks.BenchmarkStreamRequest,
|
||||
StreamLifetimeBenchmarks.BenchmarkResponse>)handler;
|
||||
var typedRequest = (StreamLifetimeBenchmarks.BenchmarkStreamRequest)request;
|
||||
StreamLifetimeBenchmarks.GeneratedBenchmarkStreamRequest,
|
||||
StreamLifetimeBenchmarks.GeneratedBenchmarkResponse>)handler;
|
||||
var typedRequest = (StreamLifetimeBenchmarks.GeneratedBenchmarkStreamRequest)request;
|
||||
return typedHandler.Handle(typedRequest, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ using GeneratedMediator = Mediator.Mediator;
|
||||
namespace GFramework.Cqrs.Benchmarks.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// 对比单处理器 notification 在 GFramework.CQRS 与 MediatR 之间的 publish 开销。
|
||||
/// 对比单处理器 notification 在 GFramework.CQRS、NuGet `Mediator` 与 MediatR 之间的 publish 开销。
|
||||
/// </summary>
|
||||
[Config(typeof(Config))]
|
||||
public class NotificationBenchmarks
|
||||
@ -95,6 +95,7 @@ public class NotificationBenchmarks
|
||||
/// <summary>
|
||||
/// 通过 GFramework.CQRS runtime 发布 notification。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 GFramework.CQRS publish 完成的值任务。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
public ValueTask PublishNotification_GFrameworkCqrs()
|
||||
{
|
||||
@ -104,6 +105,7 @@ public class NotificationBenchmarks
|
||||
/// <summary>
|
||||
/// 通过 MediatR 发布 notification,作为外部设计对照。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 MediatR publish 完成的任务。</returns>
|
||||
[Benchmark]
|
||||
public Task PublishNotification_MediatR()
|
||||
{
|
||||
@ -113,6 +115,7 @@ public class NotificationBenchmarks
|
||||
/// <summary>
|
||||
/// 通过 `Mediator` source-generated concrete mediator 发布 notification,作为高性能对照组。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 `Mediator` publish 完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask PublishNotification_Mediator()
|
||||
{
|
||||
@ -129,7 +132,7 @@ public class NotificationBenchmarks
|
||||
MediatR.INotification;
|
||||
|
||||
/// <summary>
|
||||
/// 同时实现 GFramework.CQRS 与 MediatR 契约的最小 notification handler。
|
||||
/// 同时实现 GFramework.CQRS、NuGet `Mediator` 与 MediatR 契约的最小 notification handler。
|
||||
/// </summary>
|
||||
public sealed class BenchmarkNotificationHandler :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.INotificationHandler<BenchmarkNotification>,
|
||||
|
||||
@ -123,6 +123,7 @@ public class NotificationFanOutBenchmarks
|
||||
/// <summary>
|
||||
/// 直接依次调用 4 个处理器,作为 fan-out dispatch 额外开销的 baseline。
|
||||
/// </summary>
|
||||
/// <returns>代表基线顺序调用 4 个处理器完成当前 notification 处理的值任务。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
public async ValueTask PublishNotification_Baseline()
|
||||
{
|
||||
@ -135,6 +136,7 @@ public class NotificationFanOutBenchmarks
|
||||
/// <summary>
|
||||
/// 通过默认顺序发布器的 GFramework.CQRS runtime 发布固定 4 处理器的 notification。
|
||||
/// </summary>
|
||||
/// <returns>代表当前默认顺序发布器 publish 完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask PublishNotification_GFrameworkCqrsSequential()
|
||||
{
|
||||
@ -144,6 +146,7 @@ public class NotificationFanOutBenchmarks
|
||||
/// <summary>
|
||||
/// 通过内置 <c>Task.WhenAll(...)</c> 发布器的 GFramework.CQRS runtime 发布固定 4 处理器的 notification。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 <c>Task.WhenAll(...)</c> 发布器 publish 完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask PublishNotification_GFrameworkCqrsTaskWhenAll()
|
||||
{
|
||||
@ -153,6 +156,7 @@ public class NotificationFanOutBenchmarks
|
||||
/// <summary>
|
||||
/// 通过 MediatR 发布固定 4 处理器的 notification,作为外部设计对照。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 MediatR publish 完成的任务。</returns>
|
||||
[Benchmark]
|
||||
public Task PublishNotification_MediatR()
|
||||
{
|
||||
@ -162,6 +166,7 @@ public class NotificationFanOutBenchmarks
|
||||
/// <summary>
|
||||
/// 通过 `Mediator` source-generated concrete mediator 发布固定 4 处理器的 notification,作为高性能对照组。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 `Mediator` publish 完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask PublishNotification_Mediator()
|
||||
{
|
||||
|
||||
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 对比单处理器 notification publish 在不同 handler 生命周期下的额外开销。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 当前矩阵覆盖 <c>Singleton</c>、<c>Scoped</c> 与 <c>Transient</c>。
|
||||
/// 其中 <c>Scoped</c> 会在每次 notification publish 前显式创建并释放真实的 DI 作用域,
|
||||
/// 避免把 scoped handler 错误地压到根容器解析而扭曲生命周期对照。
|
||||
/// </remarks>
|
||||
[Config(typeof(Config))]
|
||||
public class NotificationLifetimeBenchmarks
|
||||
{
|
||||
private MicrosoftDiContainer _container = null!;
|
||||
private ICqrsRuntime? _runtime;
|
||||
private ScopedBenchmarkContainer? _scopedContainer;
|
||||
private ICqrsRuntime? _scopedRuntime;
|
||||
private ServiceProvider _serviceProvider = null!;
|
||||
private IPublisher? _publisher;
|
||||
private BenchmarkNotificationHandler _baselineHandler = null!;
|
||||
private BenchmarkNotification _notification = null!;
|
||||
private ILogger _runtimeLogger = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 控制当前 benchmark 使用的 handler 生命周期。
|
||||
/// </summary>
|
||||
[Params(HandlerLifetime.Singleton, HandlerLifetime.Scoped, HandlerLifetime.Transient)]
|
||||
public HandlerLifetime Lifetime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可公平比较的 benchmark handler 生命周期集合。
|
||||
/// </summary>
|
||||
public enum HandlerLifetime
|
||||
{
|
||||
/// <summary>
|
||||
/// 复用单个 handler 实例。
|
||||
/// </summary>
|
||||
Singleton,
|
||||
|
||||
/// <summary>
|
||||
/// 每次 publish 在显式作用域内解析并复用 handler 实例。
|
||||
/// </summary>
|
||||
Scoped,
|
||||
|
||||
/// <summary>
|
||||
/// 每次 publish 都重新解析新的 handler 实例。
|
||||
/// </summary>
|
||||
Transient
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置 notification lifetime benchmark 的公共输出格式。
|
||||
/// </summary>
|
||||
private sealed class Config : ManualConfig
|
||||
{
|
||||
public Config()
|
||||
{
|
||||
AddJob(Job.Default);
|
||||
AddColumnProvider(DefaultColumnProviders.Instance);
|
||||
AddColumn(new CustomColumn("Scenario", static (_, _) => "NotificationLifetime"));
|
||||
AddDiagnoser(MemoryDiagnoser.Default);
|
||||
WithOrderer(new DefaultOrderer(SummaryOrderPolicy.FastestToSlowest, MethodOrderPolicy.Declared));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建当前生命周期下的 GFramework 与 MediatR notification 对照宿主。
|
||||
/// </summary>
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider
|
||||
{
|
||||
MinLevel = LogLevel.Fatal
|
||||
};
|
||||
Fixture.Setup($"NotificationLifetime/{Lifetime}", handlerCount: 1, pipelineCount: 0);
|
||||
BenchmarkDispatcherCacheHelper.ClearDispatcherCaches();
|
||||
|
||||
_baselineHandler = new BenchmarkNotificationHandler();
|
||||
_notification = new BenchmarkNotification(Guid.NewGuid());
|
||||
_runtimeLogger = LoggerFactoryResolver.Provider.CreateLogger(nameof(NotificationLifetimeBenchmarks) + "." + Lifetime);
|
||||
|
||||
_container = BenchmarkHostFactory.CreateFrozenGFrameworkContainer(container =>
|
||||
{
|
||||
RegisterGFrameworkHandler(container, Lifetime);
|
||||
});
|
||||
|
||||
if (Lifetime != HandlerLifetime.Scoped)
|
||||
{
|
||||
_runtime = GFramework.Cqrs.CqrsRuntimeFactory.CreateRuntime(_container, _runtimeLogger);
|
||||
}
|
||||
else
|
||||
{
|
||||
_scopedContainer = new ScopedBenchmarkContainer(_container);
|
||||
_scopedRuntime = GFramework.Cqrs.CqrsRuntimeFactory.CreateRuntime(_scopedContainer, _runtimeLogger);
|
||||
}
|
||||
|
||||
_serviceProvider = BenchmarkHostFactory.CreateMediatRServiceProvider(
|
||||
configure: null,
|
||||
typeof(NotificationLifetimeBenchmarks),
|
||||
static candidateType => candidateType == typeof(BenchmarkNotificationHandler),
|
||||
ResolveMediatRLifetime(Lifetime));
|
||||
if (Lifetime != HandlerLifetime.Scoped)
|
||||
{
|
||||
_publisher = _serviceProvider.GetRequiredService<IPublisher>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放当前生命周期矩阵持有的 benchmark 宿主资源。
|
||||
/// </summary>
|
||||
[GlobalCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
try
|
||||
{
|
||||
BenchmarkCleanupHelper.DisposeAll(_scopedContainer, _container, _serviceProvider);
|
||||
}
|
||||
finally
|
||||
{
|
||||
BenchmarkDispatcherCacheHelper.ClearDispatcherCaches();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 直接调用 handler,作为不同生命周期矩阵下的 publish 额外开销 baseline。
|
||||
/// </summary>
|
||||
/// <returns>代表基线 handler 完成当前 notification 处理的值任务。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
public ValueTask PublishNotification_Baseline()
|
||||
{
|
||||
return _baselineHandler.Handle(_notification, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 GFramework.CQRS runtime 发布 notification。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 GFramework.CQRS publish 完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask PublishNotification_GFrameworkCqrs()
|
||||
{
|
||||
if (Lifetime == HandlerLifetime.Scoped)
|
||||
{
|
||||
return PublishScopedGFrameworkNotificationAsync(
|
||||
_scopedRuntime!,
|
||||
_scopedContainer!,
|
||||
_notification,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
return _runtime!.PublishAsync(BenchmarkContext.Instance, _notification, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 MediatR 发布 notification,作为外部对照。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 MediatR publish 完成的任务。</returns>
|
||||
[Benchmark]
|
||||
public Task PublishNotification_MediatR()
|
||||
{
|
||||
if (Lifetime == HandlerLifetime.Scoped)
|
||||
{
|
||||
return PublishScopedMediatRNotificationAsync(_serviceProvider, _notification, CancellationToken.None);
|
||||
}
|
||||
|
||||
return _publisher!.Publish(_notification, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按生命周期把 benchmark notification handler 注册到 GFramework 容器。
|
||||
/// </summary>
|
||||
/// <param name="container">当前 benchmark 拥有并负责释放的容器。</param>
|
||||
/// <param name="lifetime">待比较的 handler 生命周期。</param>
|
||||
private static void RegisterGFrameworkHandler(MicrosoftDiContainer container, HandlerLifetime lifetime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(container);
|
||||
|
||||
switch (lifetime)
|
||||
{
|
||||
case HandlerLifetime.Singleton:
|
||||
container.RegisterSingleton<GFramework.Cqrs.Abstractions.Cqrs.INotificationHandler<BenchmarkNotification>, BenchmarkNotificationHandler>();
|
||||
return;
|
||||
|
||||
case HandlerLifetime.Scoped:
|
||||
container.RegisterScoped<GFramework.Cqrs.Abstractions.Cqrs.INotificationHandler<BenchmarkNotification>, BenchmarkNotificationHandler>();
|
||||
return;
|
||||
|
||||
case HandlerLifetime.Transient:
|
||||
container.RegisterTransient<GFramework.Cqrs.Abstractions.Cqrs.INotificationHandler<BenchmarkNotification>, BenchmarkNotificationHandler>();
|
||||
return;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(lifetime), lifetime, "Unsupported benchmark handler lifetime.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 benchmark 生命周期映射为 MediatR 组装所需的 <see cref="ServiceLifetime" />。
|
||||
/// </summary>
|
||||
/// <param name="lifetime">待比较的 handler 生命周期。</param>
|
||||
private static ServiceLifetime ResolveMediatRLifetime(HandlerLifetime lifetime)
|
||||
{
|
||||
return lifetime switch
|
||||
{
|
||||
HandlerLifetime.Singleton => ServiceLifetime.Singleton,
|
||||
HandlerLifetime.Scoped => ServiceLifetime.Scoped,
|
||||
HandlerLifetime.Transient => ServiceLifetime.Transient,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(lifetime), lifetime, "Unsupported benchmark handler lifetime.")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在真实的 publish 级作用域内执行一次 GFramework.CQRS notification 分发。
|
||||
/// </summary>
|
||||
/// <param name="runtime">复用的 scoped benchmark runtime。</param>
|
||||
/// <param name="scopedContainer">负责为每次 publish 激活独立作用域的只读容器适配层。</param>
|
||||
/// <param name="notification">要发布的 notification。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>代表当前 publish 完成的值任务。</returns>
|
||||
/// <remarks>
|
||||
/// notification lifetime benchmark 只关心 handler 解析和 publish 本身的热路径,
|
||||
/// 因此这里复用同一个 runtime,但在每次调用前后显式创建并释放新的 DI 作用域,
|
||||
/// 让 scoped handler 真正绑定到 publish 边界。
|
||||
/// </remarks>
|
||||
private static async ValueTask PublishScopedGFrameworkNotificationAsync(
|
||||
ICqrsRuntime runtime,
|
||||
ScopedBenchmarkContainer scopedContainer,
|
||||
BenchmarkNotification notification,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(scopedContainer);
|
||||
ArgumentNullException.ThrowIfNull(notification);
|
||||
|
||||
using var scopeLease = scopedContainer.EnterScope();
|
||||
await runtime.PublishAsync(BenchmarkContext.Instance, notification, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在真实的 publish 级作用域内执行一次 MediatR notification 分发。
|
||||
/// </summary>
|
||||
/// <param name="rootServiceProvider">当前 benchmark 的根 <see cref="ServiceProvider" />。</param>
|
||||
/// <param name="notification">要发布的 notification。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>代表当前 publish 完成的任务。</returns>
|
||||
/// <remarks>
|
||||
/// 这里显式从新的 scope 解析 <see cref="IPublisher" />,确保 <c>Scoped</c> handler 与依赖绑定到 publish 边界。
|
||||
/// </remarks>
|
||||
private static async Task PublishScopedMediatRNotificationAsync(
|
||||
ServiceProvider rootServiceProvider,
|
||||
BenchmarkNotification notification,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rootServiceProvider);
|
||||
ArgumentNullException.ThrowIfNull(notification);
|
||||
|
||||
using var scope = rootServiceProvider.CreateScope();
|
||||
var publisher = scope.ServiceProvider.GetRequiredService<IPublisher>();
|
||||
await publisher.Publish(notification, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark notification。
|
||||
/// </summary>
|
||||
/// <param name="Id">通知标识。</param>
|
||||
public sealed record BenchmarkNotification(Guid Id) :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.INotification,
|
||||
MediatR.INotification;
|
||||
|
||||
/// <summary>
|
||||
/// 同时实现 GFramework.CQRS 与 MediatR 契约的最小 notification handler。
|
||||
/// </summary>
|
||||
public sealed class BenchmarkNotificationHandler :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.INotificationHandler<BenchmarkNotification>,
|
||||
MediatR.INotificationHandler<BenchmarkNotification>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理 GFramework.CQRS notification。
|
||||
/// </summary>
|
||||
/// <param name="notification">当前要处理的 notification。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>代表当前 notification 处理完成的值任务。</returns>
|
||||
public ValueTask Handle(BenchmarkNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notification);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理 MediatR notification。
|
||||
/// </summary>
|
||||
Task MediatR.INotificationHandler<BenchmarkNotification>.Handle(
|
||||
BenchmarkNotification notification,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notification);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 对比 notification 宿主在 GFramework.CQRS、NuGet `Mediator` 与 MediatR 之间的初始化与首次发布成本。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该矩阵刻意保持“单 notification + 单 handler + 最小宿主”的对称形状,
|
||||
/// 只观察宿主构建与首个 publish 命中的额外开销,不把 fan-out 或自定义发布策略混入 startup 结论。
|
||||
/// </remarks>
|
||||
[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!;
|
||||
|
||||
/// <summary>
|
||||
/// 配置 notification startup benchmark 的公共输出格式。
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建 startup benchmark 复用的最小 notification 宿主对象。
|
||||
/// </summary>
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
Fixture.Setup("NotificationStartup", handlerCount: 1, pipelineCount: 0);
|
||||
|
||||
_serviceProvider = CreateMediatRServiceProvider();
|
||||
_publisher = _serviceProvider.GetRequiredService<IPublisher>();
|
||||
|
||||
_mediatorServiceProvider = CreateMediatorServiceProvider();
|
||||
_mediator = _mediatorServiceProvider.GetRequiredService<GeneratedMediator>();
|
||||
|
||||
_container = CreateGFrameworkContainer();
|
||||
_runtime = CreateGFrameworkRuntime(_container);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在每次 cold-start 迭代前清空 dispatcher 静态缓存,确保每组 benchmark 都重新命中首次绑定路径。
|
||||
/// </summary>
|
||||
[IterationSetup]
|
||||
public void ResetColdStartCaches()
|
||||
{
|
||||
BenchmarkDispatcherCacheHelper.ClearDispatcherCaches();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放 startup benchmark 复用的宿主对象。
|
||||
/// </summary>
|
||||
[GlobalCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
BenchmarkCleanupHelper.DisposeAll(_container, _serviceProvider, _mediatorServiceProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回已构建宿主中的 MediatR publisher,作为 initialization 组的句柄解析 baseline。
|
||||
/// </summary>
|
||||
/// <returns>当前 benchmark 复用的 MediatR publisher。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
[BenchmarkCategory("Initialization")]
|
||||
public IPublisher Initialization_MediatR()
|
||||
{
|
||||
return _publisher;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回已构建宿主中的 GFramework.CQRS runtime,确保与 MediatR baseline 处于相同初始化阶段。
|
||||
/// </summary>
|
||||
/// <returns>当前 benchmark 复用的 GFramework.CQRS runtime。</returns>
|
||||
[Benchmark]
|
||||
[BenchmarkCategory("Initialization")]
|
||||
public ICqrsRuntime Initialization_GFrameworkCqrs()
|
||||
{
|
||||
return _runtime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回已构建宿主中的 `Mediator` concrete mediator,作为 source-generated 对照组的初始化句柄。
|
||||
/// </summary>
|
||||
/// <returns>当前 benchmark 复用的 `Mediator` concrete mediator。</returns>
|
||||
[Benchmark]
|
||||
[BenchmarkCategory("Initialization")]
|
||||
public GeneratedMediator Initialization_Mediator()
|
||||
{
|
||||
return _mediator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在新宿主上首次发布 notification,作为 MediatR 的 cold-start baseline。
|
||||
/// </summary>
|
||||
/// <returns>代表首次 publish 完成的任务。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
[BenchmarkCategory("ColdStart")]
|
||||
public async Task ColdStart_MediatR()
|
||||
{
|
||||
using var serviceProvider = CreateMediatRServiceProvider();
|
||||
var publisher = serviceProvider.GetRequiredService<IPublisher>();
|
||||
await publisher.Publish(Notification, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在新 runtime 上首次发布 notification,量化 GFramework.CQRS 的 first-hit 成本。
|
||||
/// </summary>
|
||||
/// <returns>代表首次 publish 完成的值任务。</returns>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在新的 `Mediator` 宿主上首次发布 notification,量化 source-generated concrete path 的 cold-start 成本。
|
||||
/// </summary>
|
||||
/// <returns>代表首次 publish 完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
[BenchmarkCategory("ColdStart")]
|
||||
public async ValueTask ColdStart_Mediator()
|
||||
{
|
||||
using var serviceProvider = CreateMediatorServiceProvider();
|
||||
var mediator = serviceProvider.GetRequiredService<GeneratedMediator>();
|
||||
await mediator.Publish(Notification, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建只承载当前 benchmark notification 的最小 GFramework.CQRS runtime。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// startup benchmark 只需要验证单 handler publish 的首击路径,
|
||||
/// 因此这里继续使用单点手工注册,避免把更广泛的注册协调逻辑混入结果。
|
||||
/// </remarks>
|
||||
private static MicrosoftDiContainer CreateGFrameworkContainer()
|
||||
{
|
||||
return BenchmarkHostFactory.CreateFrozenGFrameworkContainer(static container =>
|
||||
{
|
||||
container.RegisterTransient<GFramework.Cqrs.Abstractions.Cqrs.INotificationHandler<BenchmarkNotification>, BenchmarkNotificationHandler>();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 基于已冻结的 benchmark 容器构建最小 GFramework.CQRS runtime。
|
||||
/// </summary>
|
||||
/// <param name="container">当前 benchmark 拥有并负责释放的容器。</param>
|
||||
/// <returns>可直接发布 notification 的 runtime。</returns>
|
||||
private static ICqrsRuntime CreateGFrameworkRuntime(MicrosoftDiContainer container)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(container);
|
||||
return GFramework.Cqrs.CqrsRuntimeFactory.CreateRuntime(container, RuntimeLogger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建只承载当前 benchmark notification handler 的最小 MediatR 对照宿主。
|
||||
/// </summary>
|
||||
/// <returns>可直接解析 <see cref="IPublisher" /> 的 DI 宿主。</returns>
|
||||
private static ServiceProvider CreateMediatRServiceProvider()
|
||||
{
|
||||
return BenchmarkHostFactory.CreateMediatRServiceProvider(
|
||||
configure: null,
|
||||
typeof(NotificationStartupBenchmarks),
|
||||
static candidateType => candidateType == typeof(BenchmarkNotificationHandler),
|
||||
ServiceLifetime.Transient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建只承载当前 benchmark notification handler 的最小 `Mediator` 对照宿主。
|
||||
/// </summary>
|
||||
/// <returns>可直接解析 generated `Mediator.Mediator` 的 DI 宿主。</returns>
|
||||
private static ServiceProvider CreateMediatorServiceProvider()
|
||||
{
|
||||
return BenchmarkHostFactory.CreateMediatorServiceProvider(configure: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为 benchmark 创建稳定的 fatal 级 logger,避免把日志成本混入 startup 测量。
|
||||
/// </summary>
|
||||
/// <param name="categoryName">logger 分类名。</param>
|
||||
/// <returns>当前 benchmark 使用的稳定 logger。</returns>
|
||||
private static ILogger CreateLogger(string categoryName)
|
||||
{
|
||||
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider
|
||||
{
|
||||
MinLevel = LogLevel.Fatal
|
||||
};
|
||||
return LoggerFactoryResolver.Provider.CreateLogger(categoryName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark notification。
|
||||
/// </summary>
|
||||
/// <param name="Id">通知标识。</param>
|
||||
public sealed record BenchmarkNotification(Guid Id) :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.INotification,
|
||||
Mediator.INotification,
|
||||
MediatR.INotification;
|
||||
|
||||
/// <summary>
|
||||
/// 同时实现 GFramework.CQRS、NuGet `Mediator` 与 MediatR 契约的最小 notification handler。
|
||||
/// </summary>
|
||||
public sealed class BenchmarkNotificationHandler :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.INotificationHandler<BenchmarkNotification>,
|
||||
Mediator.INotificationHandler<BenchmarkNotification>,
|
||||
MediatR.INotificationHandler<BenchmarkNotification>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理 GFramework.CQRS notification。
|
||||
/// </summary>
|
||||
/// <param name="notification">当前 notification。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>表示处理完成的值任务。</returns>
|
||||
public ValueTask Handle(BenchmarkNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notification);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理 NuGet `Mediator` notification。
|
||||
/// </summary>
|
||||
/// <param name="notification">当前 notification。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>表示处理完成的值任务。</returns>
|
||||
ValueTask Mediator.INotificationHandler<BenchmarkNotification>.Handle(
|
||||
BenchmarkNotification notification,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Handle(notification, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理 MediatR notification。
|
||||
/// </summary>
|
||||
/// <param name="notification">当前 notification。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>表示处理完成的任务。</returns>
|
||||
Task MediatR.INotificationHandler<BenchmarkNotification>.Handle(
|
||||
BenchmarkNotification notification,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Handle(notification, cancellationToken).AsTask();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -104,6 +104,7 @@ public class RequestBenchmarks
|
||||
/// <summary>
|
||||
/// 直接调用 handler,作为 dispatch 额外开销的 baseline。
|
||||
/// </summary>
|
||||
/// <returns>代表基线 handler 完成当前 request 处理的值任务。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
public ValueTask<BenchmarkResponse> SendRequest_Baseline()
|
||||
{
|
||||
@ -113,6 +114,7 @@ public class RequestBenchmarks
|
||||
/// <summary>
|
||||
/// 通过 GFramework.CQRS runtime 发送 request。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 GFramework.CQRS request dispatch 完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask<BenchmarkResponse> SendRequest_GFrameworkCqrs()
|
||||
{
|
||||
@ -122,6 +124,7 @@ public class RequestBenchmarks
|
||||
/// <summary>
|
||||
/// 通过 MediatR 发送 request,作为外部设计对照。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 MediatR request dispatch 完成的任务。</returns>
|
||||
[Benchmark]
|
||||
public Task<BenchmarkResponse> SendRequest_MediatR()
|
||||
{
|
||||
@ -131,6 +134,7 @@ public class RequestBenchmarks
|
||||
/// <summary>
|
||||
/// 通过 `ai-libs/Mediator` 的 source-generated concrete mediator 发送 request,作为高性能对照组。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 `Mediator` request dispatch 完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask<BenchmarkResponse> SendRequest_Mediator()
|
||||
{
|
||||
@ -153,7 +157,7 @@ public class RequestBenchmarks
|
||||
public sealed record BenchmarkResponse(Guid Id);
|
||||
|
||||
/// <summary>
|
||||
/// 同时实现 GFramework.CQRS 与 MediatR 契约的最小 request handler。
|
||||
/// 同时实现 GFramework.CQRS、NuGet `Mediator` 与 MediatR 契约的最小 request handler。
|
||||
/// </summary>
|
||||
public sealed class BenchmarkRequestHandler :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IRequestHandler<BenchmarkRequest, BenchmarkResponse>,
|
||||
|
||||
@ -116,6 +116,7 @@ public class RequestInvokerBenchmarks
|
||||
/// <summary>
|
||||
/// 直接调用最小 request handler,作为 dispatch 额外开销 baseline。
|
||||
/// </summary>
|
||||
/// <returns>代表基线 request handler 完成当前 request 处理的值任务。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
public ValueTask<ReflectionBenchmarkResponse> SendRequest_Baseline()
|
||||
{
|
||||
@ -125,6 +126,7 @@ public class RequestInvokerBenchmarks
|
||||
/// <summary>
|
||||
/// 通过 GFramework.CQRS 反射 request binding 路径发送 request。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 reflection request dispatch 完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask<ReflectionBenchmarkResponse> SendRequest_GFrameworkReflection()
|
||||
{
|
||||
@ -134,6 +136,7 @@ public class RequestInvokerBenchmarks
|
||||
/// <summary>
|
||||
/// 通过 generated request invoker provider 预热后的 GFramework.CQRS runtime 发送 request。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 generated request dispatch 完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask<GeneratedBenchmarkResponse> SendRequest_GFrameworkGenerated()
|
||||
{
|
||||
@ -143,6 +146,7 @@ public class RequestInvokerBenchmarks
|
||||
/// <summary>
|
||||
/// 通过 MediatR 发送 request,作为外部对照。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 MediatR request dispatch 完成的任务。</returns>
|
||||
[Benchmark]
|
||||
public Task<MediatRBenchmarkResponse> SendRequest_MediatR()
|
||||
{
|
||||
|
||||
@ -23,24 +23,31 @@ namespace GFramework.Cqrs.Benchmarks.Messaging;
|
||||
/// 对比 request steady-state dispatch 在不同 handler 生命周期下的额外开销。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 当前矩阵只覆盖 `Singleton` 与 `Transient`。
|
||||
/// `Scoped` 在两个 runtime 中都依赖显式作用域边界,而当前 benchmark 宿主故意保持“单根容器最小宿主”模型,
|
||||
/// 直接把 scoped 解析压到根作用域会让对照语义失真,因此留到未来有真实 scoped host 基线时再扩展。
|
||||
/// 当前矩阵覆盖 `Singleton`、`Scoped` 与 `Transient`。
|
||||
/// 其中 `Scoped` 会在每次 request 分发时显式创建并释放真实的 DI 作用域,
|
||||
/// 避免把 scoped handler 错误地压到根容器解析而扭曲生命周期对照。
|
||||
/// NuGet `Mediator` 的 DI lifetime 由 source generator 在编译期固定到整个 benchmark 项目,
|
||||
/// 不能在同一份生成产物里同时切换 `Singleton`、`Scoped` 与 `Transient`。
|
||||
/// 因此该矩阵当前只比较 `GFramework.Cqrs` 与 `MediatR` 的生命周期开销;`Mediator` 仍保留在其他
|
||||
/// steady-state / startup benchmark 中作为单一 compile-time 形状对照。
|
||||
/// </remarks>
|
||||
[Config(typeof(Config))]
|
||||
public class RequestLifetimeBenchmarks
|
||||
{
|
||||
private MicrosoftDiContainer _container = null!;
|
||||
private ICqrsRuntime _runtime = null!;
|
||||
private ICqrsRuntime? _runtime;
|
||||
private ScopedBenchmarkContainer? _scopedContainer;
|
||||
private ICqrsRuntime? _scopedRuntime;
|
||||
private ServiceProvider _serviceProvider = null!;
|
||||
private IMediator _mediatr = null!;
|
||||
private IMediator? _mediatr;
|
||||
private BenchmarkRequestHandler _baselineHandler = null!;
|
||||
private BenchmarkRequest _request = null!;
|
||||
private ILogger _runtimeLogger = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 控制当前 benchmark 使用的 handler 生命周期。
|
||||
/// </summary>
|
||||
[Params(HandlerLifetime.Singleton, HandlerLifetime.Transient)]
|
||||
[Params(HandlerLifetime.Singleton, HandlerLifetime.Scoped, HandlerLifetime.Transient)]
|
||||
public HandlerLifetime Lifetime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@ -53,6 +60,11 @@ public class RequestLifetimeBenchmarks
|
||||
/// </summary>
|
||||
Singleton,
|
||||
|
||||
/// <summary>
|
||||
/// 每次 request 在显式作用域内解析并复用 handler 实例。
|
||||
/// </summary>
|
||||
Scoped,
|
||||
|
||||
/// <summary>
|
||||
/// 每次分发都重新解析新的 handler 实例。
|
||||
/// </summary>
|
||||
@ -85,24 +97,43 @@ public class RequestLifetimeBenchmarks
|
||||
MinLevel = LogLevel.Fatal
|
||||
};
|
||||
Fixture.Setup($"RequestLifetime/{Lifetime}", handlerCount: 1, pipelineCount: 0);
|
||||
BenchmarkDispatcherCacheHelper.ClearDispatcherCaches();
|
||||
|
||||
_baselineHandler = new BenchmarkRequestHandler();
|
||||
_request = new BenchmarkRequest(Guid.NewGuid());
|
||||
|
||||
_runtimeLogger = LoggerFactoryResolver.Provider.CreateLogger(nameof(RequestLifetimeBenchmarks) + "." + Lifetime);
|
||||
|
||||
_container = BenchmarkHostFactory.CreateFrozenGFrameworkContainer(container =>
|
||||
{
|
||||
BenchmarkHostFactory.RegisterGeneratedBenchmarkRegistry<GeneratedRequestLifetimeBenchmarkRegistry>(container);
|
||||
RegisterGFrameworkHandler(container, Lifetime);
|
||||
});
|
||||
_runtime = GFramework.Cqrs.CqrsRuntimeFactory.CreateRuntime(
|
||||
_container,
|
||||
LoggerFactoryResolver.Provider.CreateLogger(nameof(RequestLifetimeBenchmarks) + "." + Lifetime));
|
||||
// 容器内已提前保留默认 runtime 以支撑 generated registry 接线;
|
||||
// 这里额外创建带生命周期后缀的 runtime,只是为了区分不同 benchmark 矩阵的 dispatcher 日志。
|
||||
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(RequestLifetimeBenchmarks),
|
||||
static candidateType => candidateType == typeof(BenchmarkRequestHandler),
|
||||
ResolveMediatRLifetime(Lifetime));
|
||||
_mediatr = _serviceProvider.GetRequiredService<IMediator>();
|
||||
if (Lifetime != HandlerLifetime.Scoped)
|
||||
{
|
||||
_mediatr = _serviceProvider.GetRequiredService<IMediator>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -111,12 +142,20 @@ public class RequestLifetimeBenchmarks
|
||||
[GlobalCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
BenchmarkCleanupHelper.DisposeAll(_container, _serviceProvider);
|
||||
try
|
||||
{
|
||||
BenchmarkCleanupHelper.DisposeAll(_container, _serviceProvider);
|
||||
}
|
||||
finally
|
||||
{
|
||||
BenchmarkDispatcherCacheHelper.ClearDispatcherCaches();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 直接调用 handler,作为不同生命周期矩阵下的 dispatch 额外开销 baseline。
|
||||
/// </summary>
|
||||
/// <returns>代表基线 request handler 完成当前 request 处理的值任务。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
public ValueTask<BenchmarkResponse> SendRequest_Baseline()
|
||||
{
|
||||
@ -126,26 +165,50 @@ public class RequestLifetimeBenchmarks
|
||||
/// <summary>
|
||||
/// 通过 GFramework.CQRS runtime 发送 request。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 GFramework.CQRS request dispatch 完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask<BenchmarkResponse> SendRequest_GFrameworkCqrs()
|
||||
{
|
||||
return _runtime.SendAsync(BenchmarkContext.Instance, _request, CancellationToken.None);
|
||||
if (Lifetime == HandlerLifetime.Scoped)
|
||||
{
|
||||
return BenchmarkHostFactory.SendScopedGFrameworkRequestAsync(
|
||||
_scopedRuntime!,
|
||||
_scopedContainer!,
|
||||
BenchmarkContext.Instance,
|
||||
_request,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
return _runtime!.SendAsync(BenchmarkContext.Instance, _request, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 MediatR 发送 request,作为外部对照。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 MediatR request dispatch 完成的任务。</returns>
|
||||
[Benchmark]
|
||||
public Task<BenchmarkResponse> SendRequest_MediatR()
|
||||
{
|
||||
return _mediatr.Send(_request, CancellationToken.None);
|
||||
if (Lifetime == HandlerLifetime.Scoped)
|
||||
{
|
||||
return BenchmarkHostFactory.SendScopedMediatRRequestAsync(
|
||||
_serviceProvider,
|
||||
_request,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
return _mediatr!.Send(_request, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按生命周期把 benchmark request handler 注册到 GFramework 容器。
|
||||
/// 按生命周期把 benchmark request handler 注册到 GFramework 容器。
|
||||
/// </summary>
|
||||
/// <param name="container">当前 benchmark 拥有并负责释放的容器。</param>
|
||||
/// <param name="lifetime">待比较的 handler 生命周期。</param>
|
||||
/// <remarks>
|
||||
/// 先通过 generated registry 提供静态 descriptor,再显式覆盖 handler 生命周期,
|
||||
/// 可以把比较变量收敛到 handler 解析成本,而不是 descriptor 发现路径本身。
|
||||
/// </remarks>
|
||||
private static void RegisterGFrameworkHandler(MicrosoftDiContainer container, HandlerLifetime lifetime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(container);
|
||||
@ -156,6 +219,10 @@ public class RequestLifetimeBenchmarks
|
||||
container.RegisterSingleton<GFramework.Cqrs.Abstractions.Cqrs.IRequestHandler<BenchmarkRequest, BenchmarkResponse>, BenchmarkRequestHandler>();
|
||||
return;
|
||||
|
||||
case HandlerLifetime.Scoped:
|
||||
container.RegisterScoped<GFramework.Cqrs.Abstractions.Cqrs.IRequestHandler<BenchmarkRequest, BenchmarkResponse>, BenchmarkRequestHandler>();
|
||||
return;
|
||||
|
||||
case HandlerLifetime.Transient:
|
||||
container.RegisterTransient<GFramework.Cqrs.Abstractions.Cqrs.IRequestHandler<BenchmarkRequest, BenchmarkResponse>, BenchmarkRequestHandler>();
|
||||
return;
|
||||
@ -174,13 +241,14 @@ public class RequestLifetimeBenchmarks
|
||||
return lifetime switch
|
||||
{
|
||||
HandlerLifetime.Singleton => ServiceLifetime.Singleton,
|
||||
HandlerLifetime.Scoped => ServiceLifetime.Scoped,
|
||||
HandlerLifetime.Transient => ServiceLifetime.Transient,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(lifetime), lifetime, "Unsupported benchmark handler lifetime.")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark request。
|
||||
/// Benchmark request。
|
||||
/// </summary>
|
||||
/// <param name="Id">请求标识。</param>
|
||||
public sealed record BenchmarkRequest(Guid Id) :
|
||||
|
||||
@ -113,6 +113,7 @@ public class RequestPipelineBenchmarks
|
||||
/// <summary>
|
||||
/// 直接调用 handler,作为 pipeline 编排之外的基线。
|
||||
/// </summary>
|
||||
/// <returns>代表基线 handler 完成当前 request 处理的值任务。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
public ValueTask<BenchmarkResponse> SendRequest_Baseline()
|
||||
{
|
||||
@ -122,6 +123,7 @@ public class RequestPipelineBenchmarks
|
||||
/// <summary>
|
||||
/// 通过 GFramework.CQRS runtime 发送 request,并按当前矩阵配置执行 pipeline。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 GFramework.CQRS request pipeline dispatch 完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask<BenchmarkResponse> SendRequest_GFrameworkCqrs()
|
||||
{
|
||||
@ -131,6 +133,7 @@ public class RequestPipelineBenchmarks
|
||||
/// <summary>
|
||||
/// 通过 MediatR 发送 request,并按当前矩阵配置执行 pipeline,作为外部设计对照。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 MediatR request pipeline dispatch 完成的任务。</returns>
|
||||
[Benchmark]
|
||||
public Task<BenchmarkResponse> SendRequest_MediatR()
|
||||
{
|
||||
|
||||
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 对比 request 宿主的初始化与首次分发成本,作为后续吸收 `Mediator` comparison benchmark 设计的 startup 基线。
|
||||
/// 对比 request 宿主在 GFramework.CQRS、NuGet `Mediator` 与 MediatR 之间的初始化与首次分发成本。
|
||||
/// </summary>
|
||||
[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!;
|
||||
|
||||
/// <summary>
|
||||
@ -63,6 +66,8 @@ public class RequestStartupBenchmarks
|
||||
|
||||
_serviceProvider = CreateMediatRServiceProvider();
|
||||
_mediatr = _serviceProvider.GetRequiredService<IMediator>();
|
||||
_mediatorServiceProvider = CreateMediatorServiceProvider();
|
||||
_mediator = _mediatorServiceProvider.GetRequiredService<GeneratedMediator>();
|
||||
_container = CreateGFrameworkContainer();
|
||||
_runtime = CreateGFrameworkRuntime(_container);
|
||||
}
|
||||
@ -86,12 +91,13 @@ public class RequestStartupBenchmarks
|
||||
[GlobalCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
BenchmarkCleanupHelper.DisposeAll(_container, _serviceProvider);
|
||||
BenchmarkCleanupHelper.DisposeAll(_container, _serviceProvider, _mediatorServiceProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回已构建宿主中的 MediatR mediator,作为 initialization 组的句柄解析 baseline。
|
||||
/// </summary>
|
||||
/// <returns>当前 benchmark 复用的 MediatR mediator。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
[BenchmarkCategory("Initialization")]
|
||||
public IMediator Initialization_MediatR()
|
||||
@ -102,6 +108,7 @@ public class RequestStartupBenchmarks
|
||||
/// <summary>
|
||||
/// 返回已构建宿主中的 GFramework.CQRS runtime,确保与 MediatR baseline 处于相同初始化阶段。
|
||||
/// </summary>
|
||||
/// <returns>当前 benchmark 复用的 GFramework.CQRS runtime。</returns>
|
||||
[Benchmark]
|
||||
[BenchmarkCategory("Initialization")]
|
||||
public ICqrsRuntime Initialization_GFrameworkCqrs()
|
||||
@ -109,9 +116,21 @@ public class RequestStartupBenchmarks
|
||||
return _runtime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回已构建宿主中的 `Mediator` concrete mediator,作为 source-generated 对照组的初始化句柄。
|
||||
/// </summary>
|
||||
/// <returns>当前 benchmark 复用的 `Mediator` concrete mediator。</returns>
|
||||
[Benchmark]
|
||||
[BenchmarkCategory("Initialization")]
|
||||
public GeneratedMediator Initialization_Mediator()
|
||||
{
|
||||
return _mediator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在新宿主上首次发送 request,作为 MediatR 的 cold-start baseline。
|
||||
/// </summary>
|
||||
/// <returns>当前 request 的响应结果。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
[BenchmarkCategory("ColdStart")]
|
||||
public async Task<BenchmarkResponse> ColdStart_MediatR()
|
||||
@ -124,6 +143,7 @@ public class RequestStartupBenchmarks
|
||||
/// <summary>
|
||||
/// 在新 runtime 上首次发送 request,量化 GFramework.CQRS 的 first-hit 成本。
|
||||
/// </summary>
|
||||
/// <returns>当前 request 的响应结果。</returns>
|
||||
[Benchmark]
|
||||
[BenchmarkCategory("ColdStart")]
|
||||
public async ValueTask<BenchmarkResponse> ColdStart_GFrameworkCqrs()
|
||||
@ -133,6 +153,19 @@ public class RequestStartupBenchmarks
|
||||
return await runtime.SendAsync(BenchmarkContext.Instance, Request, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在新的 `Mediator` 宿主上首次发送 request,量化 source-generated concrete path 的 cold-start 成本。
|
||||
/// </summary>
|
||||
/// <returns>当前 request 的响应结果。</returns>
|
||||
[Benchmark]
|
||||
[BenchmarkCategory("ColdStart")]
|
||||
public async ValueTask<BenchmarkResponse> ColdStart_Mediator()
|
||||
{
|
||||
using var serviceProvider = CreateMediatorServiceProvider();
|
||||
var mediator = serviceProvider.GetRequiredService<GeneratedMediator>();
|
||||
return await mediator.Send(Request, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建只承载当前 benchmark request 的最小 GFramework.CQRS runtime。
|
||||
/// </summary>
|
||||
@ -170,6 +203,14 @@ public class RequestStartupBenchmarks
|
||||
ServiceLifetime.Transient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建只承载当前 benchmark request 的最小 `Mediator` 对照宿主。
|
||||
/// </summary>
|
||||
private static ServiceProvider CreateMediatorServiceProvider()
|
||||
{
|
||||
return BenchmarkHostFactory.CreateMediatorServiceProvider(configure: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为 benchmark 创建稳定的 fatal 级 logger,避免把日志成本混入 startup 测量。
|
||||
/// </summary>
|
||||
@ -188,6 +229,7 @@ public class RequestStartupBenchmarks
|
||||
/// <param name="Id">请求标识。</param>
|
||||
public sealed record BenchmarkRequest(Guid Id) :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IRequest<BenchmarkResponse>,
|
||||
Mediator.IRequest<BenchmarkResponse>,
|
||||
MediatR.IRequest<BenchmarkResponse>;
|
||||
|
||||
/// <summary>
|
||||
@ -197,10 +239,11 @@ public class RequestStartupBenchmarks
|
||||
public sealed record BenchmarkResponse(Guid Id);
|
||||
|
||||
/// <summary>
|
||||
/// 同时实现 GFramework.CQRS 与 MediatR 契约的最小 request handler。
|
||||
/// 同时实现 GFramework.CQRS、NuGet `Mediator` 与 MediatR 契约的最小 request handler。
|
||||
/// </summary>
|
||||
public sealed class BenchmarkRequestHandler :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IRequestHandler<BenchmarkRequest, BenchmarkResponse>,
|
||||
Mediator.IRequestHandler<BenchmarkRequest, BenchmarkResponse>,
|
||||
MediatR.IRequestHandler<BenchmarkRequest, BenchmarkResponse>
|
||||
{
|
||||
/// <summary>
|
||||
@ -211,6 +254,16 @@ public class RequestStartupBenchmarks
|
||||
return ValueTask.FromResult(new BenchmarkResponse(request.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理 NuGet `Mediator` request。
|
||||
/// </summary>
|
||||
ValueTask<BenchmarkResponse> Mediator.IRequestHandler<BenchmarkRequest, BenchmarkResponse>.Handle(
|
||||
BenchmarkRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Handle(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理 MediatR request。
|
||||
/// </summary>
|
||||
|
||||
515
GFramework.Cqrs.Benchmarks/Messaging/ScopedBenchmarkContainer.cs
Normal file
515
GFramework.Cqrs.Benchmarks/Messaging/ScopedBenchmarkContainer.cs
Normal file
@ -0,0 +1,515 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GFramework.Core.Abstractions.Bases;
|
||||
using GFramework.Core.Abstractions.Ioc;
|
||||
using GFramework.Core.Abstractions.Rule;
|
||||
using GFramework.Core.Abstractions.Systems;
|
||||
using GFramework.Core.Ioc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GFramework.Cqrs.Benchmarks.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// 把冻结后的 benchmark 根容器适配成可重复进入的 request 级解析视图。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// `CqrsDispatcher` 会直接依赖 <see cref="IIocContainer" /> 做 handler / pipeline 解析,
|
||||
/// 因此 request lifetime benchmark 需要一个既保留根容器注册元数据,又能在每次 benchmark 调用时把实例解析切换到
|
||||
/// 显式作用域 provider 的最小适配层。该类型只覆盖 benchmark 当前 request 路径会使用到的解析相关入口;
|
||||
/// 任何注册、清空或冻结修改操作都应继续发生在根容器构建阶段,因此这里统一拒绝可变更 API。
|
||||
/// </remarks>
|
||||
internal sealed class ScopedBenchmarkContainer : IIocContainer
|
||||
{
|
||||
private readonly MicrosoftDiContainer _rootContainer;
|
||||
private IServiceScope? _activeScope;
|
||||
private IServiceProvider? _scopedProvider;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化一个绑定到单个 request 作用域的 benchmark 容器适配器。
|
||||
/// </summary>
|
||||
/// <param name="rootContainer">已冻结的 benchmark 根容器。</param>
|
||||
internal ScopedBenchmarkContainer(MicrosoftDiContainer rootContainer)
|
||||
{
|
||||
_rootContainer = rootContainer ?? throw new ArgumentNullException(nameof(rootContainer));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为当前 benchmark 调用创建并持有一个新的 request 级作用域。
|
||||
/// </summary>
|
||||
/// <returns>离开作用域时负责释放本次 request 级作用域的租约。</returns>
|
||||
/// <exception cref="InvalidOperationException">当前适配器仍持有上一次尚未释放的作用域。</exception>
|
||||
internal ScopeLease EnterScope()
|
||||
{
|
||||
if (_activeScope is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Scoped benchmark containers do not support overlapping active scopes.");
|
||||
}
|
||||
|
||||
_activeScope = _rootContainer.CreateScope();
|
||||
_scopedProvider = _activeScope.ServiceProvider;
|
||||
return new ScopeLease(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持在 request 作用域内追加注册。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">实例类型。</typeparam>
|
||||
/// <param name="instance">原本要注册到根容器中的单例实例。</param>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void RegisterSingleton<T>(T instance)
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持在 request 作用域内追加注册。
|
||||
/// </summary>
|
||||
/// <typeparam name="TService">服务契约类型。</typeparam>
|
||||
/// <typeparam name="TImpl">服务实现类型。</typeparam>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void RegisterSingleton<TService, TImpl>()
|
||||
where TImpl : class, TService
|
||||
where TService : class
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持在 request 作用域内追加注册。
|
||||
/// </summary>
|
||||
/// <typeparam name="TService">服务契约类型。</typeparam>
|
||||
/// <typeparam name="TImpl">服务实现类型。</typeparam>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void RegisterTransient<TService, TImpl>()
|
||||
where TImpl : class, TService
|
||||
where TService : class
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持在 request 作用域内追加注册。
|
||||
/// </summary>
|
||||
/// <typeparam name="TService">服务契约类型。</typeparam>
|
||||
/// <typeparam name="TImpl">服务实现类型。</typeparam>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void RegisterScoped<TService, TImpl>()
|
||||
where TImpl : class, TService
|
||||
where TService : class
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持在 request 作用域内追加注册。
|
||||
/// </summary>
|
||||
/// <param name="instance">原本要附加到复数注册集合中的实例。</param>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void RegisterPlurality(object instance)
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持在 request 作用域内追加注册。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">复数注册项类型。</typeparam>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void RegisterPlurality<T>() where T : class
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持在 request 作用域内追加注册。
|
||||
/// </summary>
|
||||
/// <param name="system">原本要注册到容器中的系统实例。</param>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void RegisterSystem(ISystem system)
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持在 request 作用域内追加注册。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">实例类型。</typeparam>
|
||||
/// <param name="instance">原本要注册到容器中的实例。</param>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void Register<T>(T instance)
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持在 request 作用域内追加注册。
|
||||
/// </summary>
|
||||
/// <param name="type">原本要绑定的服务类型。</param>
|
||||
/// <param name="instance">原本要绑定到该类型的实例。</param>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void Register(Type type, object instance)
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持在 request 作用域内追加注册。
|
||||
/// </summary>
|
||||
/// <typeparam name="TService">工厂要创建的服务类型。</typeparam>
|
||||
/// <param name="factory">原本要注册的工厂委托。</param>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void RegisterFactory<TService>(Func<IServiceProvider, TService> factory) where TService : class
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持在 request 作用域内追加注册。
|
||||
/// </summary>
|
||||
/// <typeparam name="TBehavior">原本要注册的 request pipeline 行为类型。</typeparam>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void RegisterCqrsPipelineBehavior<TBehavior>() where TBehavior : class
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持在 request 作用域内追加注册。
|
||||
/// </summary>
|
||||
/// <typeparam name="TBehavior">原本要注册的 stream pipeline 行为类型。</typeparam>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void RegisterCqrsStreamPipelineBehavior<TBehavior>() where TBehavior : class
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持在 request 作用域内追加注册。
|
||||
/// </summary>
|
||||
/// <param name="assembly">原本要扫描 CQRS handler 的程序集。</param>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void RegisterCqrsHandlersFromAssembly(System.Reflection.Assembly assembly)
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持在 request 作用域内追加注册。
|
||||
/// </summary>
|
||||
/// <param name="assemblies">原本要扫描 CQRS handler 的程序集集合。</param>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void RegisterCqrsHandlersFromAssemblies(IEnumerable<System.Reflection.Assembly> assemblies)
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持执行额外的服务配置钩子。
|
||||
/// </summary>
|
||||
/// <param name="configurator">原本要执行的服务配置委托。</param>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void ExecuteServicesHook(Action<IServiceCollection>? configurator = null)
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从当前 request 作用域解析单个服务实例。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">目标服务类型。</typeparam>
|
||||
/// <returns>解析到的服务实例;若当前作用域未注册则返回 <see langword="null"/>。</returns>
|
||||
/// <exception cref="InvalidOperationException">调用方尚未通过 <see cref="EnterScope"/> 激活作用域。</exception>
|
||||
public T? Get<T>() where T : class
|
||||
{
|
||||
return GetScopedProvider().GetService<T>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从当前 request 作用域解析单个服务实例。
|
||||
/// </summary>
|
||||
/// <param name="type">目标服务类型。</param>
|
||||
/// <returns>解析到的服务实例;若当前作用域未注册则返回 <see langword="null"/>。</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="type"/> 为 <see langword="null"/>。</exception>
|
||||
/// <exception cref="InvalidOperationException">调用方尚未通过 <see cref="EnterScope"/> 激活作用域。</exception>
|
||||
public object? Get(Type type)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(type);
|
||||
return GetScopedProvider().GetService(type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从当前 request 作用域解析必需的单个服务实例。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">目标服务类型。</typeparam>
|
||||
/// <returns>解析到的服务实例。</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// 调用方尚未通过 <see cref="EnterScope"/> 激活作用域,或当前作用域缺少必需服务。
|
||||
/// </exception>
|
||||
public T GetRequired<T>() where T : class
|
||||
{
|
||||
return GetScopedProvider().GetRequiredService<T>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从当前 request 作用域解析必需的单个服务实例。
|
||||
/// </summary>
|
||||
/// <param name="type">目标服务类型。</param>
|
||||
/// <returns>解析到的服务实例。</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="type"/> 为 <see langword="null"/>。</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// 调用方尚未通过 <see cref="EnterScope"/> 激活作用域,或当前作用域缺少必需服务。
|
||||
/// </exception>
|
||||
public object GetRequired(Type type)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(type);
|
||||
return GetScopedProvider().GetRequiredService(type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从当前 request 作用域解析全部服务实例。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">目标服务类型。</typeparam>
|
||||
/// <returns>当前作用域中该服务类型的全部实例。</returns>
|
||||
/// <exception cref="InvalidOperationException">调用方尚未通过 <see cref="EnterScope"/> 激活作用域。</exception>
|
||||
public IReadOnlyList<T> GetAll<T>() where T : class
|
||||
{
|
||||
return GetScopedProvider().GetServices<T>().ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从当前 request 作用域解析全部服务实例。
|
||||
/// </summary>
|
||||
/// <param name="type">目标服务类型。</param>
|
||||
/// <returns>当前作用域中该服务类型的全部实例。</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="type"/> 为 <see langword="null"/>。</exception>
|
||||
/// <exception cref="InvalidOperationException">调用方尚未通过 <see cref="EnterScope"/> 激活作用域。</exception>
|
||||
public IReadOnlyList<object> GetAll(Type type)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(type);
|
||||
return GetScopedProvider().GetServices(type).Where(static service => service is not null).Cast<object>().ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从当前 request 作用域解析全部服务实例,并按调用方比较器排序。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">目标服务类型。</typeparam>
|
||||
/// <param name="comparison">用于排序的比较器。</param>
|
||||
/// <returns>按比较器排序后的服务列表。</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="comparison"/> 为 <see langword="null"/>。</exception>
|
||||
/// <exception cref="InvalidOperationException">调用方尚未通过 <see cref="EnterScope"/> 激活作用域。</exception>
|
||||
public IReadOnlyList<T> GetAllSorted<T>(Comparison<T> comparison) where T : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(comparison);
|
||||
|
||||
var services = GetAll<T>().ToList();
|
||||
services.Sort(comparison);
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从当前 request 作用域解析全部服务实例,并按优先级排序。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">目标服务类型。</typeparam>
|
||||
/// <returns>按优先级稳定排序后的服务列表。</returns>
|
||||
/// <exception cref="InvalidOperationException">调用方尚未通过 <see cref="EnterScope"/> 激活作用域。</exception>
|
||||
public IReadOnlyList<T> GetAllByPriority<T>() where T : class
|
||||
{
|
||||
return SortByPriority(GetAll<T>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从当前 request 作用域解析全部服务实例,并按优先级排序。
|
||||
/// </summary>
|
||||
/// <param name="type">目标服务类型。</param>
|
||||
/// <returns>按优先级稳定排序后的服务列表。</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="type"/> 为 <see langword="null"/>。</exception>
|
||||
/// <exception cref="InvalidOperationException">调用方尚未通过 <see cref="EnterScope"/> 激活作用域。</exception>
|
||||
public IReadOnlyList<object> GetAllByPriority(Type type)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(type);
|
||||
return SortByPriority(GetAll(type));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断根容器是否声明了目标服务键。
|
||||
/// </summary>
|
||||
/// <param name="type">目标服务类型。</param>
|
||||
/// <returns>根容器中声明了该服务键时返回 <see langword="true"/>。</returns>
|
||||
/// <remarks>
|
||||
/// `CqrsDispatcher` 在热路径上先做注册存在性判断,再决定是否枚举 pipeline;这里沿用根容器冻结后的注册视图,
|
||||
/// 避免把“当前 scope 还未物化实例”误判成“没有注册该行为”。
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="type"/> 为 <see langword="null"/>。</exception>
|
||||
public bool HasRegistration(Type type)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(type);
|
||||
return _rootContainer.HasRegistration(type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断根容器是否声明了目标服务键。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">目标服务类型。</typeparam>
|
||||
/// <returns>根容器中声明了该服务键时返回 <see langword="true"/>。</returns>
|
||||
public bool Contains<T>() where T : class
|
||||
{
|
||||
return _rootContainer.Contains<T>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前 request 作用域适配器不追踪实例归属。
|
||||
/// </summary>
|
||||
/// <param name="instance">待检查的实例。</param>
|
||||
/// <returns>若根容器已追踪该实例,则返回根容器的检查结果。</returns>
|
||||
public bool ContainsInstance(object instance)
|
||||
{
|
||||
return _rootContainer.ContainsInstance(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持清空注册。
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void Clear()
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前适配器不支持重新冻结。
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">当前适配器始终为只读视图。</exception>
|
||||
public void Freeze()
|
||||
{
|
||||
throw CreateMutationNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 继续暴露根容器底层服务集合,仅用于接口兼容。
|
||||
/// </summary>
|
||||
/// <returns>根容器当前持有的底层服务集合。</returns>
|
||||
public IServiceCollection GetServicesUnsafe => _rootContainer.GetServicesUnsafe;
|
||||
|
||||
/// <summary>
|
||||
/// 基于当前 request 作用域继续创建嵌套作用域。
|
||||
/// </summary>
|
||||
/// <returns>从当前激活作用域继续派生出的子作用域。</returns>
|
||||
/// <exception cref="InvalidOperationException">调用方尚未通过 <see cref="EnterScope"/> 激活作用域。</exception>
|
||||
public IServiceScope CreateScope()
|
||||
{
|
||||
return GetScopedProvider().CreateScope();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将上下文转发给根容器,保持与 request 生命周期无关的上下文缓存行为一致。
|
||||
/// </summary>
|
||||
/// <param name="context">要绑定到根容器的架构上下文。</param>
|
||||
public void SetContext(GFramework.Core.Abstractions.Architectures.IArchitectureContext context)
|
||||
{
|
||||
((IContextAware)_rootContainer).SetContext(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取根容器当前持有的架构上下文。
|
||||
/// </summary>
|
||||
/// <returns>根容器当前保存的架构上下文。</returns>
|
||||
public GFramework.Core.Abstractions.Architectures.IArchitectureContext GetContext()
|
||||
{
|
||||
return ((IContextAware)_rootContainer).GetContext();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放当前 request 适配器时,同时兜底释放任何尚未归还的激活作用域。
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
ReleaseActiveScope();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取当前激活的 request 级作用域服务提供器。
|
||||
/// </summary>
|
||||
/// <returns>当前作用域对应的服务提供器。</returns>
|
||||
/// <exception cref="InvalidOperationException">调用方尚未通过 <see cref="EnterScope"/> 激活作用域。</exception>
|
||||
private IServiceProvider GetScopedProvider()
|
||||
{
|
||||
return _scopedProvider ?? throw new InvalidOperationException(
|
||||
"Scoped benchmark containers require an active scope. Call EnterScope() before resolving scoped services.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放当前激活的 request 级作用域,并清空解析视图。
|
||||
/// </summary>
|
||||
private void ReleaseActiveScope()
|
||||
{
|
||||
_scopedProvider = null;
|
||||
|
||||
var activeScope = _activeScope;
|
||||
_activeScope = null;
|
||||
activeScope?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成统一的只读适配器异常,避免 benchmark 误把 request 级容器当成可变组合根。
|
||||
/// </summary>
|
||||
/// <returns>描述当前适配器只读语义的统一异常。</returns>
|
||||
private static InvalidOperationException CreateMutationNotSupportedException()
|
||||
{
|
||||
return new InvalidOperationException(
|
||||
"Scoped benchmark containers are read-only request views. Mutate registrations on the root benchmark host before freezing it.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复用与根容器一致的优先级排序语义。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">服务实例类型。</typeparam>
|
||||
/// <param name="services">待排序服务集合。</param>
|
||||
/// <returns>按优先级稳定排序后的服务列表。</returns>
|
||||
private static IReadOnlyList<T> SortByPriority<T>(IReadOnlyList<T> services) where T : class
|
||||
{
|
||||
if (services.Count <= 1)
|
||||
{
|
||||
return services;
|
||||
}
|
||||
|
||||
return services
|
||||
.Select((service, index) => new { Service = service, Index = index })
|
||||
.OrderBy(static x =>
|
||||
{
|
||||
var priority = x.Service is IPrioritized prioritized ? prioritized.Priority : 0;
|
||||
return (priority, x.Index);
|
||||
})
|
||||
.Select(static x => x.Service)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示一次激活中的 request 级作用域租约。
|
||||
/// </summary>
|
||||
internal readonly struct ScopeLease : IDisposable
|
||||
{
|
||||
private readonly ScopedBenchmarkContainer _owner;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化一个绑定到目标适配器的作用域租约。
|
||||
/// </summary>
|
||||
/// <param name="owner">拥有当前作用域的 benchmark 容器适配器。</param>
|
||||
internal ScopeLease(ScopedBenchmarkContainer owner)
|
||||
{
|
||||
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放当前 request 级作用域。
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_owner.ReleaseActiveScope();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -24,8 +24,13 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
namespace GFramework.Cqrs.Benchmarks.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// 对比 stream 完整枚举在 direct handler、GFramework 反射路径、GFramework generated invoker 路径与 MediatR 之间的开销差异。
|
||||
/// 对比 stream invoker 在 direct handler、GFramework 反射路径、GFramework generated invoker 路径与 MediatR 之间的开销差异。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该矩阵只保留单一 handler 生命周期,避免把 invoker 路径差异与生命周期解析成本混在一起。
|
||||
/// <see cref="StreamObservation.FirstItem" /> 用于近似观察建流到首个元素的瞬时成本,
|
||||
/// <see cref="StreamObservation.DrainAll" /> 则保留原有完整枚举口径。
|
||||
/// </remarks>
|
||||
[Config(typeof(Config))]
|
||||
public class StreamInvokerBenchmarks
|
||||
{
|
||||
@ -40,6 +45,28 @@ public class StreamInvokerBenchmarks
|
||||
private GeneratedBenchmarkStreamRequest _generatedRequest = null!;
|
||||
private MediatRBenchmarkStreamRequest _mediatrRequest = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 控制当前 benchmark 观察“只推进首个元素”还是“完整枚举整个 stream”。
|
||||
/// </summary>
|
||||
[Params(StreamObservation.FirstItem, StreamObservation.DrainAll)]
|
||||
public StreamObservation Observation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用于拆分 stream invoker 固定成本与后续枚举成本的观测模式。
|
||||
/// </summary>
|
||||
public enum StreamObservation
|
||||
{
|
||||
/// <summary>
|
||||
/// 只推进到首个元素后立即释放枚举器。
|
||||
/// </summary>
|
||||
FirstItem,
|
||||
|
||||
/// <summary>
|
||||
/// 完整枚举整个 stream,保留原有 benchmark 语义。
|
||||
/// </summary>
|
||||
DrainAll
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置 stream invoker benchmark 的公共输出格式。
|
||||
/// </summary>
|
||||
@ -114,50 +141,110 @@ public class StreamInvokerBenchmarks
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 直接调用最小 stream handler 并完整枚举,作为 dispatch 额外开销 baseline。
|
||||
/// 直接调用最小 stream handler,并按当前观测模式消费 stream,作为 dispatch 额外开销 baseline。
|
||||
/// </summary>
|
||||
/// <returns>代表基线 stream 按当前观测模式消费完成的值任务。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
public async ValueTask Stream_Baseline()
|
||||
public ValueTask Stream_Baseline()
|
||||
{
|
||||
await foreach (var response in _baselineHandler.Handle(_reflectionRequest, CancellationToken.None).ConfigureAwait(false))
|
||||
return ObserveAsync(_baselineHandler.Handle(_reflectionRequest, CancellationToken.None), Observation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 GFramework.CQRS 反射 stream binding 路径创建 stream,并按当前观测模式消费。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 GFramework.CQRS 反射 stream 按观测模式消费完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask Stream_GFrameworkReflection()
|
||||
{
|
||||
return ObserveAsync(
|
||||
_reflectionRuntime.CreateStream(
|
||||
BenchmarkContext.Instance,
|
||||
_reflectionRequest,
|
||||
CancellationToken.None),
|
||||
Observation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 generated stream invoker provider 预热后的 GFramework.CQRS runtime 创建 stream,并按当前观测模式消费。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 GFramework.CQRS generated stream 按观测模式消费完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask Stream_GFrameworkGenerated()
|
||||
{
|
||||
return ObserveAsync(
|
||||
_generatedRuntime.CreateStream(
|
||||
BenchmarkContext.Instance,
|
||||
_generatedRequest,
|
||||
CancellationToken.None),
|
||||
Observation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 MediatR 创建 stream,并按当前观测模式消费,作为外部对照。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 MediatR stream 按观测模式消费完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask Stream_MediatR()
|
||||
{
|
||||
return ObserveAsync(_mediatr.CreateStream(_mediatrRequest, CancellationToken.None), Observation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按观测模式消费 stream,便于把“建流/首个元素”和“完整枚举”分开观察。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
|
||||
/// <param name="responses">待观察的异步响应序列。</param>
|
||||
/// <param name="observation">当前 benchmark 选定的观测模式。</param>
|
||||
/// <returns>异步消费完成后的等待句柄。</returns>
|
||||
private static ValueTask ObserveAsync<TResponse>(
|
||||
IAsyncEnumerable<TResponse> responses,
|
||||
StreamObservation observation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(responses);
|
||||
|
||||
return observation switch
|
||||
{
|
||||
_ = response;
|
||||
StreamObservation.FirstItem => ConsumeFirstItemAsync(responses, CancellationToken.None),
|
||||
StreamObservation.DrainAll => DrainAsync(responses),
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(observation),
|
||||
observation,
|
||||
"Unsupported stream observation mode.")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 只推进到首个元素后立即释放枚举器,用来近似隔离建流与首个 <c>MoveNextAsync</c> 的固定成本。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
|
||||
/// <param name="responses">待观察的异步响应序列。</param>
|
||||
/// <param name="cancellationToken">用于向异步枚举器传播取消的令牌。</param>
|
||||
/// <returns>消费首个元素后的等待句柄。</returns>
|
||||
private static async ValueTask ConsumeFirstItemAsync<TResponse>(
|
||||
IAsyncEnumerable<TResponse> responses,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var enumerator = responses.GetAsyncEnumerator(cancellationToken);
|
||||
await using (enumerator.ConfigureAwait(false))
|
||||
{
|
||||
// 这里显式读取 Current,只为了让所有路径都完成首个元素的同等消费。
|
||||
if (await enumerator.MoveNextAsync().ConfigureAwait(false))
|
||||
{
|
||||
_ = enumerator.Current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 GFramework.CQRS 反射 stream binding 路径创建并完整枚举 stream。
|
||||
/// 完整枚举整个 stream,保留原 benchmark 的总成本观测口径。
|
||||
/// </summary>
|
||||
[Benchmark]
|
||||
public async ValueTask Stream_GFrameworkReflection()
|
||||
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
|
||||
/// <param name="responses">待完整枚举的异步响应序列。</param>
|
||||
/// <returns>完整枚举结束后的等待句柄。</returns>
|
||||
private static async ValueTask DrainAsync<TResponse>(IAsyncEnumerable<TResponse> responses)
|
||||
{
|
||||
await foreach (var response in _reflectionRuntime.CreateStream(BenchmarkContext.Instance, _reflectionRequest, CancellationToken.None)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
_ = response;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 generated stream invoker provider 预热后的 GFramework.CQRS runtime 创建并完整枚举 stream。
|
||||
/// </summary>
|
||||
[Benchmark]
|
||||
public async ValueTask Stream_GFrameworkGenerated()
|
||||
{
|
||||
await foreach (var response in _generatedRuntime.CreateStream(BenchmarkContext.Instance, _generatedRequest, CancellationToken.None)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
_ = response;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 MediatR 创建并完整枚举 stream,作为外部对照。
|
||||
/// </summary>
|
||||
[Benchmark]
|
||||
public async ValueTask Stream_MediatR()
|
||||
{
|
||||
await foreach (var response in _mediatr.CreateStream(_mediatrRequest, CancellationToken.None).ConfigureAwait(false))
|
||||
await foreach (var response in responses.ConfigureAwait(false))
|
||||
{
|
||||
_ = response;
|
||||
}
|
||||
@ -214,6 +301,9 @@ public class StreamInvokerBenchmarks
|
||||
/// <summary>
|
||||
/// 处理 reflection benchmark stream request。
|
||||
/// </summary>
|
||||
/// <param name="request">当前 reflection benchmark stream 请求。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>完整枚举所需的低噪声异步响应序列。</returns>
|
||||
public IAsyncEnumerable<ReflectionBenchmarkResponse> Handle(
|
||||
ReflectionBenchmarkStreamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
@ -235,6 +325,9 @@ public class StreamInvokerBenchmarks
|
||||
/// <summary>
|
||||
/// 处理 generated benchmark stream request。
|
||||
/// </summary>
|
||||
/// <param name="request">当前 generated benchmark stream 请求。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>完整枚举所需的低噪声异步响应序列。</returns>
|
||||
public IAsyncEnumerable<GeneratedBenchmarkResponse> Handle(
|
||||
GeneratedBenchmarkStreamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
@ -256,6 +349,9 @@ public class StreamInvokerBenchmarks
|
||||
/// <summary>
|
||||
/// 处理 MediatR benchmark stream request。
|
||||
/// </summary>
|
||||
/// <param name="request">当前 MediatR benchmark stream 请求。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>完整枚举所需的低噪声异步响应序列。</returns>
|
||||
public IAsyncEnumerable<MediatRBenchmarkResponse> Handle(
|
||||
MediatRBenchmarkStreamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
@ -271,6 +367,12 @@ public class StreamInvokerBenchmarks
|
||||
/// <summary>
|
||||
/// 为三组 stream benchmark 构造相同形状的低噪声异步枚举,避免枚举体差异干扰 invoker 对照。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
|
||||
/// <param name="id">每个响应复用的稳定标识。</param>
|
||||
/// <param name="itemCount">待返回的响应元素数量。</param>
|
||||
/// <param name="responseFactory">将稳定标识映射为响应对象的工厂。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>供各对照路径共享的低噪声异步响应序列。</returns>
|
||||
private static async IAsyncEnumerable<TResponse> EnumerateAsync<TResponse>(
|
||||
Guid id,
|
||||
int itemCount,
|
||||
|
||||
@ -9,6 +9,7 @@ using BenchmarkDotNet.Jobs;
|
||||
using BenchmarkDotNet.Order;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
@ -21,29 +22,48 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
namespace GFramework.Cqrs.Benchmarks.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// 对比 stream 完整枚举在不同 handler 生命周期下的额外开销。
|
||||
/// 对比 stream 在不同 handler 生命周期与观测方式下的额外开销。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 当前矩阵只覆盖 `Singleton` 与 `Transient`。
|
||||
/// `Scoped` 仍依赖真实的显式作用域边界;在当前“单根容器最小宿主”模型下直接加入 scoped 会把枚举宿主成本与生命周期成本混在一起,
|
||||
/// 因此保持与 request 生命周期矩阵相同的边界,留待后续 scoped host 基线具备后再扩展。
|
||||
/// 当前矩阵覆盖 `Singleton`、`Scoped` 与 `Transient`。
|
||||
/// 其中 `Scoped` 会在每次建流与枚举期间显式创建并持有真实的 DI 作用域,
|
||||
/// 避免把 scoped handler 错误地下沉到根容器解析,或在异步枚举尚未结束时提前释放作用域。
|
||||
/// <see cref="StreamObservation" /> 当前只保留 <see cref="StreamObservation.FirstItem" /> 与
|
||||
/// <see cref="StreamObservation.DrainAll" /> 两种模式,分别用于观察建流到首个元素的固定成本与完整枚举的总成本,
|
||||
/// 以避免把更多观测策略与 <see cref="StreamLifetimeBenchmarks" /> 的生命周期对照目标混在一起。
|
||||
/// </remarks>
|
||||
[Config(typeof(Config))]
|
||||
public class StreamLifetimeBenchmarks
|
||||
{
|
||||
private MicrosoftDiContainer _container = null!;
|
||||
private ICqrsRuntime _runtime = null!;
|
||||
private MicrosoftDiContainer _reflectionContainer = null!;
|
||||
private ICqrsRuntime _reflectionRuntime = null!;
|
||||
private ScopedBenchmarkContainer? _scopedReflectionContainer;
|
||||
private ICqrsRuntime? _scopedReflectionRuntime;
|
||||
private MicrosoftDiContainer _generatedContainer = null!;
|
||||
private ICqrsRuntime _generatedRuntime = null!;
|
||||
private ScopedBenchmarkContainer? _scopedGeneratedContainer;
|
||||
private ICqrsRuntime? _scopedGeneratedRuntime;
|
||||
private ServiceProvider _serviceProvider = null!;
|
||||
private IMediator _mediatr = null!;
|
||||
private BenchmarkStreamHandler _baselineHandler = null!;
|
||||
private BenchmarkStreamRequest _request = null!;
|
||||
private ReflectionBenchmarkStreamHandler _baselineHandler = null!;
|
||||
private ReflectionBenchmarkStreamRequest _reflectionRequest = null!;
|
||||
private GeneratedBenchmarkStreamRequest _generatedRequest = null!;
|
||||
private MediatRBenchmarkStreamRequest _mediatrRequest = null!;
|
||||
private ILogger _reflectionRuntimeLogger = null!;
|
||||
private ILogger _generatedRuntimeLogger = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 控制当前 benchmark 使用的 handler 生命周期。
|
||||
/// </summary>
|
||||
[Params(HandlerLifetime.Singleton, HandlerLifetime.Transient)]
|
||||
[Params(HandlerLifetime.Singleton, HandlerLifetime.Scoped, HandlerLifetime.Transient)]
|
||||
public HandlerLifetime Lifetime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 控制当前 benchmark 观察“只推进首个元素”还是“完整枚举整个 stream”。
|
||||
/// </summary>
|
||||
[Params(StreamObservation.FirstItem, StreamObservation.DrainAll)]
|
||||
public StreamObservation Observation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可公平比较的 benchmark handler 生命周期集合。
|
||||
/// </summary>
|
||||
@ -54,12 +74,33 @@ public class StreamLifetimeBenchmarks
|
||||
/// </summary>
|
||||
Singleton,
|
||||
|
||||
/// <summary>
|
||||
/// 每次建流在显式作用域内解析并复用 handler 实例,且作用域会覆盖整个枚举周期。
|
||||
/// </summary>
|
||||
Scoped,
|
||||
|
||||
/// <summary>
|
||||
/// 每次建流都重新解析新的 handler 实例。
|
||||
/// </summary>
|
||||
Transient
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于拆分 stream dispatch 与后续枚举成本的观测模式。
|
||||
/// </summary>
|
||||
public enum StreamObservation
|
||||
{
|
||||
/// <summary>
|
||||
/// 只推进到首个元素后立即释放枚举器。
|
||||
/// </summary>
|
||||
FirstItem,
|
||||
|
||||
/// <summary>
|
||||
/// 完整枚举整个 stream,保留原有 benchmark 语义。
|
||||
/// </summary>
|
||||
DrainAll
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置 stream 生命周期 benchmark 的公共输出格式。
|
||||
/// </summary>
|
||||
@ -76,38 +117,16 @@ public class StreamLifetimeBenchmarks
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建当前生命周期下的 GFramework 与 MediatR stream 对照宿主。
|
||||
/// 构建当前生命周期下的 GFramework reflection、GFramework generated 与 MediatR stream 对照宿主。
|
||||
/// </summary>
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider
|
||||
{
|
||||
MinLevel = LogLevel.Fatal
|
||||
};
|
||||
Fixture.Setup($"StreamLifetime/{Lifetime}", handlerCount: 1, pipelineCount: 0);
|
||||
BenchmarkDispatcherCacheHelper.ClearDispatcherCaches();
|
||||
|
||||
_baselineHandler = new BenchmarkStreamHandler();
|
||||
_request = new BenchmarkStreamRequest(Guid.NewGuid(), 3);
|
||||
|
||||
_container = BenchmarkHostFactory.CreateFrozenGFrameworkContainer(container =>
|
||||
{
|
||||
BenchmarkHostFactory.RegisterGeneratedBenchmarkRegistry<GeneratedStreamLifetimeBenchmarkRegistry>(container);
|
||||
RegisterGFrameworkHandler(container, Lifetime);
|
||||
});
|
||||
// 容器内已提前保留默认 runtime 以支撑 generated registry 接线;
|
||||
// 这里额外创建带生命周期后缀的 runtime,只是为了区分不同 benchmark 矩阵的 dispatcher 日志。
|
||||
_runtime = GFramework.Cqrs.CqrsRuntimeFactory.CreateRuntime(
|
||||
_container,
|
||||
LoggerFactoryResolver.Provider.CreateLogger(nameof(StreamLifetimeBenchmarks) + "." + Lifetime));
|
||||
|
||||
_serviceProvider = BenchmarkHostFactory.CreateMediatRServiceProvider(
|
||||
configure: null,
|
||||
typeof(StreamLifetimeBenchmarks),
|
||||
static candidateType => candidateType == typeof(BenchmarkStreamHandler),
|
||||
ResolveMediatRLifetime(Lifetime));
|
||||
_mediatr = _serviceProvider.GetRequiredService<IMediator>();
|
||||
ConfigureBenchmarkInfrastructure();
|
||||
InitializeRequestsAndLoggers();
|
||||
InitializeReflectionRuntime();
|
||||
InitializeGeneratedRuntime();
|
||||
InitializeMediatRRuntime();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -118,7 +137,7 @@ public class StreamLifetimeBenchmarks
|
||||
{
|
||||
try
|
||||
{
|
||||
BenchmarkCleanupHelper.DisposeAll(_container, _serviceProvider);
|
||||
BenchmarkCleanupHelper.DisposeAll(_reflectionContainer, _generatedContainer, _serviceProvider);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@ -127,52 +146,95 @@ public class StreamLifetimeBenchmarks
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 直接调用 handler 并完整枚举,作为不同生命周期矩阵下的 dispatch 额外开销 baseline。
|
||||
/// 直接调用 handler,并按当前观测模式消费 stream,作为不同生命周期矩阵下的 dispatch 额外开销 baseline。
|
||||
/// </summary>
|
||||
/// <returns>代表基线 handler stream 按当前观测模式消费完成的值任务。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
public async ValueTask Stream_Baseline()
|
||||
public ValueTask Stream_Baseline()
|
||||
{
|
||||
await foreach (var response in _baselineHandler.Handle(_request, CancellationToken.None).ConfigureAwait(false))
|
||||
{
|
||||
_ = response;
|
||||
}
|
||||
return ObserveAsync(_baselineHandler.Handle(_reflectionRequest, CancellationToken.None), Observation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 GFramework.CQRS runtime 创建并完整枚举 stream。
|
||||
/// 通过 GFramework.CQRS reflection stream binding 路径创建 stream,并按当前观测模式消费。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 reflection stream 按当前观测模式消费完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public async ValueTask Stream_GFrameworkCqrs()
|
||||
public ValueTask Stream_GFrameworkReflection()
|
||||
{
|
||||
await foreach (var response in _runtime.CreateStream(BenchmarkContext.Instance, _request, CancellationToken.None)
|
||||
.ConfigureAwait(false))
|
||||
if (Lifetime == HandlerLifetime.Scoped)
|
||||
{
|
||||
_ = response;
|
||||
return ObserveAsync(
|
||||
BenchmarkHostFactory.CreateScopedGFrameworkStream(
|
||||
_scopedReflectionRuntime!,
|
||||
_scopedReflectionContainer!,
|
||||
BenchmarkContext.Instance,
|
||||
_reflectionRequest,
|
||||
CancellationToken.None),
|
||||
Observation);
|
||||
}
|
||||
|
||||
return ObserveAsync(
|
||||
_reflectionRuntime.CreateStream(
|
||||
BenchmarkContext.Instance,
|
||||
_reflectionRequest,
|
||||
CancellationToken.None),
|
||||
Observation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 MediatR 创建并完整枚举 stream,作为外部对照。
|
||||
/// 通过 generated stream invoker provider 预热后的 GFramework.CQRS runtime 创建 stream,并按当前观测模式消费。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 generated stream 按当前观测模式消费完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public async ValueTask Stream_MediatR()
|
||||
public ValueTask Stream_GFrameworkGenerated()
|
||||
{
|
||||
await foreach (var response in _mediatr.CreateStream(_request, CancellationToken.None).ConfigureAwait(false))
|
||||
if (Lifetime == HandlerLifetime.Scoped)
|
||||
{
|
||||
_ = response;
|
||||
return ObserveAsync(
|
||||
BenchmarkHostFactory.CreateScopedGFrameworkStream(
|
||||
_scopedGeneratedRuntime!,
|
||||
_scopedGeneratedContainer!,
|
||||
BenchmarkContext.Instance,
|
||||
_generatedRequest,
|
||||
CancellationToken.None),
|
||||
Observation);
|
||||
}
|
||||
|
||||
return ObserveAsync(
|
||||
_generatedRuntime.CreateStream(
|
||||
BenchmarkContext.Instance,
|
||||
_generatedRequest,
|
||||
CancellationToken.None),
|
||||
Observation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按生命周期把 benchmark stream handler 注册到 GFramework 容器。
|
||||
/// 通过 MediatR 创建 stream,并按当前观测模式消费,作为外部对照。
|
||||
/// </summary>
|
||||
/// <returns>代表当前 MediatR stream 按当前观测模式消费完成的值任务。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask Stream_MediatR()
|
||||
{
|
||||
if (Lifetime == HandlerLifetime.Scoped)
|
||||
{
|
||||
return ObserveAsync(
|
||||
BenchmarkHostFactory.CreateScopedMediatRStream(
|
||||
_serviceProvider,
|
||||
_mediatrRequest,
|
||||
CancellationToken.None),
|
||||
Observation);
|
||||
}
|
||||
|
||||
return ObserveAsync(_mediatr.CreateStream(_mediatrRequest, CancellationToken.None), Observation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按生命周期把 reflection benchmark stream handler 注册到 GFramework 容器。
|
||||
/// </summary>
|
||||
/// <param name="container">当前 benchmark 拥有并负责释放的容器。</param>
|
||||
/// <param name="lifetime">待比较的 handler 生命周期。</param>
|
||||
/// <remarks>
|
||||
/// 先通过 generated registry 提供静态 descriptor,再显式覆盖 handler 生命周期,
|
||||
/// 可以把比较变量收敛到 handler 解析成本,而不是 descriptor 发现路径本身。
|
||||
/// </remarks>
|
||||
private static void RegisterGFrameworkHandler(MicrosoftDiContainer container, HandlerLifetime lifetime)
|
||||
private static void RegisterReflectionHandler(MicrosoftDiContainer container, HandlerLifetime lifetime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(container);
|
||||
|
||||
@ -180,14 +242,58 @@ public class StreamLifetimeBenchmarks
|
||||
{
|
||||
case HandlerLifetime.Singleton:
|
||||
container.RegisterSingleton<
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>,
|
||||
BenchmarkStreamHandler>();
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler<ReflectionBenchmarkStreamRequest, ReflectionBenchmarkResponse>,
|
||||
ReflectionBenchmarkStreamHandler>();
|
||||
return;
|
||||
|
||||
case HandlerLifetime.Scoped:
|
||||
container.RegisterScoped<
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler<ReflectionBenchmarkStreamRequest, ReflectionBenchmarkResponse>,
|
||||
ReflectionBenchmarkStreamHandler>();
|
||||
return;
|
||||
|
||||
case HandlerLifetime.Transient:
|
||||
container.RegisterTransient<
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>,
|
||||
BenchmarkStreamHandler>();
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler<ReflectionBenchmarkStreamRequest, ReflectionBenchmarkResponse>,
|
||||
ReflectionBenchmarkStreamHandler>();
|
||||
return;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(lifetime), lifetime, "Unsupported benchmark handler lifetime.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按生命周期把 generated benchmark stream handler 注册到 GFramework 容器。
|
||||
/// </summary>
|
||||
/// <param name="container">当前 benchmark 拥有并负责释放的容器。</param>
|
||||
/// <param name="lifetime">待比较的 handler 生命周期。</param>
|
||||
/// <remarks>
|
||||
/// generated registry 只负责暴露静态 descriptor;
|
||||
/// 生命周期矩阵仍由 benchmark 主体显式覆盖 handler 注册,避免把 descriptor 发现与实例解析混在一起。
|
||||
/// </remarks>
|
||||
private static void RegisterGeneratedHandler(MicrosoftDiContainer container, HandlerLifetime lifetime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(container);
|
||||
|
||||
switch (lifetime)
|
||||
{
|
||||
case HandlerLifetime.Singleton:
|
||||
container.RegisterSingleton<
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler<GeneratedBenchmarkStreamRequest, GeneratedBenchmarkResponse>,
|
||||
GeneratedBenchmarkStreamHandler>();
|
||||
return;
|
||||
|
||||
case HandlerLifetime.Scoped:
|
||||
container.RegisterScoped<
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler<GeneratedBenchmarkStreamRequest, GeneratedBenchmarkResponse>,
|
||||
GeneratedBenchmarkStreamHandler>();
|
||||
return;
|
||||
|
||||
case HandlerLifetime.Transient:
|
||||
container.RegisterTransient<
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler<GeneratedBenchmarkStreamRequest, GeneratedBenchmarkResponse>,
|
||||
GeneratedBenchmarkStreamHandler>();
|
||||
return;
|
||||
|
||||
default:
|
||||
@ -205,75 +311,295 @@ public class StreamLifetimeBenchmarks
|
||||
return lifetime switch
|
||||
{
|
||||
HandlerLifetime.Singleton => ServiceLifetime.Singleton,
|
||||
HandlerLifetime.Scoped => ServiceLifetime.Scoped,
|
||||
HandlerLifetime.Transient => ServiceLifetime.Transient,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(lifetime), lifetime, "Unsupported benchmark handler lifetime.")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark stream request。
|
||||
/// 初始化当前 benchmark 所需的全局日志与夹具基础设施。
|
||||
/// </summary>
|
||||
/// <param name="Id">请求标识。</param>
|
||||
/// <param name="ItemCount">返回元素数量。</param>
|
||||
public sealed record BenchmarkStreamRequest(Guid Id, int ItemCount) :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequest<BenchmarkResponse>,
|
||||
MediatR.IStreamRequest<BenchmarkResponse>;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark stream response。
|
||||
/// </summary>
|
||||
/// <param name="Id">响应标识。</param>
|
||||
public sealed record BenchmarkResponse(Guid Id);
|
||||
|
||||
/// <summary>
|
||||
/// 同时实现 GFramework.CQRS 与 MediatR 契约的最小 stream handler。
|
||||
/// </summary>
|
||||
public sealed class BenchmarkStreamHandler :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>,
|
||||
MediatR.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>
|
||||
private void ConfigureBenchmarkInfrastructure()
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理 GFramework.CQRS stream request。
|
||||
/// </summary>
|
||||
/// <param name="request">当前 benchmark stream 请求。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>完整枚举所需的低噪声异步响应序列。</returns>
|
||||
public IAsyncEnumerable<BenchmarkResponse> Handle(
|
||||
BenchmarkStreamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider
|
||||
{
|
||||
return EnumerateAsync(request, cancellationToken);
|
||||
MinLevel = LogLevel.Fatal
|
||||
};
|
||||
Fixture.Setup($"StreamLifetime/{Lifetime}", handlerCount: 1, pipelineCount: 0);
|
||||
BenchmarkDispatcherCacheHelper.ClearDispatcherCaches();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化当前 benchmark 会复用的请求对象、baseline handler 与日志器。
|
||||
/// </summary>
|
||||
private void InitializeRequestsAndLoggers()
|
||||
{
|
||||
_baselineHandler = new ReflectionBenchmarkStreamHandler();
|
||||
_reflectionRequest = new ReflectionBenchmarkStreamRequest(Guid.NewGuid(), 3);
|
||||
_generatedRequest = new GeneratedBenchmarkStreamRequest(Guid.NewGuid(), 3);
|
||||
_mediatrRequest = new MediatRBenchmarkStreamRequest(Guid.NewGuid(), 3);
|
||||
_reflectionRuntimeLogger =
|
||||
LoggerFactoryResolver.Provider.CreateLogger(nameof(StreamLifetimeBenchmarks) + ".Reflection." + Lifetime);
|
||||
_generatedRuntimeLogger =
|
||||
LoggerFactoryResolver.Provider.CreateLogger(nameof(StreamLifetimeBenchmarks) + ".Generated." + Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 reflection 路径的 GFramework runtime。
|
||||
/// </summary>
|
||||
private void InitializeReflectionRuntime()
|
||||
{
|
||||
_reflectionContainer = BenchmarkHostFactory.CreateFrozenGFrameworkContainer(container =>
|
||||
{
|
||||
RegisterReflectionHandler(container, Lifetime);
|
||||
});
|
||||
|
||||
if (Lifetime != HandlerLifetime.Scoped)
|
||||
{
|
||||
_reflectionRuntime = GFramework.Cqrs.CqrsRuntimeFactory.CreateRuntime(
|
||||
_reflectionContainer,
|
||||
_reflectionRuntimeLogger);
|
||||
return;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理 MediatR stream request。
|
||||
/// </summary>
|
||||
/// <param name="request">当前 benchmark stream 请求。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>完整枚举所需的低噪声异步响应序列。</returns>
|
||||
IAsyncEnumerable<BenchmarkResponse> MediatR.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>.Handle(
|
||||
BenchmarkStreamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
_scopedReflectionContainer = new ScopedBenchmarkContainer(_reflectionContainer);
|
||||
_scopedReflectionRuntime = GFramework.Cqrs.CqrsRuntimeFactory.CreateRuntime(
|
||||
_scopedReflectionContainer,
|
||||
_reflectionRuntimeLogger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 generated registry 路径的 GFramework runtime。
|
||||
/// </summary>
|
||||
private void InitializeGeneratedRuntime()
|
||||
{
|
||||
_generatedContainer = BenchmarkHostFactory.CreateFrozenGFrameworkContainer(container =>
|
||||
{
|
||||
return EnumerateAsync(request, cancellationToken);
|
||||
BenchmarkHostFactory.RegisterGeneratedBenchmarkRegistry<GeneratedStreamLifetimeBenchmarkRegistry>(container);
|
||||
RegisterGeneratedHandler(container, Lifetime);
|
||||
});
|
||||
|
||||
// 容器内已提前保留默认 runtime 以支撑 generated registry 接线;
|
||||
// 这里额外创建带生命周期后缀的 runtime,只是为了区分不同 benchmark 矩阵的 dispatcher 日志。
|
||||
if (Lifetime != HandlerLifetime.Scoped)
|
||||
{
|
||||
_generatedRuntime = GFramework.Cqrs.CqrsRuntimeFactory.CreateRuntime(
|
||||
_generatedContainer,
|
||||
_generatedRuntimeLogger);
|
||||
return;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为生命周期矩阵构造稳定、低噪声的异步响应序列。
|
||||
/// </summary>
|
||||
/// <param name="request">当前 benchmark 请求。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>按固定元素数量返回的异步响应序列。</returns>
|
||||
private static async IAsyncEnumerable<BenchmarkResponse> EnumerateAsync(
|
||||
BenchmarkStreamRequest request,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
_scopedGeneratedContainer = new ScopedBenchmarkContainer(_generatedContainer);
|
||||
_scopedGeneratedRuntime = GFramework.Cqrs.CqrsRuntimeFactory.CreateRuntime(
|
||||
_scopedGeneratedContainer,
|
||||
_generatedRuntimeLogger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 MediatR 对照宿主。
|
||||
/// </summary>
|
||||
private void InitializeMediatRRuntime()
|
||||
{
|
||||
_serviceProvider = BenchmarkHostFactory.CreateMediatRServiceProvider(
|
||||
configure: null,
|
||||
typeof(StreamLifetimeBenchmarks),
|
||||
static candidateType => candidateType == typeof(MediatRBenchmarkStreamHandler),
|
||||
ResolveMediatRLifetime(Lifetime));
|
||||
|
||||
if (Lifetime != HandlerLifetime.Scoped)
|
||||
{
|
||||
for (var index = 0; index < request.ItemCount; index++)
|
||||
_mediatr = _serviceProvider.GetRequiredService<IMediator>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按观测模式消费 stream,便于把“建流/首个元素”和“完整枚举”分开观察。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
|
||||
/// <param name="responses">待观察的异步响应序列。</param>
|
||||
/// <param name="observation">当前 benchmark 选定的观测模式。</param>
|
||||
/// <returns>异步消费完成后的等待句柄。</returns>
|
||||
private static ValueTask ObserveAsync<TResponse>(
|
||||
IAsyncEnumerable<TResponse> responses,
|
||||
StreamObservation observation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(responses);
|
||||
|
||||
return observation switch
|
||||
{
|
||||
StreamObservation.FirstItem => ConsumeFirstItemAsync(responses, CancellationToken.None),
|
||||
StreamObservation.DrainAll => DrainAsync(responses),
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(observation),
|
||||
observation,
|
||||
"Unsupported stream observation mode.")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 只推进到首个元素后立即释放枚举器,用来近似隔离建流与首个 `MoveNextAsync` 的固定成本。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
|
||||
/// <param name="responses">待观察的异步响应序列。</param>
|
||||
/// <param name="cancellationToken">用于向异步枚举器传播取消的令牌。</param>
|
||||
/// <returns>消费首个元素后的等待句柄。</returns>
|
||||
private static async ValueTask ConsumeFirstItemAsync<TResponse>(
|
||||
IAsyncEnumerable<TResponse> responses,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var enumerator = responses.GetAsyncEnumerator(cancellationToken);
|
||||
await using (enumerator.ConfigureAwait(false))
|
||||
{
|
||||
if (await enumerator.MoveNextAsync().ConfigureAwait(false))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return new BenchmarkResponse(request.Id);
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
_ = enumerator.Current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 完整枚举整个 stream,保留原 benchmark 的总成本观测口径。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
|
||||
/// <param name="responses">待完整枚举的异步响应序列。</param>
|
||||
/// <returns>完整枚举结束后的等待句柄。</returns>
|
||||
private static async ValueTask DrainAsync<TResponse>(IAsyncEnumerable<TResponse> responses)
|
||||
{
|
||||
await foreach (var response in responses.ConfigureAwait(false))
|
||||
{
|
||||
_ = response;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reflection runtime stream request。
|
||||
/// </summary>
|
||||
/// <param name="Id">请求标识。</param>
|
||||
/// <param name="ItemCount">返回元素数量。</param>
|
||||
public sealed record ReflectionBenchmarkStreamRequest(Guid Id, int ItemCount) :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequest<ReflectionBenchmarkResponse>;
|
||||
|
||||
/// <summary>
|
||||
/// Reflection runtime stream response。
|
||||
/// </summary>
|
||||
/// <param name="Id">响应标识。</param>
|
||||
public sealed record ReflectionBenchmarkResponse(Guid Id);
|
||||
|
||||
/// <summary>
|
||||
/// Generated runtime stream request。
|
||||
/// </summary>
|
||||
/// <param name="Id">请求标识。</param>
|
||||
/// <param name="ItemCount">返回元素数量。</param>
|
||||
public sealed record GeneratedBenchmarkStreamRequest(Guid Id, int ItemCount) :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequest<GeneratedBenchmarkResponse>;
|
||||
|
||||
/// <summary>
|
||||
/// Generated runtime stream response。
|
||||
/// </summary>
|
||||
/// <param name="Id">响应标识。</param>
|
||||
public sealed record GeneratedBenchmarkResponse(Guid Id);
|
||||
|
||||
/// <summary>
|
||||
/// MediatR stream request。
|
||||
/// </summary>
|
||||
/// <param name="Id">请求标识。</param>
|
||||
/// <param name="ItemCount">返回元素数量。</param>
|
||||
public sealed record MediatRBenchmarkStreamRequest(Guid Id, int ItemCount) :
|
||||
MediatR.IStreamRequest<MediatRBenchmarkResponse>;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR stream response。
|
||||
/// </summary>
|
||||
/// <param name="Id">响应标识。</param>
|
||||
public sealed record MediatRBenchmarkResponse(Guid Id);
|
||||
|
||||
/// <summary>
|
||||
/// Reflection runtime 的最小 stream request handler。
|
||||
/// </summary>
|
||||
public sealed class ReflectionBenchmarkStreamHandler :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler<ReflectionBenchmarkStreamRequest, ReflectionBenchmarkResponse>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理 reflection benchmark stream request。
|
||||
/// </summary>
|
||||
/// <param name="request">当前 reflection benchmark stream 请求。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>完整枚举所需的低噪声异步响应序列。</returns>
|
||||
public IAsyncEnumerable<ReflectionBenchmarkResponse> Handle(
|
||||
ReflectionBenchmarkStreamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return EnumerateAsync(
|
||||
request.Id,
|
||||
request.ItemCount,
|
||||
static id => new ReflectionBenchmarkResponse(id),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generated runtime 的最小 stream request handler。
|
||||
/// </summary>
|
||||
public sealed class GeneratedBenchmarkStreamHandler :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler<GeneratedBenchmarkStreamRequest, GeneratedBenchmarkResponse>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理 generated benchmark stream request。
|
||||
/// </summary>
|
||||
/// <param name="request">当前 generated benchmark stream 请求。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>完整枚举所需的低噪声异步响应序列。</returns>
|
||||
public IAsyncEnumerable<GeneratedBenchmarkResponse> Handle(
|
||||
GeneratedBenchmarkStreamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return EnumerateAsync(
|
||||
request.Id,
|
||||
request.ItemCount,
|
||||
static id => new GeneratedBenchmarkResponse(id),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MediatR 对照组的最小 stream request handler。
|
||||
/// </summary>
|
||||
public sealed class MediatRBenchmarkStreamHandler :
|
||||
MediatR.IStreamRequestHandler<MediatRBenchmarkStreamRequest, MediatRBenchmarkResponse>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理 MediatR benchmark stream request。
|
||||
/// </summary>
|
||||
/// <param name="request">当前 MediatR benchmark stream 请求。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>完整枚举所需的低噪声异步响应序列。</returns>
|
||||
public IAsyncEnumerable<MediatRBenchmarkResponse> Handle(
|
||||
MediatRBenchmarkStreamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return EnumerateAsync(
|
||||
request.Id,
|
||||
request.ItemCount,
|
||||
static id => new MediatRBenchmarkResponse(id),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为生命周期矩阵构造相同形状的低噪声异步枚举,避免不同口径的枚举体差异干扰 dispatch 对照。
|
||||
/// </summary>
|
||||
private static async IAsyncEnumerable<TResponse> EnumerateAsync<TResponse>(
|
||||
Guid id,
|
||||
int itemCount,
|
||||
Func<Guid, TResponse> responseFactory,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
for (var index = 0; index < itemCount; index++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return responseFactory(id);
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
527
GFramework.Cqrs.Benchmarks/Messaging/StreamPipelineBenchmarks.cs
Normal file
527
GFramework.Cqrs.Benchmarks/Messaging/StreamPipelineBenchmarks.cs
Normal file
@ -0,0 +1,527 @@
|
||||
// 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.StreamPipelineBenchmarks.GeneratedStreamPipelineBenchmarkRegistry))]
|
||||
|
||||
namespace GFramework.Cqrs.Benchmarks.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// 对比不同 stream pipeline 行为数量下,单个 stream request 在直接调用、GFramework.CQRS runtime 与 MediatR 之间的 steady-state dispatch 开销。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 当前矩阵同时覆盖 <c>0 / 1 / 4</c> 个 stream pipeline 行为,以及
|
||||
/// <see cref="StreamObservation.FirstItem" /> 与 <see cref="StreamObservation.DrainAll" /> 两种观测口径,
|
||||
/// 以便把建流固定成本与完整枚举成本拆开观察。
|
||||
/// </remarks>
|
||||
[Config(typeof(Config))]
|
||||
public class StreamPipelineBenchmarks
|
||||
{
|
||||
private MicrosoftDiContainer _container = null!;
|
||||
private ICqrsRuntime _runtime = null!;
|
||||
private ServiceProvider _serviceProvider = null!;
|
||||
private IMediator _mediatr = null!;
|
||||
private BenchmarkStreamHandler _baselineHandler = null!;
|
||||
private BenchmarkStreamRequest _request = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 控制当前场景注册的 stream pipeline 行为数量,保持与 request pipeline benchmark 相同的 <c>0 / 1 / 4</c> 矩阵。
|
||||
/// </summary>
|
||||
[Params(0, 1, 4)]
|
||||
public int PipelineCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 控制当前 benchmark 观察“只推进首个元素”还是“完整枚举整个 stream”。
|
||||
/// </summary>
|
||||
[Params(StreamObservation.FirstItem, StreamObservation.DrainAll)]
|
||||
public StreamObservation Observation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用于拆分 stream dispatch 固定成本与后续枚举成本的观测模式。
|
||||
/// </summary>
|
||||
public enum StreamObservation
|
||||
{
|
||||
/// <summary>
|
||||
/// 只推进到首个元素后立即释放枚举器。
|
||||
/// </summary>
|
||||
FirstItem,
|
||||
|
||||
/// <summary>
|
||||
/// 完整枚举整个 stream,保留原有 benchmark 语义。
|
||||
/// </summary>
|
||||
DrainAll
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置 stream pipeline benchmark 的公共输出格式。
|
||||
/// </summary>
|
||||
private sealed class Config : ManualConfig
|
||||
{
|
||||
public Config()
|
||||
{
|
||||
AddJob(Job.Default);
|
||||
AddColumnProvider(DefaultColumnProviders.Instance);
|
||||
AddColumn(new CustomColumn("Scenario", static (_, _) => "StreamPipeline"));
|
||||
AddDiagnoser(MemoryDiagnoser.Default);
|
||||
WithOrderer(new DefaultOrderer(SummaryOrderPolicy.FastestToSlowest, MethodOrderPolicy.Declared));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建 stream pipeline dispatch 所需的最小 runtime 宿主和对照对象。
|
||||
/// </summary>
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider
|
||||
{
|
||||
MinLevel = LogLevel.Fatal
|
||||
};
|
||||
Fixture.Setup("StreamPipeline", handlerCount: 1, pipelineCount: PipelineCount);
|
||||
BenchmarkDispatcherCacheHelper.ClearDispatcherCaches();
|
||||
|
||||
_baselineHandler = new BenchmarkStreamHandler();
|
||||
_container = BenchmarkHostFactory.CreateFrozenGFrameworkContainer(container =>
|
||||
{
|
||||
BenchmarkHostFactory.RegisterGeneratedBenchmarkRegistry<GeneratedStreamPipelineBenchmarkRegistry>(container);
|
||||
RegisterGFrameworkPipelineBehaviors(container, PipelineCount);
|
||||
});
|
||||
_runtime = GFramework.Cqrs.CqrsRuntimeFactory.CreateRuntime(
|
||||
_container,
|
||||
LoggerFactoryResolver.Provider.CreateLogger(nameof(StreamPipelineBenchmarks)));
|
||||
|
||||
_serviceProvider = BenchmarkHostFactory.CreateMediatRServiceProvider(
|
||||
services =>
|
||||
{
|
||||
RegisterMediatRStreamPipelineBehaviors(services, PipelineCount);
|
||||
},
|
||||
typeof(StreamPipelineBenchmarks),
|
||||
static candidateType =>
|
||||
candidateType == typeof(BenchmarkStreamHandler) ||
|
||||
candidateType == typeof(BenchmarkStreamPipelineBehavior1) ||
|
||||
candidateType == typeof(BenchmarkStreamPipelineBehavior2) ||
|
||||
candidateType == typeof(BenchmarkStreamPipelineBehavior3) ||
|
||||
candidateType == typeof(BenchmarkStreamPipelineBehavior4),
|
||||
ServiceLifetime.Singleton);
|
||||
_mediatr = _serviceProvider.GetRequiredService<IMediator>();
|
||||
|
||||
_request = new BenchmarkStreamRequest(Guid.NewGuid(), 3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放 MediatR 对照组使用的 DI 宿主。
|
||||
/// </summary>
|
||||
[GlobalCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
try
|
||||
{
|
||||
BenchmarkCleanupHelper.DisposeAll(_container, _serviceProvider);
|
||||
}
|
||||
finally
|
||||
{
|
||||
BenchmarkDispatcherCacheHelper.ClearDispatcherCaches();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 直接调用 handler,并按当前观测模式消费响应序列,作为 stream pipeline 编排之外的基线。
|
||||
/// </summary>
|
||||
/// <returns>按当前观测模式完成 stream 消费后的等待句柄。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
public ValueTask Stream_Baseline()
|
||||
{
|
||||
return ObserveAsync(_baselineHandler.Handle(_request, CancellationToken.None), Observation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 GFramework.CQRS runtime 创建 stream,并按当前矩阵配置执行 stream pipeline。
|
||||
/// </summary>
|
||||
/// <returns>按当前观测模式完成 stream 消费后的等待句柄。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask Stream_GFrameworkCqrs()
|
||||
{
|
||||
return ObserveAsync(
|
||||
_runtime.CreateStream(
|
||||
BenchmarkContext.Instance,
|
||||
_request,
|
||||
CancellationToken.None),
|
||||
Observation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 MediatR 创建 stream,并按当前矩阵配置执行 stream pipeline,作为外部设计对照。
|
||||
/// </summary>
|
||||
/// <returns>按当前观测模式完成 stream 消费后的等待句柄。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask Stream_MediatR()
|
||||
{
|
||||
return ObserveAsync(_mediatr.CreateStream(_request, CancellationToken.None), Observation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按指定数量向 GFramework.CQRS 宿主注册最小 no-op stream pipeline 行为。
|
||||
/// </summary>
|
||||
/// <param name="container">当前 benchmark 使用的容器。</param>
|
||||
/// <param name="pipelineCount">要注册的行为数量。</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">行为数量不在支持的矩阵内时抛出。</exception>
|
||||
private static void RegisterGFrameworkPipelineBehaviors(MicrosoftDiContainer container, int pipelineCount)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(container);
|
||||
|
||||
switch (pipelineCount)
|
||||
{
|
||||
case 0:
|
||||
return;
|
||||
case 1:
|
||||
container.RegisterCqrsStreamPipelineBehavior<BenchmarkStreamPipelineBehavior1>();
|
||||
return;
|
||||
case 4:
|
||||
container.RegisterCqrsStreamPipelineBehavior<BenchmarkStreamPipelineBehavior1>();
|
||||
container.RegisterCqrsStreamPipelineBehavior<BenchmarkStreamPipelineBehavior2>();
|
||||
container.RegisterCqrsStreamPipelineBehavior<BenchmarkStreamPipelineBehavior3>();
|
||||
container.RegisterCqrsStreamPipelineBehavior<BenchmarkStreamPipelineBehavior4>();
|
||||
return;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(pipelineCount), pipelineCount,
|
||||
"Only the 0/1/4 pipeline matrix is supported.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按指定数量向 MediatR 宿主注册最小 no-op stream pipeline 行为。
|
||||
/// </summary>
|
||||
/// <param name="services">当前 benchmark 使用的服务集合。</param>
|
||||
/// <param name="pipelineCount">要注册的行为数量。</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">行为数量不在支持的矩阵内时抛出。</exception>
|
||||
private static void RegisterMediatRStreamPipelineBehaviors(IServiceCollection services, int pipelineCount)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
switch (pipelineCount)
|
||||
{
|
||||
case 0:
|
||||
return;
|
||||
case 1:
|
||||
services.AddSingleton<MediatR.IStreamPipelineBehavior<BenchmarkStreamRequest, BenchmarkResponse>, BenchmarkStreamPipelineBehavior1>();
|
||||
return;
|
||||
case 4:
|
||||
services.AddSingleton<MediatR.IStreamPipelineBehavior<BenchmarkStreamRequest, BenchmarkResponse>, BenchmarkStreamPipelineBehavior1>();
|
||||
services.AddSingleton<MediatR.IStreamPipelineBehavior<BenchmarkStreamRequest, BenchmarkResponse>, BenchmarkStreamPipelineBehavior2>();
|
||||
services.AddSingleton<MediatR.IStreamPipelineBehavior<BenchmarkStreamRequest, BenchmarkResponse>, BenchmarkStreamPipelineBehavior3>();
|
||||
services.AddSingleton<MediatR.IStreamPipelineBehavior<BenchmarkStreamRequest, BenchmarkResponse>, BenchmarkStreamPipelineBehavior4>();
|
||||
return;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(pipelineCount), pipelineCount,
|
||||
"Only the 0/1/4 pipeline matrix is supported.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按观测模式消费 stream,便于把“建流/首个元素”和“完整枚举”分开观察。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
|
||||
/// <param name="responses">待观察的异步响应序列。</param>
|
||||
/// <param name="observation">当前 benchmark 选定的观测模式。</param>
|
||||
/// <returns>异步消费完成后的等待句柄。</returns>
|
||||
private static ValueTask ObserveAsync<TResponse>(
|
||||
IAsyncEnumerable<TResponse> responses,
|
||||
StreamObservation observation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(responses);
|
||||
|
||||
return observation switch
|
||||
{
|
||||
StreamObservation.FirstItem => ConsumeFirstItemAsync(responses, CancellationToken.None),
|
||||
StreamObservation.DrainAll => DrainAsync(responses),
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(observation),
|
||||
observation,
|
||||
"Unsupported stream observation mode.")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 只推进到首个元素后立即释放枚举器,用来近似隔离建流与首个 <c>MoveNextAsync</c> 的固定成本。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
|
||||
/// <param name="responses">待观察的异步响应序列。</param>
|
||||
/// <param name="cancellationToken">用于向异步枚举器传播取消的令牌。</param>
|
||||
/// <returns>消费首个元素后的等待句柄。</returns>
|
||||
private static async ValueTask ConsumeFirstItemAsync<TResponse>(
|
||||
IAsyncEnumerable<TResponse> responses,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var enumerator = responses.GetAsyncEnumerator(cancellationToken);
|
||||
await using (enumerator.ConfigureAwait(false))
|
||||
{
|
||||
if (await enumerator.MoveNextAsync().ConfigureAwait(false))
|
||||
{
|
||||
_ = enumerator.Current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 完整枚举整个 stream,保留原 benchmark 的总成本观测口径。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
|
||||
/// <param name="responses">待完整枚举的异步响应序列。</param>
|
||||
/// <returns>完整枚举结束后的等待句柄。</returns>
|
||||
private static async ValueTask DrainAsync<TResponse>(IAsyncEnumerable<TResponse> responses)
|
||||
{
|
||||
await foreach (var response in responses.ConfigureAwait(false))
|
||||
{
|
||||
_ = response;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark stream request。
|
||||
/// </summary>
|
||||
/// <param name="Id">请求标识。</param>
|
||||
/// <param name="ItemCount">返回元素数量。</param>
|
||||
public sealed record BenchmarkStreamRequest(Guid Id, int ItemCount) :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequest<BenchmarkResponse>,
|
||||
MediatR.IStreamRequest<BenchmarkResponse>;
|
||||
|
||||
/// <summary>
|
||||
/// 复用 stream benchmark 的响应结构,保持跨场景可比性。
|
||||
/// </summary>
|
||||
/// <param name="Id">响应标识。</param>
|
||||
public sealed record BenchmarkResponse(Guid Id);
|
||||
|
||||
/// <summary>
|
||||
/// 同时实现 GFramework.CQRS 与 MediatR 契约的最小 stream handler。
|
||||
/// </summary>
|
||||
public sealed class BenchmarkStreamHandler :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>,
|
||||
MediatR.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理 GFramework.CQRS stream request。
|
||||
/// </summary>
|
||||
/// <param name="request">当前 benchmark stream 请求。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>低噪声、可重复的异步响应序列。</returns>
|
||||
public IAsyncEnumerable<BenchmarkResponse> Handle(
|
||||
BenchmarkStreamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return EnumerateAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理 MediatR stream request。
|
||||
/// </summary>
|
||||
/// <param name="request">当前 benchmark stream 请求。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>低噪声、可重复的异步响应序列。</returns>
|
||||
IAsyncEnumerable<BenchmarkResponse> MediatR.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>.Handle(
|
||||
BenchmarkStreamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return EnumerateAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为 benchmark 构造稳定、低噪声的异步响应序列。
|
||||
/// </summary>
|
||||
/// <param name="request">决定元素数量和标识的 benchmark 请求。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>按请求数量生成的响应序列。</returns>
|
||||
private static async IAsyncEnumerable<BenchmarkResponse> EnumerateAsync(
|
||||
BenchmarkStreamRequest request,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
for (int index = 0; index < request.ItemCount; index++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return new BenchmarkResponse(request.Id);
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为 benchmark 提供统一的 no-op stream pipeline 行为实现,尽量把测量焦点保持在调度器与行为编排本身。
|
||||
/// </summary>
|
||||
public abstract class BenchmarkStreamPipelineBehaviorBase :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamPipelineBehavior<BenchmarkStreamRequest, BenchmarkResponse>,
|
||||
MediatR.IStreamPipelineBehavior<BenchmarkStreamRequest, BenchmarkResponse>
|
||||
{
|
||||
/// <summary>
|
||||
/// 透传 GFramework.CQRS stream pipeline,避免引入额外业务逻辑噪音。
|
||||
/// </summary>
|
||||
/// <param name="message">当前 benchmark stream 请求。</param>
|
||||
/// <param name="next">继续向下执行的 stream pipeline 委托。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>下游 handler 产出的异步响应序列。</returns>
|
||||
public IAsyncEnumerable<BenchmarkResponse> Handle(
|
||||
BenchmarkStreamRequest message,
|
||||
GFramework.Cqrs.Abstractions.Cqrs.StreamMessageHandlerDelegate<BenchmarkStreamRequest, BenchmarkResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return next(message, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 透传 MediatR stream pipeline,保持与 GFramework.CQRS 相同的 no-op 语义。
|
||||
/// </summary>
|
||||
/// <param name="request">当前 benchmark stream 请求。</param>
|
||||
/// <param name="next">继续向下执行的 MediatR stream pipeline 委托。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>下游 handler 产出的异步响应序列。</returns>
|
||||
IAsyncEnumerable<BenchmarkResponse> MediatR.IStreamPipelineBehavior<BenchmarkStreamRequest, BenchmarkResponse>.Handle(
|
||||
BenchmarkStreamRequest request,
|
||||
MediatR.StreamHandlerDelegate<BenchmarkResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_ = request;
|
||||
_ = cancellationToken;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// pipeline 矩阵中的第一个 no-op stream 行为。
|
||||
/// </summary>
|
||||
public sealed class BenchmarkStreamPipelineBehavior1 : BenchmarkStreamPipelineBehaviorBase
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// pipeline 矩阵中的第二个 no-op stream 行为。
|
||||
/// </summary>
|
||||
public sealed class BenchmarkStreamPipelineBehavior2 : BenchmarkStreamPipelineBehaviorBase
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// pipeline 矩阵中的第三个 no-op stream 行为。
|
||||
/// </summary>
|
||||
public sealed class BenchmarkStreamPipelineBehavior3 : BenchmarkStreamPipelineBehaviorBase
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// pipeline 矩阵中的第四个 no-op stream 行为。
|
||||
/// </summary>
|
||||
public sealed class BenchmarkStreamPipelineBehavior4 : BenchmarkStreamPipelineBehaviorBase
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为 stream pipeline benchmark 提供 handwritten generated registry,
|
||||
/// 让默认 pipeline 宿主也能走真实的 generated stream invoker provider 接线路径。
|
||||
/// </summary>
|
||||
public sealed class GeneratedStreamPipelineBenchmarkRegistry :
|
||||
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(GeneratedStreamPipelineBenchmarkRegistry).GetMethod(
|
||||
nameof(InvokeBenchmarkStreamHandler),
|
||||
BindingFlags.Public | BindingFlags.Static)
|
||||
?? throw new InvalidOperationException("Missing generated stream pipeline benchmark method."));
|
||||
|
||||
private static readonly IReadOnlyList<GFramework.Cqrs.CqrsStreamInvokerDescriptorEntry> Descriptors =
|
||||
[
|
||||
new GFramework.Cqrs.CqrsStreamInvokerDescriptorEntry(
|
||||
typeof(BenchmarkStreamRequest),
|
||||
typeof(BenchmarkResponse),
|
||||
Descriptor)
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// 把 stream pipeline benchmark handler 注册为单例,保持与当前矩阵宿主一致的生命周期语义。
|
||||
/// </summary>
|
||||
/// <param name="services">用于承载 generated handler 注册的服务集合。</param>
|
||||
/// <param name="logger">记录 generated registry 接线结果的日志器。</param>
|
||||
public void Register(IServiceCollection services, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
services.AddSingleton(
|
||||
typeof(GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>),
|
||||
typeof(BenchmarkStreamHandler));
|
||||
logger.Debug("Registered generated stream pipeline benchmark handler.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回当前 provider 暴露的全部 generated stream invoker 描述符。
|
||||
/// </summary>
|
||||
/// <returns>当前 benchmark 的 generated stream invoker 描述符集合。</returns>
|
||||
public IReadOnlyList<GFramework.Cqrs.CqrsStreamInvokerDescriptorEntry> GetDescriptors()
|
||||
{
|
||||
return Descriptors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为目标流式请求/响应类型对返回 generated stream invoker 描述符。
|
||||
/// </summary>
|
||||
/// <param name="requestType">要匹配的 stream 请求类型。</param>
|
||||
/// <param name="responseType">要匹配的 stream 响应类型。</param>
|
||||
/// <param name="descriptor">命中时返回的 generated stream invoker 描述符。</param>
|
||||
/// <returns>是否命中了当前 benchmark 的 stream 请求/响应类型对。</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 generated stream invoker provider 为 stream pipeline benchmark 产出的开放静态调用入口。
|
||||
/// </summary>
|
||||
/// <param name="handler">当前要调用的 stream handler 实例。</param>
|
||||
/// <param name="request">当前要分发的 stream 请求实例。</param>
|
||||
/// <param name="cancellationToken">用于向 handler 传播的取消令牌。</param>
|
||||
/// <returns>handler 产出的异步响应序列。</returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
482
GFramework.Cqrs.Benchmarks/Messaging/StreamStartupBenchmarks.cs
Normal file
482
GFramework.Cqrs.Benchmarks/Messaging/StreamStartupBenchmarks.cs
Normal file
@ -0,0 +1,482 @@
|
||||
// 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;
|
||||
using GeneratedMediator = Mediator.Mediator;
|
||||
|
||||
[assembly: GFramework.Cqrs.CqrsHandlerRegistryAttribute(
|
||||
typeof(GFramework.Cqrs.Benchmarks.Messaging.StreamStartupBenchmarks.GeneratedRegistry))]
|
||||
|
||||
namespace GFramework.Cqrs.Benchmarks.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// 对比 stream 宿主在 GFramework.CQRS reflection / generated、NuGet `Mediator` 与 MediatR 之间的初始化与首次建流命中成本。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该场景与 <see cref="RequestStartupBenchmarks" /> 保持相同的 `Initialization + ColdStart` 结构,
|
||||
/// 但 cold-start 边界改为“新宿主 + 首个元素命中”,因为 stream 的首个 <c>MoveNextAsync</c>
|
||||
/// 才会真正覆盖建流后的首次处理链路。
|
||||
/// </remarks>
|
||||
[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 ServiceProvider _mediatorServiceProvider = null!;
|
||||
private IMediator _mediatr = null!;
|
||||
private GeneratedMediator _mediator = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 配置 stream startup benchmark 的公共输出格式。
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建 startup benchmark 复用的 reflection / generated / `Mediator` / MediatR 宿主对象。
|
||||
/// </summary>
|
||||
[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<IMediator>();
|
||||
_mediatorServiceProvider = CreateMediatorServiceProvider();
|
||||
_mediator = _mediatorServiceProvider.GetRequiredService<GeneratedMediator>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在每次 cold-start 迭代前清空 dispatcher 静态缓存,确保首次绑定路径可重复观察。
|
||||
/// </summary>
|
||||
[IterationSetup]
|
||||
public void ResetColdStartCaches()
|
||||
{
|
||||
BenchmarkDispatcherCacheHelper.ClearDispatcherCaches();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放 startup benchmark 复用的宿主对象。
|
||||
/// </summary>
|
||||
[GlobalCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
BenchmarkCleanupHelper.DisposeAll(_reflectionContainer, _generatedContainer, _serviceProvider, _mediatorServiceProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回已构建宿主中的 MediatR mediator,作为 initialization 组的句柄解析 baseline。
|
||||
/// </summary>
|
||||
/// <returns>当前 benchmark 复用的 MediatR mediator。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
[BenchmarkCategory("Initialization")]
|
||||
public IMediator Initialization_MediatR()
|
||||
{
|
||||
return _mediatr;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回已构建宿主中的 GFramework.CQRS reflection runtime,观察默认 stream binding 宿主句柄解析成本。
|
||||
/// </summary>
|
||||
/// <returns>当前 benchmark 复用的 reflection CQRS runtime。</returns>
|
||||
[Benchmark]
|
||||
[BenchmarkCategory("Initialization")]
|
||||
public ICqrsRuntime Initialization_GFrameworkReflection()
|
||||
{
|
||||
return _reflectionRuntime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回已构建宿主中的 GFramework.CQRS generated runtime,观察 generated stream invoker 宿主句柄解析成本。
|
||||
/// </summary>
|
||||
/// <returns>当前 benchmark 复用的 generated CQRS runtime。</returns>
|
||||
[Benchmark]
|
||||
[BenchmarkCategory("Initialization")]
|
||||
public ICqrsRuntime Initialization_GFrameworkGenerated()
|
||||
{
|
||||
return _generatedRuntime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回已构建宿主中的 `Mediator` concrete mediator,作为 source-generated concrete path 的初始化句柄。
|
||||
/// </summary>
|
||||
/// <returns>当前 benchmark 复用的 `Mediator` concrete mediator。</returns>
|
||||
[Benchmark]
|
||||
[BenchmarkCategory("Initialization")]
|
||||
public GeneratedMediator Initialization_Mediator()
|
||||
{
|
||||
return _mediator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在新宿主上首次创建并推进 stream,作为 MediatR 的 cold-start baseline。
|
||||
/// </summary>
|
||||
/// <returns>首个 stream 响应元素。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
[BenchmarkCategory("ColdStart")]
|
||||
public async Task<BenchmarkResponse> ColdStart_MediatR()
|
||||
{
|
||||
using var serviceProvider = CreateMediatRServiceProvider();
|
||||
var mediator = serviceProvider.GetRequiredService<IMediator>();
|
||||
return await ConsumeFirstItemAsync(mediator.CreateStream(Request, CancellationToken.None), CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在新的 reflection runtime 上首次创建并推进 stream,量化默认 stream binding 的 first-hit 成本。
|
||||
/// </summary>
|
||||
/// <returns>首个 stream 响应元素。</returns>
|
||||
[Benchmark]
|
||||
[BenchmarkCategory("ColdStart")]
|
||||
public async ValueTask<BenchmarkResponse> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在新的 generated runtime 上首次创建并推进 stream,量化 generated stream invoker 路径的 first-hit 成本。
|
||||
/// </summary>
|
||||
/// <returns>首个 stream 响应元素。</returns>
|
||||
[Benchmark]
|
||||
[BenchmarkCategory("ColdStart")]
|
||||
public async ValueTask<BenchmarkResponse> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在新的 `Mediator` 宿主上首次创建并推进 stream,量化 source-generated concrete path 的 first-hit 成本。
|
||||
/// </summary>
|
||||
/// <returns>首个 stream 响应元素。</returns>
|
||||
[Benchmark]
|
||||
[BenchmarkCategory("ColdStart")]
|
||||
public async ValueTask<BenchmarkResponse> ColdStart_Mediator()
|
||||
{
|
||||
using var serviceProvider = CreateMediatorServiceProvider();
|
||||
var mediator = serviceProvider.GetRequiredService<GeneratedMediator>();
|
||||
return await ConsumeFirstItemAsync(mediator.CreateStream(Request, CancellationToken.None), CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建只承载当前 benchmark handler 的最小 reflection GFramework.CQRS 容器。
|
||||
/// </summary>
|
||||
private static MicrosoftDiContainer CreateReflectionContainer()
|
||||
{
|
||||
return BenchmarkHostFactory.CreateFrozenGFrameworkContainer(static container =>
|
||||
{
|
||||
container.RegisterTransient<
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>,
|
||||
BenchmarkStreamHandler>();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建只承载当前 benchmark generated registry 的最小 generated GFramework.CQRS 容器。
|
||||
/// </summary>
|
||||
private static MicrosoftDiContainer CreateGeneratedContainer()
|
||||
{
|
||||
return BenchmarkHostFactory.CreateFrozenGFrameworkContainer(static container =>
|
||||
{
|
||||
BenchmarkHostFactory.RegisterGeneratedBenchmarkRegistry<GeneratedRegistry>(container);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 基于已冻结的 benchmark 容器构建最小 GFramework.CQRS runtime。
|
||||
/// </summary>
|
||||
/// <param name="container">当前 benchmark 拥有并负责释放的容器。</param>
|
||||
/// <param name="logger">当前 runtime 使用的 benchmark logger。</param>
|
||||
private static ICqrsRuntime CreateRuntime(MicrosoftDiContainer container, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(container);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
return GFramework.Cqrs.CqrsRuntimeFactory.CreateRuntime(container, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建只承载当前 benchmark handler 的最小 MediatR 对照宿主。
|
||||
/// </summary>
|
||||
private static ServiceProvider CreateMediatRServiceProvider()
|
||||
{
|
||||
return BenchmarkHostFactory.CreateMediatRServiceProvider(
|
||||
configure: null,
|
||||
typeof(StreamStartupBenchmarks),
|
||||
static candidateType => candidateType == typeof(BenchmarkStreamHandler),
|
||||
ServiceLifetime.Transient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建只承载当前 benchmark handler 的最小 `Mediator` 对照宿主。
|
||||
/// </summary>
|
||||
private static ServiceProvider CreateMediatorServiceProvider()
|
||||
{
|
||||
return BenchmarkHostFactory.CreateMediatorServiceProvider(configure: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 推进 stream 到首个元素,并返回该元素作为 cold-start 结果。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
|
||||
/// <param name="responses">待推进的异步响应序列。</param>
|
||||
/// <param name="cancellationToken">用于向异步枚举器传播取消的令牌。</param>
|
||||
/// <returns>首个元素。</returns>
|
||||
/// <exception cref="InvalidOperationException">stream 未产生任何元素。</exception>
|
||||
private static async ValueTask<TResponse> ConsumeFirstItemAsync<TResponse>(
|
||||
IAsyncEnumerable<TResponse> 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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为 benchmark 创建稳定的 fatal 级 logger,避免把日志成本混入 startup 测量。
|
||||
/// </summary>
|
||||
private static ILogger CreateLogger(string categoryName)
|
||||
{
|
||||
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider
|
||||
{
|
||||
MinLevel = LogLevel.Fatal
|
||||
};
|
||||
return LoggerFactoryResolver.Provider.CreateLogger(categoryName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark stream request。
|
||||
/// </summary>
|
||||
/// <param name="Id">请求标识。</param>
|
||||
/// <param name="ItemCount">返回元素数量。</param>
|
||||
public sealed record BenchmarkStreamRequest(Guid Id, int ItemCount) :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequest<BenchmarkResponse>,
|
||||
Mediator.IStreamRequest<BenchmarkResponse>,
|
||||
MediatR.IStreamRequest<BenchmarkResponse>;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark stream response。
|
||||
/// </summary>
|
||||
/// <param name="Id">响应标识。</param>
|
||||
public sealed record BenchmarkResponse(Guid Id);
|
||||
|
||||
/// <summary>
|
||||
/// 同时实现 GFramework.CQRS、NuGet `Mediator` 与 MediatR 契约的最小 stream handler。
|
||||
/// </summary>
|
||||
public sealed class BenchmarkStreamHandler :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>,
|
||||
Mediator.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>,
|
||||
MediatR.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理 GFramework.CQRS stream request。
|
||||
/// </summary>
|
||||
/// <param name="request">当前 stream 请求。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>按请求元素数量延迟生成的异步响应序列。</returns>
|
||||
public IAsyncEnumerable<BenchmarkResponse> Handle(
|
||||
BenchmarkStreamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return EnumerateAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理 NuGet `Mediator` stream request。
|
||||
/// </summary>
|
||||
/// <param name="request">当前 stream 请求。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>按请求元素数量延迟生成的异步响应序列。</returns>
|
||||
IAsyncEnumerable<BenchmarkResponse> Mediator.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>.Handle(
|
||||
BenchmarkStreamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Handle(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理 MediatR stream request。
|
||||
/// </summary>
|
||||
/// <param name="request">当前 stream 请求。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>按请求元素数量延迟生成的异步响应序列。</returns>
|
||||
IAsyncEnumerable<BenchmarkResponse> MediatR.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>.Handle(
|
||||
BenchmarkStreamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return EnumerateAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成固定长度的 benchmark stream,确保 cold-start 与 steady-state 维度共用同一份响应形状。
|
||||
/// </summary>
|
||||
/// <param name="request">当前 stream 请求。</param>
|
||||
/// <param name="cancellationToken">用于向异步枚举器传播取消的令牌。</param>
|
||||
/// <returns>按请求数量生成的异步响应序列。</returns>
|
||||
private static async IAsyncEnumerable<BenchmarkResponse> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为 stream startup benchmark 提供 hand-written generated registry,
|
||||
/// 以便独立比较 generated stream invoker 的初始化与首次命中成本。
|
||||
/// </summary>
|
||||
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<GFramework.Cqrs.CqrsStreamInvokerDescriptorEntry> Descriptors =
|
||||
[
|
||||
new GFramework.Cqrs.CqrsStreamInvokerDescriptorEntry(
|
||||
typeof(BenchmarkStreamRequest),
|
||||
typeof(BenchmarkResponse),
|
||||
Descriptor)
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// 把 startup benchmark handler 注册为 transient,保持与 cold-start 对照宿主一致的 handler 生命周期。
|
||||
/// </summary>
|
||||
/// <param name="services">承载 generated handler 注册结果的目标服务集合。</param>
|
||||
/// <param name="logger">记录 generated registry 注册过程的日志器。</param>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回当前 provider 暴露的全部 generated stream invoker 描述符。
|
||||
/// </summary>
|
||||
/// <returns>当前 startup benchmark 的 generated stream invoker 描述符集合。</returns>
|
||||
public IReadOnlyList<GFramework.Cqrs.CqrsStreamInvokerDescriptorEntry> GetDescriptors()
|
||||
{
|
||||
return Descriptors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为目标流式请求/响应类型对返回 generated stream invoker 描述符。
|
||||
/// </summary>
|
||||
/// <param name="requestType">待匹配的 stream 请求类型。</param>
|
||||
/// <param name="responseType">待匹配的 stream 响应类型。</param>
|
||||
/// <param name="descriptor">匹配成功时返回的 generated stream invoker 描述符。</param>
|
||||
/// <returns>命中当前 benchmark 请求/响应类型对时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 generated stream invoker provider 为 startup benchmark 产出的开放静态调用入口。
|
||||
/// </summary>
|
||||
/// <param name="handler">当前 benchmark 注册的 stream handler 实例。</param>
|
||||
/// <param name="request">当前 benchmark 的 stream 请求对象。</param>
|
||||
/// <param name="cancellationToken">用于中断异步枚举的取消令牌。</param>
|
||||
/// <returns>由 handler 产生的异步响应序列。</returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,7 @@ using GFramework.Core.Logging;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using GeneratedMediator = Mediator.Mediator;
|
||||
|
||||
[assembly: GFramework.Cqrs.CqrsHandlerRegistryAttribute(
|
||||
typeof(GFramework.Cqrs.Benchmarks.Messaging.GeneratedDefaultStreamingBenchmarkRegistry))]
|
||||
@ -24,18 +25,47 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
namespace GFramework.Cqrs.Benchmarks.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// 对比单个 stream request 在直接调用、GFramework.CQRS runtime 与 MediatR 之间的完整枚举开销。
|
||||
/// 对比单个 stream request 在直接调用、GFramework.CQRS runtime、NuGet `Mediator` 与 MediatR 之间的 steady-state stream 开销。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 默认 generated-provider stream 宿主同时暴露 <see cref="StreamObservation.FirstItem" /> 与
|
||||
/// <see cref="StreamObservation.DrainAll" /> 两种观测口径,
|
||||
/// 以便把“建流到首个元素”的固定成本与“完整枚举整个 stream”的总成本拆开观察。
|
||||
/// </remarks>
|
||||
[Config(typeof(Config))]
|
||||
public class StreamingBenchmarks
|
||||
{
|
||||
private MicrosoftDiContainer _container = null!;
|
||||
private ICqrsRuntime _runtime = null!;
|
||||
private ServiceProvider _serviceProvider = null!;
|
||||
private ServiceProvider _mediatrServiceProvider = null!;
|
||||
private ServiceProvider _mediatorServiceProvider = null!;
|
||||
private IMediator _mediatr = null!;
|
||||
private GeneratedMediator _mediator = null!;
|
||||
private BenchmarkStreamHandler _baselineHandler = null!;
|
||||
private BenchmarkStreamRequest _request = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 控制当前 benchmark 观察“只推进首个元素”还是“完整枚举整个 stream”。
|
||||
/// </summary>
|
||||
[Params(StreamObservation.FirstItem, StreamObservation.DrainAll)]
|
||||
public StreamObservation Observation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用于拆分 stream dispatch 与后续枚举成本的观测模式。
|
||||
/// </summary>
|
||||
public enum StreamObservation
|
||||
{
|
||||
/// <summary>
|
||||
/// 只推进到首个元素后立即释放枚举器。
|
||||
/// </summary>
|
||||
FirstItem,
|
||||
|
||||
/// <summary>
|
||||
/// 完整枚举整个 stream,保留原有 benchmark 语义。
|
||||
/// </summary>
|
||||
DrainAll
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置 stream benchmark 的公共输出格式。
|
||||
/// </summary>
|
||||
@ -73,25 +103,28 @@ public class StreamingBenchmarks
|
||||
_container,
|
||||
LoggerFactoryResolver.Provider.CreateLogger(nameof(StreamingBenchmarks)));
|
||||
|
||||
_serviceProvider = BenchmarkHostFactory.CreateMediatRServiceProvider(
|
||||
_mediatrServiceProvider = BenchmarkHostFactory.CreateMediatRServiceProvider(
|
||||
configure: null,
|
||||
typeof(StreamingBenchmarks),
|
||||
static candidateType => candidateType == typeof(BenchmarkStreamHandler),
|
||||
ServiceLifetime.Singleton);
|
||||
_mediatr = _serviceProvider.GetRequiredService<IMediator>();
|
||||
_mediatr = _mediatrServiceProvider.GetRequiredService<IMediator>();
|
||||
|
||||
_mediatorServiceProvider = BenchmarkHostFactory.CreateMediatorServiceProvider(configure: null);
|
||||
_mediator = _mediatorServiceProvider.GetRequiredService<GeneratedMediator>();
|
||||
|
||||
_request = new BenchmarkStreamRequest(Guid.NewGuid(), 3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放 MediatR 对照组使用的 DI 宿主。
|
||||
/// 释放 MediatR 与 `Mediator` 对照组使用的 DI 宿主。
|
||||
/// </summary>
|
||||
[GlobalCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
try
|
||||
{
|
||||
BenchmarkCleanupHelper.DisposeAll(_container, _serviceProvider);
|
||||
BenchmarkCleanupHelper.DisposeAll(_container, _mediatrServiceProvider, _mediatorServiceProvider);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@ -100,37 +133,104 @@ public class StreamingBenchmarks
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 直接调用 handler 并完整枚举响应序列,作为 stream dispatch 额外开销的 baseline。
|
||||
/// 直接调用 handler,并按当前观测模式消费响应序列,作为 stream dispatch 额外开销的 baseline。
|
||||
/// </summary>
|
||||
/// <returns>按当前观测模式完成 stream 消费后的等待句柄。</returns>
|
||||
[Benchmark(Baseline = true)]
|
||||
public async ValueTask Stream_Baseline()
|
||||
public ValueTask Stream_Baseline()
|
||||
{
|
||||
await foreach (var response in _baselineHandler.Handle(_request, CancellationToken.None).ConfigureAwait(false))
|
||||
return ObserveAsync(_baselineHandler.Handle(_request, CancellationToken.None), Observation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 GFramework.CQRS runtime 创建 stream,并按当前观测模式消费。
|
||||
/// </summary>
|
||||
/// <returns>按当前观测模式完成 stream 消费后的等待句柄。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask Stream_GFrameworkCqrs()
|
||||
{
|
||||
return ObserveAsync(
|
||||
_runtime.CreateStream(
|
||||
BenchmarkContext.Instance,
|
||||
_request,
|
||||
CancellationToken.None),
|
||||
Observation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 MediatR 创建 stream,并按当前观测模式消费,作为外部设计对照。
|
||||
/// </summary>
|
||||
/// <returns>按当前观测模式完成 stream 消费后的等待句柄。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask Stream_MediatR()
|
||||
{
|
||||
return ObserveAsync(_mediatr.CreateStream(_request, CancellationToken.None), Observation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 `ai-libs/Mediator` 的 source-generated concrete mediator 创建 stream,并按当前观测模式消费。
|
||||
/// </summary>
|
||||
/// <returns>按当前观测模式完成 stream 消费后的等待句柄。</returns>
|
||||
[Benchmark]
|
||||
public ValueTask Stream_Mediator()
|
||||
{
|
||||
return ObserveAsync(_mediator.CreateStream(_request, CancellationToken.None), Observation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按观测模式消费 stream,便于把“建流/首个元素”和“完整枚举”分开观察。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
|
||||
/// <param name="responses">待观察的异步响应序列。</param>
|
||||
/// <param name="observation">当前 benchmark 选定的观测模式。</param>
|
||||
/// <returns>异步消费完成后的等待句柄。</returns>
|
||||
private static ValueTask ObserveAsync<TResponse>(
|
||||
IAsyncEnumerable<TResponse> responses,
|
||||
StreamObservation observation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(responses);
|
||||
|
||||
return observation switch
|
||||
{
|
||||
_ = response;
|
||||
StreamObservation.FirstItem => ConsumeFirstItemAsync(responses, CancellationToken.None),
|
||||
StreamObservation.DrainAll => DrainAsync(responses),
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(observation),
|
||||
observation,
|
||||
"Unsupported stream observation mode.")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 只推进到首个元素后立即释放枚举器,用来近似隔离建流与首个 `MoveNextAsync` 的固定成本。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
|
||||
/// <param name="responses">待观察的异步响应序列。</param>
|
||||
/// <param name="cancellationToken">用于向异步枚举器传播取消的令牌。</param>
|
||||
/// <returns>消费首个元素后的等待句柄。</returns>
|
||||
private static async ValueTask ConsumeFirstItemAsync<TResponse>(
|
||||
IAsyncEnumerable<TResponse> responses,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var enumerator = responses.GetAsyncEnumerator(cancellationToken);
|
||||
await using (enumerator.ConfigureAwait(false))
|
||||
{
|
||||
if (await enumerator.MoveNextAsync().ConfigureAwait(false))
|
||||
{
|
||||
_ = enumerator.Current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 GFramework.CQRS runtime 创建并完整枚举 stream。
|
||||
/// 完整枚举整个 stream,保留原 benchmark 的总成本观测口径。
|
||||
/// </summary>
|
||||
[Benchmark]
|
||||
public async ValueTask Stream_GFrameworkCqrs()
|
||||
/// <typeparam name="TResponse">当前 stream 的响应类型。</typeparam>
|
||||
/// <param name="responses">待完整枚举的异步响应序列。</param>
|
||||
/// <returns>完整枚举结束后的等待句柄。</returns>
|
||||
private static async ValueTask DrainAsync<TResponse>(IAsyncEnumerable<TResponse> responses)
|
||||
{
|
||||
await foreach (var response in _runtime.CreateStream(BenchmarkContext.Instance, _request, CancellationToken.None)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
_ = response;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 MediatR 创建并完整枚举 stream,作为外部设计对照。
|
||||
/// </summary>
|
||||
[Benchmark]
|
||||
public async ValueTask Stream_MediatR()
|
||||
{
|
||||
await foreach (var response in _mediatr.CreateStream(_request, CancellationToken.None).ConfigureAwait(false))
|
||||
await foreach (var response in responses.ConfigureAwait(false))
|
||||
{
|
||||
_ = response;
|
||||
}
|
||||
@ -143,6 +243,7 @@ public class StreamingBenchmarks
|
||||
/// <param name="ItemCount">返回元素数量。</param>
|
||||
public sealed record BenchmarkStreamRequest(Guid Id, int ItemCount) :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequest<BenchmarkResponse>,
|
||||
Mediator.IStreamRequest<BenchmarkResponse>,
|
||||
MediatR.IStreamRequest<BenchmarkResponse>;
|
||||
|
||||
/// <summary>
|
||||
@ -152,10 +253,11 @@ public class StreamingBenchmarks
|
||||
public sealed record BenchmarkResponse(Guid Id);
|
||||
|
||||
/// <summary>
|
||||
/// 同时实现 GFramework.CQRS 与 MediatR 契约的最小 stream handler。
|
||||
/// 同时实现 GFramework.CQRS、NuGet `Mediator` 与 MediatR 契约的最小 stream handler。
|
||||
/// </summary>
|
||||
public sealed class BenchmarkStreamHandler :
|
||||
GFramework.Cqrs.Abstractions.Cqrs.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>,
|
||||
Mediator.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>,
|
||||
MediatR.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>
|
||||
{
|
||||
/// <summary>
|
||||
@ -168,6 +270,16 @@ public class StreamingBenchmarks
|
||||
return EnumerateAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理 NuGet `Mediator` stream request。
|
||||
/// </summary>
|
||||
IAsyncEnumerable<BenchmarkResponse> Mediator.IStreamRequestHandler<BenchmarkStreamRequest, BenchmarkResponse>.Handle(
|
||||
BenchmarkStreamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Handle(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理 MediatR stream request。
|
||||
/// </summary>
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using BenchmarkDotNet.Configs;
|
||||
using BenchmarkDotNet.Loggers;
|
||||
using BenchmarkDotNet.Running;
|
||||
|
||||
@ -11,13 +16,302 @@ namespace GFramework.Cqrs.Benchmarks;
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
private const string ArtifactsSuffixOption = "--artifacts-suffix";
|
||||
private const string ArtifactsSuffixEnvironmentVariable = "GFRAMEWORK_CQRS_BENCHMARK_ARTIFACTS_SUFFIX";
|
||||
private const string ArtifactsPathEnvironmentVariable = "GFRAMEWORK_CQRS_BENCHMARK_ARTIFACTS_PATH";
|
||||
private const string IsolatedHostEnvironmentVariable = "GFRAMEWORK_CQRS_BENCHMARK_ISOLATED_HOST";
|
||||
private const string DefaultArtifactsDirectoryName = "BenchmarkDotNet.Artifacts";
|
||||
private const string IsolatedHostDirectoryName = "host";
|
||||
|
||||
/// <summary>
|
||||
/// 运行当前程序集中的全部 benchmark。
|
||||
/// </summary>
|
||||
/// <param name="args">透传给 BenchmarkDotNet 的命令行参数。</param>
|
||||
/// <param name="args">仓库入口参数与透传给 BenchmarkDotNet 的命令行参数。</param>
|
||||
private static void Main(string[] args)
|
||||
{
|
||||
var invocation = ParseInvocation(args);
|
||||
|
||||
ConsoleLogger.Default.WriteLine("Running GFramework.Cqrs benchmarks");
|
||||
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
|
||||
|
||||
if (invocation.RequiresHostIsolation &&
|
||||
!string.Equals(
|
||||
Environment.GetEnvironmentVariable(IsolatedHostEnvironmentVariable),
|
||||
"1",
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
Environment.Exit(RunFromIsolatedHost(invocation, args));
|
||||
}
|
||||
|
||||
if (invocation.ArtifactsPath is null)
|
||||
{
|
||||
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(invocation.BenchmarkDotNetArguments);
|
||||
return;
|
||||
}
|
||||
|
||||
ConsoleLogger.Default.WriteLine(
|
||||
$"Using isolated BenchmarkDotNet artifacts path: {invocation.ArtifactsPath}");
|
||||
|
||||
BenchmarkSwitcher
|
||||
.FromAssembly(typeof(Program).Assembly)
|
||||
.Run(invocation.BenchmarkDotNetArguments, DefaultConfig.Instance.WithArtifactsPath(invocation.ArtifactsPath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析仓库自定义参数,并生成实际传递给 BenchmarkDotNet 的参数与隔离后的 artifacts 路径。
|
||||
/// </summary>
|
||||
/// <param name="args">当前进程收到的完整命令行参数。</param>
|
||||
/// <returns>入口解析后的 benchmark 调用选项。</returns>
|
||||
/// <exception cref="ArgumentException">自定义参数缺失值或包含非法路径片段时抛出。</exception>
|
||||
private static BenchmarkInvocation ParseInvocation(string[] args)
|
||||
{
|
||||
var benchmarkDotNetArguments = new List<string>(args.Length);
|
||||
string? commandLineSuffix = null;
|
||||
|
||||
for (var index = 0; index < args.Length; index++)
|
||||
{
|
||||
var argument = args[index];
|
||||
if (!string.Equals(argument, ArtifactsSuffixOption, StringComparison.Ordinal))
|
||||
{
|
||||
benchmarkDotNetArguments.Add(argument);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index == args.Length - 1)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The {ArtifactsSuffixOption} option requires a suffix value.",
|
||||
nameof(args));
|
||||
}
|
||||
|
||||
if (commandLineSuffix is not null)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The {ArtifactsSuffixOption} option can only be provided once.",
|
||||
nameof(args));
|
||||
}
|
||||
|
||||
// 剥离仓库自定义参数,避免将它误传给 BenchmarkDotNet 自身的命令行解析器。
|
||||
commandLineSuffix = args[++index];
|
||||
}
|
||||
|
||||
var artifactsPath = ResolveArtifactsPath(commandLineSuffix);
|
||||
return new BenchmarkInvocation(
|
||||
benchmarkDotNetArguments.ToArray(),
|
||||
commandLineSuffix,
|
||||
artifactsPath,
|
||||
artifactsPath is not null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将当前 benchmark 入口重启到独立的宿主工作目录,避免多个并发进程共享同一份 auto-generated build 目录。
|
||||
/// </summary>
|
||||
/// <param name="invocation">当前入口解析后的 benchmark 调用选项。</param>
|
||||
/// <param name="originalArgs">原始命令行参数,用于透传给隔离后的宿主进程。</param>
|
||||
/// <returns>隔离后宿主进程的退出码。</returns>
|
||||
private static int RunFromIsolatedHost(BenchmarkInvocation invocation, string[] originalArgs)
|
||||
{
|
||||
var artifactsPath = invocation.ArtifactsPath
|
||||
?? throw new ArgumentNullException(nameof(invocation), "An isolated benchmark host requires an artifacts path.");
|
||||
|
||||
var currentAssemblyPath = typeof(Program).Assembly.Location;
|
||||
var sourceHostDirectory = AppContext.BaseDirectory;
|
||||
var isolatedHostDirectory = Path.Combine(artifactsPath, IsolatedHostDirectoryName);
|
||||
|
||||
PrepareIsolatedHostDirectory(sourceHostDirectory, isolatedHostDirectory);
|
||||
|
||||
var isolatedAssemblyPath = Path.Combine(
|
||||
isolatedHostDirectory,
|
||||
Path.GetFileName(currentAssemblyPath));
|
||||
|
||||
var startInfo = new ProcessStartInfo("dotnet")
|
||||
{
|
||||
WorkingDirectory = isolatedHostDirectory,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add(isolatedAssemblyPath);
|
||||
foreach (var argument in originalArgs)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.Environment[IsolatedHostEnvironmentVariable] = "1";
|
||||
startInfo.Environment[ArtifactsPathEnvironmentVariable] = artifactsPath;
|
||||
|
||||
ConsoleLogger.Default.WriteLine(
|
||||
$"Launching isolated benchmark host in: {isolatedHostDirectory}");
|
||||
|
||||
using var process = Process.Start(startInfo) ??
|
||||
throw new InvalidOperationException("Failed to launch the isolated benchmark host process.");
|
||||
|
||||
process.WaitForExit();
|
||||
return process.ExitCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据命令行或环境变量中的 suffix 生成当前 benchmark 运行的独立 artifacts 目录。
|
||||
/// </summary>
|
||||
/// <param name="commandLineSuffix">命令行显式提供的 suffix。</param>
|
||||
/// <returns>隔离后的 artifacts 目录;若未提供 suffix,则返回 <see langword="null"/>。</returns>
|
||||
private static string? ResolveArtifactsPath(string? commandLineSuffix)
|
||||
{
|
||||
var explicitArtifactsPath = Environment.GetEnvironmentVariable(ArtifactsPathEnvironmentVariable);
|
||||
if (!string.IsNullOrWhiteSpace(explicitArtifactsPath))
|
||||
{
|
||||
return Path.GetFullPath(explicitArtifactsPath);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(commandLineSuffix))
|
||||
{
|
||||
var validatedCommandLineSuffix = ValidateArtifactsSuffix(
|
||||
commandLineSuffix,
|
||||
ArtifactsSuffixOption);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(DefaultArtifactsDirectoryName, validatedCommandLineSuffix));
|
||||
}
|
||||
|
||||
var environmentSuffix = Environment.GetEnvironmentVariable(ArtifactsSuffixEnvironmentVariable);
|
||||
if (string.IsNullOrWhiteSpace(environmentSuffix))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var validatedEnvironmentSuffix = ValidateArtifactsSuffix(
|
||||
environmentSuffix,
|
||||
ArtifactsSuffixEnvironmentVariable);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(DefaultArtifactsDirectoryName, validatedEnvironmentSuffix));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验自定义 suffix,避免路径穿越、分隔符注入或不可移植字符污染 BenchmarkDotNet 的输出目录。
|
||||
/// </summary>
|
||||
/// <param name="suffix">待校验的后缀值。</param>
|
||||
/// <param name="sourceName">后缀来源名称,用于错误提示。</param>
|
||||
/// <returns>可安全用于单级目录名的后缀。</returns>
|
||||
/// <exception cref="ArgumentException">当后缀为空或包含未允许字符时抛出。</exception>
|
||||
private static string ValidateArtifactsSuffix(string suffix, string sourceName)
|
||||
{
|
||||
var trimmedSuffix = suffix.Trim();
|
||||
if (trimmedSuffix.Length == 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The {sourceName} value must not be empty.",
|
||||
nameof(suffix));
|
||||
}
|
||||
|
||||
foreach (var character in trimmedSuffix)
|
||||
{
|
||||
if (char.IsAsciiLetterOrDigit(character) || character is '.' or '-' or '_')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new ArgumentException(
|
||||
$"The {sourceName} value '{trimmedSuffix}' contains unsupported characters. " +
|
||||
"Only ASCII letters, digits, '.', '-' and '_' are allowed.",
|
||||
nameof(suffix));
|
||||
}
|
||||
|
||||
return trimmedSuffix;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将当前 benchmark 宿主输出复制到独立目录,确保并发运行时的 auto-generated benchmark 项目不会写入同一路径。
|
||||
/// </summary>
|
||||
/// <param name="sourceHostDirectory">当前 benchmark 宿主输出目录。</param>
|
||||
/// <param name="isolatedHostDirectory">当前 suffix 对应的独立宿主目录。</param>
|
||||
private static void PrepareIsolatedHostDirectory(string sourceHostDirectory, string isolatedHostDirectory)
|
||||
{
|
||||
ValidateIsolatedHostDirectory(sourceHostDirectory, isolatedHostDirectory);
|
||||
Directory.CreateDirectory(isolatedHostDirectory);
|
||||
CopyDirectoryRecursively(sourceHostDirectory, isolatedHostDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 拒绝把隔离宿主目录放到当前宿主输出目录内部,避免递归复制把 `host/host/...` 无限扩张。
|
||||
/// </summary>
|
||||
/// <param name="sourceHostDirectory">当前 benchmark 宿主输出目录。</param>
|
||||
/// <param name="isolatedHostDirectory">目标隔离宿主目录。</param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// <paramref name="isolatedHostDirectory"/> 等于或位于 <paramref name="sourceHostDirectory"/> 之内。
|
||||
/// </exception>
|
||||
private static void ValidateIsolatedHostDirectory(string sourceHostDirectory, string isolatedHostDirectory)
|
||||
{
|
||||
var normalizedSourceDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sourceHostDirectory));
|
||||
var normalizedIsolatedHostDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(isolatedHostDirectory));
|
||||
|
||||
if (string.Equals(
|
||||
normalizedSourceDirectory,
|
||||
normalizedIsolatedHostDirectory,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The isolated benchmark host directory must differ from the current host output directory.");
|
||||
}
|
||||
|
||||
var relativePath = Path.GetRelativePath(normalizedSourceDirectory, normalizedIsolatedHostDirectory);
|
||||
if (IsCurrentDirectoryOrChild(relativePath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The isolated benchmark host directory '{normalizedIsolatedHostDirectory}' must not be nested inside the current host output directory '{normalizedSourceDirectory}'.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断一个相对路径是否仍指向当前目录或其子目录。
|
||||
/// </summary>
|
||||
/// <param name="relativePath">相对路径。</param>
|
||||
/// <returns>目标位于当前目录或其子目录时返回 <see langword="true"/>。</returns>
|
||||
private static bool IsCurrentDirectoryOrChild(string relativePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(relativePath) || string.Equals(relativePath, ".", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(relativePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !string.Equals(relativePath, "..", StringComparison.Ordinal) &&
|
||||
!relativePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) &&
|
||||
!relativePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 递归复制 benchmark 宿主输出目录,覆盖同名文件以支持同一 suffix 的重复运行。
|
||||
/// </summary>
|
||||
/// <param name="sourceDirectory">源目录。</param>
|
||||
/// <param name="destinationDirectory">目标目录。</param>
|
||||
private static void CopyDirectoryRecursively(string sourceDirectory, string destinationDirectory)
|
||||
{
|
||||
foreach (var directory in Directory.GetDirectories(sourceDirectory, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relativeDirectory = Path.GetRelativePath(sourceDirectory, directory);
|
||||
Directory.CreateDirectory(Path.Combine(destinationDirectory, relativeDirectory));
|
||||
}
|
||||
|
||||
foreach (var file in Directory.GetFiles(sourceDirectory, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relativeFile = Path.GetRelativePath(sourceDirectory, file);
|
||||
var destinationFile = Path.Combine(destinationDirectory, relativeFile);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!);
|
||||
File.Copy(file, destinationFile, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示一次 benchmark 入口调用在剥离仓库自定义参数后的最终配置。
|
||||
/// </summary>
|
||||
/// <param name="BenchmarkDotNetArguments">实际传递给 BenchmarkDotNet 的命令行参数。</param>
|
||||
/// <param name="ArtifactsSuffix">当前运行声明的隔离后缀;若未声明则为 <see langword="null"/>。</param>
|
||||
/// <param name="ArtifactsPath">本次运行的 artifacts 目录;若未隔离则为 <see langword="null"/>。</param>
|
||||
/// <param name="RequiresHostIsolation">本次运行是否需要重启到隔离宿主目录。</param>
|
||||
private readonly record struct BenchmarkInvocation(
|
||||
string[] BenchmarkDotNetArguments,
|
||||
string? ArtifactsSuffix,
|
||||
string? ArtifactsPath,
|
||||
bool RequiresHostIsolation);
|
||||
}
|
||||
|
||||
@ -1,59 +1,108 @@
|
||||
# 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 / Transient` 两类 handler 生命周期下,direct handler、`GFramework.Cqrs` runtime 与 `MediatR` 的 request steady-state dispatch 对比
|
||||
- `Messaging/StreamLifetimeBenchmarks.cs`
|
||||
- `Singleton / Transient` 两类 handler 生命周期下,direct handler、已接上 handwritten generated stream invoker provider 的 `GFramework.Cqrs` runtime 与 `MediatR` 的 stream 完整枚举对比
|
||||
- `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 完整枚举对比
|
||||
- `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 完整枚举对比
|
||||
当前工程已经覆盖以下矩阵:
|
||||
|
||||
- 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`
|
||||
- 其中 `GFramework.Cqrs` 路径是“单 handler 最小宿主 + 手工注册”的 startup/cold-start 模型,不包含更大范围的程序集扫描或完整注册协调器接线
|
||||
- stream steady-state
|
||||
- `Messaging/StreamingBenchmarks.cs`
|
||||
- baseline、默认 generated-provider 宿主接线的 `GFramework.Cqrs` runtime、NuGet `Mediator` source-generated concrete path 与 `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` 两种观测口径
|
||||
- `Messaging/StreamPipelineBenchmarks.cs`
|
||||
- `0 / 1 / 4` 个 stream pipeline 行为下,baseline、默认 generated-provider 宿主接线的 `GFramework.Cqrs` runtime 与 `MediatR`
|
||||
- 同时提供 `FirstItem` 与 `DrainAll` 两种观测口径
|
||||
- stream startup
|
||||
- `Messaging/StreamStartupBenchmarks.cs`
|
||||
- `Initialization` 与 `ColdStart` 两组下,覆盖 `MediatR`、`GFramework.Cqrs` reflection、`GFramework.Cqrs` generated、NuGet `Mediator` 四组 initialization/cold-start 对照
|
||||
- 其中 `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`
|
||||
- 其中 `GFramework.Cqrs` 路径是“单 handler 最小宿主 + 手工注册”的 startup/cold-start 模型,不包含 fan-out、发布策略变体或更大范围的注册协调逻辑
|
||||
|
||||
## 最小使用方式
|
||||
|
||||
```bash
|
||||
dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release
|
||||
```
|
||||
|
||||
上面的命令只验证 benchmark 工程当前可以正常编译。
|
||||
|
||||
如需实际运行 benchmark,再执行:
|
||||
|
||||
```bash
|
||||
dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release
|
||||
```
|
||||
|
||||
也可以通过 `BenchmarkDotNet` 过滤器只运行某一类场景。
|
||||
如需只复核某一类场景,可把 `BenchmarkDotNet` 参数放在 `--` 之后,例如:
|
||||
|
||||
## 当前约束
|
||||
```bash
|
||||
dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*RequestLifetimeBenchmarks.SendRequest_*"
|
||||
dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*StreamLifetimeBenchmarks.Stream_*"
|
||||
dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*StreamPipelineBenchmarks.Stream_*"
|
||||
```
|
||||
|
||||
- `BenchmarkDotNet.Artifacts/` 属于本地生成输出,默认加入仓库忽略,不作为常规提交内容
|
||||
- 只要变更影响 `GFramework.Cqrs` request dispatch、DI 解析热路径、invoker/provider、pipeline 或 benchmark 宿主,就必须至少复跑:
|
||||
- `RequestBenchmarks.SendRequest_*`
|
||||
- `RequestLifetimeBenchmarks.SendRequest_*`
|
||||
- 当前性能目标不是超过 source-generated `Mediator`,而是让默认 request steady-state 路径尽量接近它,并至少稳定快于基于反射 / 扫描的 `MediatR`
|
||||
## 并发运行约束
|
||||
|
||||
## 后续扩展方向
|
||||
当两个 benchmark 进程需要并发运行时,必须为每个进程追加不同的 `--artifacts-suffix <suffix>`。当前入口会把这个 suffix 解析成独立的 `BenchmarkDotNet.Artifacts/<suffix>/` 目录,并在该目录下复制隔离的 benchmark host,避免多个进程写入同一份 auto-generated build 与 artifacts 输出。
|
||||
|
||||
- request / stream 的真实 source-generator 产物与 handwritten generated provider 对照
|
||||
- `Mediator` 的 transient / scoped compile-time lifetime 矩阵对照
|
||||
- 带真实显式作用域边界的 scoped host 对照
|
||||
- generated invoker provider 与纯反射 dispatch / 建流对比继续扩展到更多场景
|
||||
例如:
|
||||
|
||||
```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/` 仍然是本地生成输出,默认不作为常规提交内容。
|
||||
|
||||
## 结果解读边界
|
||||
|
||||
- `RequestLifetimeBenchmarks` 的 `Scoped` 场景会在每次 request 分发时显式创建并释放真实 DI 作用域;它观察的是 scoped handler 的解析与 dispatch 成本,不把 runtime 构造常量成本混入生命周期对照
|
||||
- `NotificationLifetimeBenchmarks` 的 `Scoped` 场景也采用真实 DI 作用域;它比较的是 publish 路径上的生命周期额外开销,不是根容器解析退化后的近似值
|
||||
- `StreamingBenchmarks`、`StreamLifetimeBenchmarks`、`StreamInvokerBenchmarks`、`StreamPipelineBenchmarks` 同时暴露 `FirstItem` 与 `DrainAll`
|
||||
- `FirstItem` 适合观察“建流到首个元素”的固定成本
|
||||
- `DrainAll` 适合观察完整枚举整个 stream 的总成本
|
||||
- `StreamStartupBenchmarks` 的 `ColdStart` 只推进到首个元素,因此它回答的是“新宿主下首次建流命中”的边界,不回答完整枚举总成本
|
||||
- `RequestStartupBenchmarks` 与 `NotificationStartupBenchmarks` 的 `GFramework.Cqrs` startup 路径都固定在单 handler、最小宿主、手工注册模型;它们回答的是首次 request / publish 命中的额外成本,不代表程序集扫描或完整注册协调器场景
|
||||
- 当前 HEAD 没有单独固化的 short-job benchmark 类或 checked-in short-job 结果;如果手动使用 short job / short run 只做 smoke 复核,应把它理解为“确认矩阵与路径能跑通”
|
||||
- 特别是 `StreamInvokerBenchmarks` 的 `DrainAll` 在 short-job smoke 下不应直接写成 reflection、generated 或 `MediatR` 之间的稳定排序结论;若要比较名次或小幅差值,应复跑默认作业或更完整的批次
|
||||
|
||||
## 当前缺口
|
||||
|
||||
- 当前没有 stream 生命周期版的 NuGet `Mediator` source-generated concrete path 对照;`StreamLifetimeBenchmarks` 现在只覆盖 `GFramework.Cqrs` 与 `MediatR`
|
||||
- 当前没有 request 生命周期版的 NuGet `Mediator` source-generated concrete path 对照;`Mediator` 的 DI lifetime 由 source generator 在 benchmark 项目编译期固定,若要比较 `Singleton / Scoped / Transient`,需要拆成独立 build config 或独立 benchmark 工程,而不是在同一份生成产物里切换
|
||||
- 当前没有 notification fan-out 的生命周期矩阵;`NotificationFanOutBenchmarks` 只覆盖固定 `4 handler` 的已装配宿主
|
||||
|
||||
@ -27,19 +27,7 @@ internal sealed class CqrsDispatcherCacheTests
|
||||
{
|
||||
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider();
|
||||
_container = new MicrosoftDiContainer();
|
||||
_container.RegisterCqrsPipelineBehavior<DispatcherPipelineCacheBehavior>();
|
||||
_container.RegisterCqrsPipelineBehavior<DispatcherPipelineContextRefreshBehavior>();
|
||||
_container.RegisterCqrsPipelineBehavior<DispatcherPipelineOrderOuterBehavior>();
|
||||
_container.RegisterCqrsPipelineBehavior<DispatcherPipelineOrderInnerBehavior>();
|
||||
_container.RegisterCqrsStreamPipelineBehavior<DispatcherStreamPipelineCacheBehavior>();
|
||||
_container.RegisterCqrsStreamPipelineBehavior<DispatcherStreamPipelineContextRefreshBehavior>();
|
||||
_container.RegisterCqrsStreamPipelineBehavior<DispatcherStreamPipelineOrderOuterBehavior>();
|
||||
_container.RegisterCqrsStreamPipelineBehavior<DispatcherStreamPipelineOrderInnerBehavior>();
|
||||
|
||||
CqrsTestRuntime.RegisterHandlers(
|
||||
_container,
|
||||
typeof(CqrsDispatcherCacheTests).Assembly,
|
||||
typeof(ArchitectureContext).Assembly);
|
||||
ConfigureDispatcherCacheFixture(_container);
|
||||
|
||||
_container.Freeze();
|
||||
_context = new ArchitectureContext(_container);
|
||||
@ -160,6 +148,102 @@ internal sealed class CqrsDispatcherCacheTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 request 的“是否存在 pipeline behavior”判定会按 dispatcher 实例缓存,
|
||||
/// 让零行为请求在首次分发后不再重复查询容器,同时不同 dispatcher 不共享该实例级状态。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Dispatcher_Should_Cache_Zero_Pipeline_Request_Presence_Per_Dispatcher_Instance()
|
||||
{
|
||||
var firstContext = new ArchitectureContext(_container!);
|
||||
var secondContext = new ArchitectureContext(_container!);
|
||||
var firstDispatcher = GetDispatcherFromContext(firstContext);
|
||||
var secondDispatcher = GetDispatcherFromContext(secondContext);
|
||||
using var isolatedContainer = CreateFrozenContainer();
|
||||
var isolatedContext = new ArchitectureContext(isolatedContainer);
|
||||
var isolatedDispatcher = GetDispatcherFromContext(isolatedContext);
|
||||
|
||||
AssertRequestBehaviorPresenceIsUnset(firstDispatcher, typeof(IPipelineBehavior<DispatcherCacheRequest, int>));
|
||||
AssertRequestBehaviorPresenceIsUnset(secondDispatcher, typeof(IPipelineBehavior<DispatcherCacheRequest, int>));
|
||||
AssertRequestBehaviorPresenceIsUnset(isolatedDispatcher, typeof(IPipelineBehavior<DispatcherCacheRequest, int>));
|
||||
AssertRequestBehaviorPresenceIsUnset(
|
||||
firstDispatcher,
|
||||
typeof(IPipelineBehavior<DispatcherPipelineCacheRequest, int>));
|
||||
|
||||
await firstContext.SendRequestAsync(new DispatcherCacheRequest());
|
||||
await firstContext.SendRequestAsync(new DispatcherPipelineCacheRequest());
|
||||
|
||||
var zeroPipelinePresence = GetRequestBehaviorPresenceCacheValue(
|
||||
firstDispatcher,
|
||||
typeof(IPipelineBehavior<DispatcherCacheRequest, int>));
|
||||
var onePipelinePresence = GetRequestBehaviorPresenceCacheValue(
|
||||
firstDispatcher,
|
||||
typeof(IPipelineBehavior<DispatcherPipelineCacheRequest, int>));
|
||||
|
||||
AssertSharedDispatcherCacheState(
|
||||
firstDispatcher,
|
||||
secondDispatcher,
|
||||
isolatedDispatcher,
|
||||
zeroPipelinePresence,
|
||||
onePipelinePresence);
|
||||
|
||||
await isolatedContext.SendRequestAsync(new DispatcherCacheRequest());
|
||||
|
||||
AssertRequestBehaviorPresenceEquals(
|
||||
isolatedDispatcher,
|
||||
typeof(IPipelineBehavior<DispatcherCacheRequest, int>),
|
||||
false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 stream 的“是否存在 pipeline behavior”判定会按 dispatcher 实例缓存,
|
||||
/// 并与当前容器的实际服务可见性保持一致,同时不同 dispatcher 不共享该实例级状态。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Dispatcher_Should_Cache_Stream_Behavior_Presence_Per_Dispatcher_Instance()
|
||||
{
|
||||
var firstContext = new ArchitectureContext(_container!);
|
||||
var secondContext = new ArchitectureContext(_container!);
|
||||
var firstDispatcher = GetDispatcherFromContext(firstContext);
|
||||
var secondDispatcher = GetDispatcherFromContext(secondContext);
|
||||
using var isolatedContainer = CreateFrozenContainer();
|
||||
var isolatedContext = new ArchitectureContext(isolatedContainer);
|
||||
var isolatedDispatcher = GetDispatcherFromContext(isolatedContext);
|
||||
var zeroPipelineBehaviorType = typeof(IStreamPipelineBehavior<DispatcherZeroPipelineStreamRequest, int>);
|
||||
var twoPipelineBehaviorType = typeof(IStreamPipelineBehavior<DispatcherStreamPipelineOrderRequest, int>);
|
||||
var expectedZeroPipelinePresence = _container!.HasRegistration(zeroPipelineBehaviorType);
|
||||
var expectedTwoPipelinePresence = _container.HasRegistration(twoPipelineBehaviorType);
|
||||
|
||||
AssertStreamBehaviorPresenceIsUnset(firstDispatcher, zeroPipelineBehaviorType);
|
||||
AssertStreamBehaviorPresenceIsUnset(secondDispatcher, zeroPipelineBehaviorType);
|
||||
AssertStreamBehaviorPresenceIsUnset(isolatedDispatcher, zeroPipelineBehaviorType);
|
||||
AssertStreamBehaviorPresenceIsUnset(firstDispatcher, twoPipelineBehaviorType);
|
||||
|
||||
await DrainAsync(firstContext.CreateStream(new DispatcherZeroPipelineStreamRequest()));
|
||||
await DrainAsync(firstContext.CreateStream(new DispatcherStreamPipelineOrderRequest()));
|
||||
|
||||
var zeroPipelinePresence = GetStreamBehaviorPresenceCacheValue(
|
||||
firstDispatcher,
|
||||
zeroPipelineBehaviorType);
|
||||
var twoPipelinePresence = GetStreamBehaviorPresenceCacheValue(
|
||||
firstDispatcher,
|
||||
twoPipelineBehaviorType);
|
||||
|
||||
AssertSharedStreamDispatcherCacheState(
|
||||
firstDispatcher,
|
||||
secondDispatcher,
|
||||
isolatedDispatcher,
|
||||
zeroPipelinePresence,
|
||||
twoPipelinePresence,
|
||||
zeroPipelineBehaviorType,
|
||||
expectedZeroPipelinePresence,
|
||||
expectedTwoPipelinePresence);
|
||||
|
||||
await DrainAsync(isolatedContext.CreateStream(new DispatcherZeroPipelineStreamRequest()));
|
||||
|
||||
AssertStreamBehaviorPresenceEquals(isolatedDispatcher, zeroPipelineBehaviorType, expectedZeroPipelinePresence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 request pipeline executor 会按行为数量在 binding 内首次创建并在后续分发中复用。
|
||||
/// </summary>
|
||||
@ -225,6 +309,60 @@ internal sealed class CqrsDispatcherCacheTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证同一 request dispatch binding 先走零行为直连路径时不会提前创建 pipeline executor,
|
||||
/// 后续另一 dispatcher 命中相同 binding 且存在行为时,仍会按实际行为数量补建缓存 executor。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Dispatcher_Should_Create_Request_Pipeline_Executor_Only_When_Shared_Binding_Sees_Behaviors()
|
||||
{
|
||||
var requestBindings = GetCacheField("RequestDispatchBindings");
|
||||
var behaviorType = typeof(IPipelineBehavior<DispatcherPipelineCacheRequest, int>);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 stream pipeline executor 会按行为数量在 binding 内首次创建并在后续建流中复用。
|
||||
/// </summary>
|
||||
@ -290,6 +428,60 @@ internal sealed class CqrsDispatcherCacheTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证同一 stream dispatch binding 先走零行为直连路径时不会提前创建 pipeline executor,
|
||||
/// 后续另一 dispatcher 命中相同 binding 且存在行为时,仍会按实际行为数量补建缓存 executor。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Dispatcher_Should_Create_Stream_Pipeline_Executor_Only_When_Shared_Binding_Sees_Behaviors()
|
||||
{
|
||||
var streamBindings = GetCacheField("StreamDispatchBindings");
|
||||
var behaviorType = typeof(IStreamPipelineBehavior<DispatcherCacheStreamRequest, int>);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证复用缓存的 request pipeline executor 后,行为顺序和最终处理器顺序保持不变。
|
||||
/// </summary>
|
||||
@ -408,6 +600,71 @@ internal sealed class CqrsDispatcherCacheTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证同一 request dispatch binding 先在零行为 dispatcher 上命中后,
|
||||
/// 后续切换到存在行为的 dispatcher 仍会重新解析 behavior/handler,并为当前上下文重新注入架构实例。
|
||||
/// </summary>
|
||||
[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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证缓存的 notification dispatch binding 在重复分发时仍会重新解析 handler,
|
||||
/// 并为当次实例重新注入当前架构上下文。
|
||||
@ -548,6 +805,108 @@ internal sealed class CqrsDispatcherCacheTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证同一 stream dispatch binding 先在零行为 dispatcher 上命中后,
|
||||
/// 后续切换到存在行为的 dispatcher 仍会重新解析 behavior/handler,并为当前上下文重新注入架构实例。
|
||||
/// </summary>
|
||||
[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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 描述缓存测试 fixture 需要启用的可选 pipeline 行为集合,
|
||||
/// 用于构造“同一静态 binding 对应不同 dispatcher 注册可见性”的组合场景。
|
||||
/// </summary>
|
||||
private sealed class DispatcherCacheFixtureOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取是否注册 <see cref="DispatcherPipelineCacheBehavior" />。
|
||||
/// </summary>
|
||||
public bool IncludeRequestPipelineCacheBehavior { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 获取是否注册 <see cref="DispatcherPipelineContextRefreshBehavior" />。
|
||||
/// </summary>
|
||||
public bool IncludeRequestPipelineContextRefreshBehavior { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 获取是否注册 request 顺序验证所需的两层 pipeline 行为。
|
||||
/// </summary>
|
||||
public bool IncludeRequestPipelineOrderBehaviors { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 获取是否注册 <see cref="DispatcherStreamPipelineCacheBehavior" />。
|
||||
/// </summary>
|
||||
public bool IncludeStreamPipelineCacheBehavior { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 获取是否注册 <see cref="DispatcherStreamPipelineContextRefreshBehavior" />。
|
||||
/// </summary>
|
||||
public bool IncludeStreamPipelineContextRefreshBehavior { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 获取是否注册 stream 顺序验证所需的两层 pipeline 行为。
|
||||
/// </summary>
|
||||
public bool IncludeStreamPipelineOrderBehaviors { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过反射读取 dispatcher 的静态缓存对象。
|
||||
/// </summary>
|
||||
@ -565,6 +924,95 @@ internal sealed class CqrsDispatcherCacheTests
|
||||
$"Dispatcher cache field {fieldName} returned null.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从架构上下文中解析当前延迟创建的 dispatcher 实例,便于验证其实例级热路径缓存。
|
||||
/// </summary>
|
||||
private static object GetDispatcherFromContext(ArchitectureContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var lazyRuntimeField = typeof(ArchitectureContext).GetField(
|
||||
"_cqrsRuntime",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.That(lazyRuntimeField, Is.Not.Null, "Missing ArchitectureContext._cqrsRuntime field.");
|
||||
|
||||
var lazyRuntime = lazyRuntimeField!.GetValue(context)
|
||||
?? throw new InvalidOperationException(
|
||||
"ArchitectureContext._cqrsRuntime returned null.");
|
||||
var lazyValueProperty = lazyRuntime.GetType().GetProperty(
|
||||
"Value",
|
||||
BindingFlags.Instance | BindingFlags.Public);
|
||||
|
||||
Assert.That(lazyValueProperty, Is.Not.Null, "Missing Lazy<ICqrsRuntime>.Value accessor.");
|
||||
|
||||
return lazyValueProperty!.GetValue(lazyRuntime)
|
||||
?? throw new InvalidOperationException("Resolved CQRS runtime instance was null.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建与当前 fixture 注册形状一致、但拥有独立 runtime 实例的冻结容器,
|
||||
/// 用于验证 dispatcher 的实例级缓存不会跨容器共享。
|
||||
/// </summary>
|
||||
/// <param name="options">控制当前隔离容器要启用哪些可选 pipeline 行为的配置。</param>
|
||||
private static MicrosoftDiContainer CreateFrozenContainer(DispatcherCacheFixtureOptions? options = null)
|
||||
{
|
||||
var container = new MicrosoftDiContainer();
|
||||
ConfigureDispatcherCacheFixture(container, options);
|
||||
|
||||
container.Freeze();
|
||||
return container;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 组装当前 fixture 依赖的 CQRS 容器注册形状,确保默认上下文与隔离容器复用同一份装配基线。
|
||||
/// </summary>
|
||||
/// <param name="container">待补齐 CQRS 注册的目标容器。</param>
|
||||
/// <param name="options">控制是否跳过特定 pipeline 行为注册的可选配置。</param>
|
||||
private static void ConfigureDispatcherCacheFixture(
|
||||
MicrosoftDiContainer container,
|
||||
DispatcherCacheFixtureOptions? options = null)
|
||||
{
|
||||
options ??= new DispatcherCacheFixtureOptions();
|
||||
|
||||
if (options.IncludeRequestPipelineCacheBehavior)
|
||||
{
|
||||
container.RegisterCqrsPipelineBehavior<DispatcherPipelineCacheBehavior>();
|
||||
}
|
||||
|
||||
if (options.IncludeRequestPipelineContextRefreshBehavior)
|
||||
{
|
||||
container.RegisterCqrsPipelineBehavior<DispatcherPipelineContextRefreshBehavior>();
|
||||
}
|
||||
|
||||
if (options.IncludeRequestPipelineOrderBehaviors)
|
||||
{
|
||||
container.RegisterCqrsPipelineBehavior<DispatcherPipelineOrderOuterBehavior>();
|
||||
container.RegisterCqrsPipelineBehavior<DispatcherPipelineOrderInnerBehavior>();
|
||||
}
|
||||
|
||||
if (options.IncludeStreamPipelineCacheBehavior)
|
||||
{
|
||||
container.RegisterCqrsStreamPipelineBehavior<DispatcherStreamPipelineCacheBehavior>();
|
||||
}
|
||||
|
||||
if (options.IncludeStreamPipelineContextRefreshBehavior)
|
||||
{
|
||||
container.RegisterCqrsStreamPipelineBehavior<DispatcherStreamPipelineContextRefreshBehavior>();
|
||||
}
|
||||
|
||||
if (options.IncludeStreamPipelineOrderBehaviors)
|
||||
{
|
||||
container.RegisterCqrsStreamPipelineBehavior<DispatcherStreamPipelineOrderOuterBehavior>();
|
||||
container.RegisterCqrsStreamPipelineBehavior<DispatcherStreamPipelineOrderInnerBehavior>();
|
||||
}
|
||||
|
||||
CqrsTestRuntime.RegisterHandlers(
|
||||
container,
|
||||
typeof(CqrsDispatcherCacheTests).Assembly,
|
||||
typeof(ArchitectureContext).Assembly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清空本测试依赖的 dispatcher 静态缓存,避免跨用例共享进程级状态导致断言漂移。
|
||||
/// </summary>
|
||||
@ -591,6 +1039,140 @@ internal sealed class CqrsDispatcherCacheTests
|
||||
return InvokeInstanceMethod(cache, "GetValueOrDefaultForTesting", primaryType, secondaryType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取指定 dispatcher 实例中当前保存的 request behavior presence 缓存项。
|
||||
/// </summary>
|
||||
private static object? GetRequestBehaviorPresenceCacheValue(object dispatcher, Type behaviorType)
|
||||
{
|
||||
var field = dispatcher.GetType().GetField(
|
||||
"_requestBehaviorPresenceCache",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.That(field, Is.Not.Null, "Missing dispatcher request behavior presence cache field.");
|
||||
|
||||
var cache = field!.GetValue(dispatcher)
|
||||
?? throw new InvalidOperationException(
|
||||
"Dispatcher request behavior presence cache returned null.");
|
||||
var tryGetValueMethod = cache.GetType().GetMethod(
|
||||
"TryGetValue",
|
||||
BindingFlags.Instance | BindingFlags.Public);
|
||||
|
||||
Assert.That(tryGetValueMethod, Is.Not.Null, "Missing ConcurrentDictionary.TryGetValue accessor.");
|
||||
|
||||
object?[] arguments = [behaviorType, null];
|
||||
var found = (bool)(tryGetValueMethod!.Invoke(cache, arguments)
|
||||
?? throw new InvalidOperationException(
|
||||
"ConcurrentDictionary.TryGetValue returned null."));
|
||||
return found ? arguments[1] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取指定 dispatcher 实例中当前保存的 stream behavior presence 缓存项。
|
||||
/// </summary>
|
||||
private static object? GetStreamBehaviorPresenceCacheValue(object dispatcher, Type behaviorType)
|
||||
{
|
||||
var field = dispatcher.GetType().GetField(
|
||||
"_streamBehaviorPresenceCache",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.That(field, Is.Not.Null, "Missing dispatcher stream behavior presence cache field.");
|
||||
|
||||
var cache = field!.GetValue(dispatcher)
|
||||
?? throw new InvalidOperationException(
|
||||
"Dispatcher stream behavior presence cache returned null.");
|
||||
var tryGetValueMethod = cache.GetType().GetMethod(
|
||||
"TryGetValue",
|
||||
BindingFlags.Instance | BindingFlags.Public);
|
||||
|
||||
Assert.That(tryGetValueMethod, Is.Not.Null, "Missing ConcurrentDictionary.TryGetValue accessor.");
|
||||
|
||||
object?[] arguments = [behaviorType, null];
|
||||
var found = (bool)(tryGetValueMethod!.Invoke(cache, arguments)
|
||||
?? throw new InvalidOperationException(
|
||||
"ConcurrentDictionary.TryGetValue returned null."));
|
||||
return found ? arguments[1] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 断言指定 dispatcher 上某个 request behavior presence 缓存项尚未建立。
|
||||
/// </summary>
|
||||
private static void AssertRequestBehaviorPresenceIsUnset(object dispatcher, Type behaviorType)
|
||||
{
|
||||
Assert.That(GetRequestBehaviorPresenceCacheValue(dispatcher, behaviorType), Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 断言指定 dispatcher 上某个 request behavior presence 缓存项等于预期值。
|
||||
/// </summary>
|
||||
private static void AssertRequestBehaviorPresenceEquals(object dispatcher, Type behaviorType, bool expected)
|
||||
{
|
||||
Assert.That(GetRequestBehaviorPresenceCacheValue(dispatcher, behaviorType), Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 断言指定 dispatcher 上某个 stream behavior presence 缓存项尚未建立。
|
||||
/// </summary>
|
||||
private static void AssertStreamBehaviorPresenceIsUnset(object dispatcher, Type behaviorType)
|
||||
{
|
||||
Assert.That(GetStreamBehaviorPresenceCacheValue(dispatcher, behaviorType), Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 断言指定 dispatcher 上某个 stream behavior presence 缓存项等于预期值。
|
||||
/// </summary>
|
||||
private static void AssertStreamBehaviorPresenceEquals(object dispatcher, Type behaviorType, bool expected)
|
||||
{
|
||||
Assert.That(GetStreamBehaviorPresenceCacheValue(dispatcher, behaviorType), Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 断言同一容器解析出的 dispatcher 会共享实例级缓存,而另一独立容器的 dispatcher 不会提前命中。
|
||||
/// </summary>
|
||||
private static void AssertSharedDispatcherCacheState(
|
||||
object firstDispatcher,
|
||||
object secondDispatcher,
|
||||
object isolatedDispatcher,
|
||||
object? zeroPipelinePresence,
|
||||
object? onePipelinePresence)
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(secondDispatcher, Is.SameAs(firstDispatcher));
|
||||
Assert.That(zeroPipelinePresence, Is.EqualTo(false));
|
||||
Assert.That(onePipelinePresence, Is.EqualTo(true));
|
||||
AssertRequestBehaviorPresenceEquals(
|
||||
secondDispatcher,
|
||||
typeof(IPipelineBehavior<DispatcherCacheRequest, int>),
|
||||
false);
|
||||
AssertRequestBehaviorPresenceIsUnset(
|
||||
isolatedDispatcher,
|
||||
typeof(IPipelineBehavior<DispatcherCacheRequest, int>));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 断言同一容器解析出的 dispatcher 会共享 stream 的实例级缓存,而另一独立容器的 dispatcher 不会提前命中。
|
||||
/// </summary>
|
||||
private static void AssertSharedStreamDispatcherCacheState(
|
||||
object firstDispatcher,
|
||||
object secondDispatcher,
|
||||
object isolatedDispatcher,
|
||||
object? zeroPipelinePresence,
|
||||
object? twoPipelinePresence,
|
||||
Type zeroPipelineBehaviorType,
|
||||
bool expectedZeroPipelinePresence,
|
||||
bool expectedTwoPipelinePresence)
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(secondDispatcher, Is.SameAs(firstDispatcher));
|
||||
Assert.That(zeroPipelinePresence, Is.EqualTo(expectedZeroPipelinePresence));
|
||||
Assert.That(twoPipelinePresence, Is.EqualTo(expectedTwoPipelinePresence));
|
||||
AssertStreamBehaviorPresenceEquals(secondDispatcher, zeroPipelineBehaviorType, expectedZeroPipelinePresence);
|
||||
AssertStreamBehaviorPresenceIsUnset(isolatedDispatcher, zeroPipelineBehaviorType);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取 request dispatch binding 中指定行为数量的 pipeline executor 缓存项。
|
||||
/// </summary>
|
||||
@ -625,7 +1207,7 @@ internal sealed class CqrsDispatcherCacheTests
|
||||
Type responseType,
|
||||
int behaviorCount)
|
||||
{
|
||||
var binding = GetPairCacheValue(streamBindings, requestType, responseType);
|
||||
var binding = GetStreamDispatchBindingValue(streamBindings, requestType, responseType);
|
||||
return binding is null
|
||||
? null
|
||||
: InvokeInstanceMethod(binding, "GetPipelineExecutorForTesting", behaviorCount);
|
||||
@ -679,6 +1261,32 @@ internal sealed class CqrsDispatcherCacheTests
|
||||
.Invoke(bindingBox, Array.Empty<object>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取指定流式请求/响应类型对对应的强类型 stream dispatch binding。
|
||||
/// </summary>
|
||||
/// <param name="streamBindings">dispatcher 内部的 stream binding 缓存对象。</param>
|
||||
/// <param name="requestType">要读取的流式请求运行时类型。</param>
|
||||
/// <param name="responseType">要读取的响应元素类型。</param>
|
||||
/// <returns>强类型 binding;若缓存尚未建立则返回 <see langword="null" />。</returns>
|
||||
private static object? GetStreamDispatchBindingValue(object streamBindings, Type requestType, Type responseType)
|
||||
{
|
||||
var bindingBox = GetPairCacheValue(streamBindings, requestType, responseType);
|
||||
if (bindingBox is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var method = bindingBox.GetType().GetMethod(
|
||||
"Get",
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
|
||||
Assert.That(method, Is.Not.Null, $"Missing stream binding accessor on {bindingBox.GetType().FullName}.");
|
||||
|
||||
return method!
|
||||
.MakeGenericMethod(responseType)
|
||||
.Invoke(bindingBox, Array.Empty<object>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取 CQRS dispatcher 运行时类型。
|
||||
/// </summary>
|
||||
|
||||
@ -7,6 +7,7 @@ using GFramework.Core.Abstractions.Ioc;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs;
|
||||
using GFramework.Cqrs.Cqrs;
|
||||
using GFramework.Cqrs.Notification;
|
||||
using GFramework.Cqrs.Tests.Logging;
|
||||
|
||||
namespace GFramework.Cqrs.Tests.Cqrs;
|
||||
@ -184,6 +185,11 @@ internal sealed class CqrsDispatcherContextValidationTests
|
||||
var container = new Mock<IIocContainer>(MockBehavior.Strict);
|
||||
var logger = new TestLogger("CqrsDispatcherContextValidationTests", LogLevel.Debug);
|
||||
|
||||
// PublishAsync 的默认路径会在真正发布时查询通知发布器注册;strict mock 需显式覆盖空注册分支。
|
||||
container
|
||||
.Setup(currentContainer => currentContainer.GetAll(typeof(INotificationPublisher)))
|
||||
.Returns(Array.Empty<object>());
|
||||
|
||||
configureContainer(container);
|
||||
return CqrsRuntimeFactory.CreateRuntime(container.Object, logger);
|
||||
}
|
||||
|
||||
@ -222,6 +222,49 @@ internal sealed class CqrsGeneratedRequestInvokerProviderTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 generated stream binding 与对应的 pipeline executor 在首次建流后会被缓存并复用,
|
||||
/// 同时保持 generated invoker 的结果与行为执行语义不变。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task CreateStream_Should_Reuse_Cached_Generated_Stream_Binding_And_Pipeline_Executor()
|
||||
{
|
||||
var generatedAssembly = CreateGeneratedStreamInvokerAssembly();
|
||||
var container = new MicrosoftDiContainer();
|
||||
container.RegisterCqrsStreamPipelineBehavior<GeneratedStreamPipelineTrackingBehavior>();
|
||||
|
||||
CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object);
|
||||
container.Freeze();
|
||||
|
||||
var streamBindings = GetDispatcherCacheField("StreamDispatchBindings");
|
||||
var requestType = typeof(GeneratedStreamInvokerRequest);
|
||||
var responseType = typeof(int);
|
||||
|
||||
Assert.That(
|
||||
GetStreamPipelineExecutorValue(streamBindings, requestType, responseType, 1),
|
||||
Is.Null);
|
||||
|
||||
var context = new ArchitectureContext(container);
|
||||
var firstResults = await DrainAsync(context.CreateStream(new GeneratedStreamInvokerRequest(3))).ConfigureAwait(false);
|
||||
var bindingAfterFirstDispatch = GetPairCacheValue(streamBindings, requestType, responseType);
|
||||
var executorAfterFirstDispatch = GetStreamPipelineExecutorValue(streamBindings, requestType, responseType, 1);
|
||||
|
||||
var secondResults = await DrainAsync(context.CreateStream(new GeneratedStreamInvokerRequest(3))).ConfigureAwait(false);
|
||||
var bindingAfterSecondDispatch = GetPairCacheValue(streamBindings, requestType, responseType);
|
||||
var executorAfterSecondDispatch = GetStreamPipelineExecutorValue(streamBindings, requestType, responseType, 1);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(firstResults, Is.EqualTo([30, 31]));
|
||||
Assert.That(secondResults, Is.EqualTo([30, 31]));
|
||||
Assert.That(bindingAfterFirstDispatch, Is.Not.Null);
|
||||
Assert.That(bindingAfterSecondDispatch, Is.SameAs(bindingAfterFirstDispatch));
|
||||
Assert.That(executorAfterFirstDispatch, Is.Not.Null);
|
||||
Assert.That(executorAfterSecondDispatch, Is.SameAs(executorAfterFirstDispatch));
|
||||
Assert.That(GeneratedStreamPipelineTrackingBehavior.InvocationCount, Is.EqualTo(2));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当实现类型隐藏、但 stream handler interface 仍可直接表达时,
|
||||
/// dispatcher 仍会消费 generated stream invoker descriptor。
|
||||
@ -405,6 +448,166 @@ internal sealed class CqrsGeneratedRequestInvokerProviderTests
|
||||
Assert.That(results, Is.EqualTo([3, 4]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当 generated request invoker provider 的 descriptor 枚举抛出异常时,
|
||||
/// registrar 会跳过 generated descriptor 预热并回退到反射路径。
|
||||
/// </summary>
|
||||
[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"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当 generated stream invoker provider 的 descriptor 枚举抛出异常时,
|
||||
/// registrar 会跳过 generated descriptor 预热并回退到反射建流路径。
|
||||
/// </summary>
|
||||
[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]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当 request descriptor 枚举返回重复 request-response pair 时,
|
||||
/// registrar 会稳定保留首个有效描述符,并忽略后续重复项。
|
||||
/// </summary>
|
||||
[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"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当 stream descriptor 枚举返回重复 request-response pair 时,
|
||||
/// registrar 会稳定保留首个有效描述符,并忽略后续重复项。
|
||||
/// </summary>
|
||||
[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]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当 request descriptor 枚举项与 provider 的 TryGetDescriptor 结果不一致时,
|
||||
/// registrar 会忽略该坏 descriptor,并继续回退到反射路径。
|
||||
/// </summary>
|
||||
[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"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当首个 request descriptor 无效、后续同键 descriptor 有效时,
|
||||
/// registrar 不会因为过早去重而丢掉本可注册的 generated descriptor。
|
||||
/// </summary>
|
||||
[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"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当 stream descriptor 枚举项与 provider 的 TryGetDescriptor 结果不一致时,
|
||||
/// registrar 会忽略该坏 descriptor,并继续回退到反射建流路径。
|
||||
/// </summary>
|
||||
[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]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当首个 stream descriptor 无效、后续同键 descriptor 有效时,
|
||||
/// registrar 不会因为过早去重而丢掉本可注册的 generated descriptor。
|
||||
/// </summary>
|
||||
[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]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟返回实例 request invoker 方法的 generated registry。
|
||||
/// </summary>
|
||||
@ -817,6 +1020,529 @@ internal sealed class CqrsGeneratedRequestInvokerProviderTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 descriptor 枚举阶段抛出异常的 request invoker provider。
|
||||
/// </summary>
|
||||
private sealed class ThrowingEnumeratingRequestInvokerProviderRegistry :
|
||||
ICqrsHandlerRegistry,
|
||||
ICqrsRequestInvokerProvider,
|
||||
IEnumeratesCqrsRequestInvokerDescriptors
|
||||
{
|
||||
private static readonly CqrsRequestInvokerDescriptor Descriptor = new(
|
||||
typeof(IRequestHandler<GeneratedRequestInvokerRequest, string>),
|
||||
typeof(GeneratedRequestInvokerProviderRegistry).GetMethod(
|
||||
"InvokeGenerated",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(IServiceCollection services, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
services.AddTransient(
|
||||
typeof(IRequestHandler<GeneratedRequestInvokerRequest, string>),
|
||||
typeof(GeneratedRequestInvokerRequestHandler));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<CqrsRequestInvokerDescriptorEntry> GetDescriptors()
|
||||
{
|
||||
throw new InvalidOperationException("request descriptors failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 descriptor 枚举阶段抛出异常的 stream invoker provider。
|
||||
/// </summary>
|
||||
private sealed class ThrowingEnumeratingStreamInvokerProviderRegistry :
|
||||
ICqrsHandlerRegistry,
|
||||
ICqrsStreamInvokerProvider,
|
||||
IEnumeratesCqrsStreamInvokerDescriptors
|
||||
{
|
||||
private static readonly CqrsStreamInvokerDescriptor Descriptor = new(
|
||||
typeof(IStreamRequestHandler<GeneratedStreamInvokerRequest, int>),
|
||||
typeof(GeneratedStreamInvokerProviderRegistry).GetMethod(
|
||||
"InvokeGenerated",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(IServiceCollection services, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
services.AddTransient(
|
||||
typeof(IStreamRequestHandler<GeneratedStreamInvokerRequest, int>),
|
||||
typeof(GeneratedStreamInvokerRequestHandler));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<CqrsStreamInvokerDescriptorEntry> GetDescriptors()
|
||||
{
|
||||
throw new InvalidOperationException("stream descriptors failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟返回重复 request descriptor 条目的 generated registry。
|
||||
/// </summary>
|
||||
private sealed class DuplicateEnumeratingRequestInvokerProviderRegistry :
|
||||
ICqrsHandlerRegistry,
|
||||
ICqrsRequestInvokerProvider,
|
||||
IEnumeratesCqrsRequestInvokerDescriptors
|
||||
{
|
||||
private static readonly CqrsRequestInvokerDescriptor PrimaryDescriptor = new(
|
||||
typeof(IRequestHandler<GeneratedRequestInvokerRequest, string>),
|
||||
typeof(GeneratedRequestInvokerProviderRegistry).GetMethod(
|
||||
"InvokeGenerated",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!);
|
||||
|
||||
private static readonly CqrsRequestInvokerDescriptor SecondaryDescriptor = new(
|
||||
typeof(IRequestHandler<GeneratedRequestInvokerRequest, string>),
|
||||
typeof(DuplicateEnumeratingRequestInvokerProviderRegistry).GetMethod(
|
||||
nameof(InvokeAlternativeGenerated),
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(IServiceCollection services, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
services.AddTransient(
|
||||
typeof(IRequestHandler<GeneratedRequestInvokerRequest, string>),
|
||||
typeof(GeneratedRequestInvokerRequestHandler));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<CqrsRequestInvokerDescriptorEntry> GetDescriptors()
|
||||
{
|
||||
return
|
||||
[
|
||||
new CqrsRequestInvokerDescriptorEntry(typeof(GeneratedRequestInvokerRequest), typeof(string), PrimaryDescriptor),
|
||||
new CqrsRequestInvokerDescriptorEntry(typeof(GeneratedRequestInvokerRequest), typeof(string), SecondaryDescriptor)
|
||||
];
|
||||
}
|
||||
|
||||
private static ValueTask<string> InvokeAlternativeGenerated(
|
||||
object handler,
|
||||
object request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ValueTask.FromResult("duplicate:payload");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟返回重复 stream descriptor 条目的 generated registry。
|
||||
/// </summary>
|
||||
private sealed class DuplicateEnumeratingStreamInvokerProviderRegistry :
|
||||
ICqrsHandlerRegistry,
|
||||
ICqrsStreamInvokerProvider,
|
||||
IEnumeratesCqrsStreamInvokerDescriptors
|
||||
{
|
||||
private static readonly CqrsStreamInvokerDescriptor PrimaryDescriptor = new(
|
||||
typeof(IStreamRequestHandler<GeneratedStreamInvokerRequest, int>),
|
||||
typeof(GeneratedStreamInvokerProviderRegistry).GetMethod(
|
||||
"InvokeGenerated",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!);
|
||||
|
||||
private static readonly CqrsStreamInvokerDescriptor SecondaryDescriptor = new(
|
||||
typeof(IStreamRequestHandler<GeneratedStreamInvokerRequest, int>),
|
||||
typeof(DuplicateEnumeratingStreamInvokerProviderRegistry).GetMethod(
|
||||
nameof(InvokeAlternativeGenerated),
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(IServiceCollection services, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
services.AddTransient(
|
||||
typeof(IStreamRequestHandler<GeneratedStreamInvokerRequest, int>),
|
||||
typeof(GeneratedStreamInvokerRequestHandler));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<CqrsStreamInvokerDescriptorEntry> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟枚举出的 request descriptor 与 provider 显式查询结果不一致的 generated registry。
|
||||
/// </summary>
|
||||
private sealed class MismatchedEnumeratingRequestInvokerProviderRegistry :
|
||||
ICqrsHandlerRegistry,
|
||||
ICqrsRequestInvokerProvider,
|
||||
IEnumeratesCqrsRequestInvokerDescriptors
|
||||
{
|
||||
private static readonly CqrsRequestInvokerDescriptor ProviderDescriptor = new(
|
||||
typeof(IRequestHandler<GeneratedRequestInvokerRequest, string>),
|
||||
typeof(GeneratedRequestInvokerProviderRegistry).GetMethod(
|
||||
"InvokeGenerated",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!);
|
||||
|
||||
private static readonly CqrsRequestInvokerDescriptor EnumeratedDescriptor = new(
|
||||
typeof(IRequestHandler<GeneratedRequestInvokerRequest, string>),
|
||||
typeof(MismatchedEnumeratingRequestInvokerProviderRegistry).GetMethod(
|
||||
nameof(InvokeAlternativeGenerated),
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(IServiceCollection services, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
services.AddTransient(
|
||||
typeof(IRequestHandler<GeneratedRequestInvokerRequest, string>),
|
||||
typeof(GeneratedRequestInvokerRequestHandler));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<CqrsRequestInvokerDescriptorEntry> GetDescriptors()
|
||||
{
|
||||
return
|
||||
[
|
||||
new CqrsRequestInvokerDescriptorEntry(
|
||||
typeof(GeneratedRequestInvokerRequest),
|
||||
typeof(string),
|
||||
EnumeratedDescriptor)
|
||||
];
|
||||
}
|
||||
|
||||
private static ValueTask<string> InvokeAlternativeGenerated(
|
||||
object handler,
|
||||
object request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ValueTask.FromResult("mismatched:payload");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟首个 request descriptor 无效、后续同键 descriptor 有效的 generated registry。
|
||||
/// </summary>
|
||||
private sealed class InvalidThenValidDuplicateRequestInvokerProviderRegistry :
|
||||
ICqrsHandlerRegistry,
|
||||
ICqrsRequestInvokerProvider,
|
||||
IEnumeratesCqrsRequestInvokerDescriptors
|
||||
{
|
||||
private static readonly CqrsRequestInvokerDescriptor InvalidDescriptor = new(
|
||||
typeof(IRequestHandler<GeneratedRequestInvokerRequest, string>),
|
||||
typeof(InvalidThenValidDuplicateRequestInvokerProviderRegistry).GetMethod(
|
||||
nameof(InvokeAlternativeGenerated),
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!);
|
||||
|
||||
private static readonly CqrsRequestInvokerDescriptor ValidDescriptor = new(
|
||||
typeof(IRequestHandler<GeneratedRequestInvokerRequest, string>),
|
||||
typeof(GeneratedRequestInvokerProviderRegistry).GetMethod(
|
||||
"InvokeGenerated",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(IServiceCollection services, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
services.AddTransient(
|
||||
typeof(IRequestHandler<GeneratedRequestInvokerRequest, string>),
|
||||
typeof(GeneratedRequestInvokerRequestHandler));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<CqrsRequestInvokerDescriptorEntry> GetDescriptors()
|
||||
{
|
||||
return
|
||||
[
|
||||
new CqrsRequestInvokerDescriptorEntry(
|
||||
typeof(GeneratedRequestInvokerRequest),
|
||||
typeof(string),
|
||||
InvalidDescriptor),
|
||||
new CqrsRequestInvokerDescriptorEntry(
|
||||
typeof(GeneratedRequestInvokerRequest),
|
||||
typeof(string),
|
||||
ValidDescriptor)
|
||||
];
|
||||
}
|
||||
|
||||
private static ValueTask<string> InvokeAlternativeGenerated(
|
||||
object handler,
|
||||
object request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ValueTask.FromResult("invalid-first:payload");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟枚举出的 stream descriptor 与 provider 显式查询结果不一致的 generated registry。
|
||||
/// </summary>
|
||||
private sealed class MismatchedEnumeratingStreamInvokerProviderRegistry :
|
||||
ICqrsHandlerRegistry,
|
||||
ICqrsStreamInvokerProvider,
|
||||
IEnumeratesCqrsStreamInvokerDescriptors
|
||||
{
|
||||
private static readonly CqrsStreamInvokerDescriptor ProviderDescriptor = new(
|
||||
typeof(IStreamRequestHandler<GeneratedStreamInvokerRequest, int>),
|
||||
typeof(GeneratedStreamInvokerProviderRegistry).GetMethod(
|
||||
"InvokeGenerated",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!);
|
||||
|
||||
private static readonly CqrsStreamInvokerDescriptor EnumeratedDescriptor = new(
|
||||
typeof(IStreamRequestHandler<GeneratedStreamInvokerRequest, int>),
|
||||
typeof(MismatchedEnumeratingStreamInvokerProviderRegistry).GetMethod(
|
||||
nameof(InvokeAlternativeGenerated),
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(IServiceCollection services, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
services.AddTransient(
|
||||
typeof(IStreamRequestHandler<GeneratedStreamInvokerRequest, int>),
|
||||
typeof(GeneratedStreamInvokerRequestHandler));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<CqrsStreamInvokerDescriptorEntry> GetDescriptors()
|
||||
{
|
||||
return
|
||||
[
|
||||
new CqrsStreamInvokerDescriptorEntry(
|
||||
typeof(GeneratedStreamInvokerRequest),
|
||||
typeof(int),
|
||||
EnumeratedDescriptor)
|
||||
];
|
||||
}
|
||||
|
||||
private static object InvokeAlternativeGenerated(object handler, object request, CancellationToken cancellationToken)
|
||||
{
|
||||
return new[] { 700, 701 }.ToAsyncEnumerable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟首个 stream descriptor 无效、后续同键 descriptor 有效的 generated registry。
|
||||
/// </summary>
|
||||
private sealed class InvalidThenValidDuplicateStreamInvokerProviderRegistry :
|
||||
ICqrsHandlerRegistry,
|
||||
ICqrsStreamInvokerProvider,
|
||||
IEnumeratesCqrsStreamInvokerDescriptors
|
||||
{
|
||||
private static readonly CqrsStreamInvokerDescriptor InvalidDescriptor = new(
|
||||
typeof(IStreamRequestHandler<GeneratedStreamInvokerRequest, int>),
|
||||
typeof(InvalidThenValidDuplicateStreamInvokerProviderRegistry).GetMethod(
|
||||
nameof(InvokeAlternativeGenerated),
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!);
|
||||
|
||||
private static readonly CqrsStreamInvokerDescriptor ValidDescriptor = new(
|
||||
typeof(IStreamRequestHandler<GeneratedStreamInvokerRequest, int>),
|
||||
typeof(GeneratedStreamInvokerProviderRegistry).GetMethod(
|
||||
"InvokeGenerated",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(IServiceCollection services, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
services.AddTransient(
|
||||
typeof(IStreamRequestHandler<GeneratedStreamInvokerRequest, int>),
|
||||
typeof(GeneratedStreamInvokerRequestHandler));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<CqrsStreamInvokerDescriptorEntry> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建带有 generated request invoker registry 元数据的程序集替身。
|
||||
/// </summary>
|
||||
@ -959,6 +1685,65 @@ internal sealed class CqrsGeneratedRequestInvokerProviderTests
|
||||
.Invoke(cache, Array.Empty<object>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取双键缓存中当前保存的对象。
|
||||
/// </summary>
|
||||
private static object? GetPairCacheValue(object cache, Type primaryType, Type secondaryType)
|
||||
{
|
||||
return InvokeInstanceMethod(cache, "GetValueOrDefaultForTesting", primaryType, secondaryType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取指定 stream dispatch binding 中当前缓存的 pipeline executor。
|
||||
/// </summary>
|
||||
private static object? GetStreamPipelineExecutorValue(
|
||||
object streamBindings,
|
||||
Type requestType,
|
||||
Type responseType,
|
||||
int behaviorCount)
|
||||
{
|
||||
var binding = GetStreamDispatchBindingValue(streamBindings, requestType, responseType);
|
||||
return binding is null
|
||||
? null
|
||||
: InvokeInstanceMethod(binding, "GetPipelineExecutorForTesting", behaviorCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取指定流式请求/响应类型对对应的强类型 stream dispatch binding。
|
||||
/// </summary>
|
||||
private static object? GetStreamDispatchBindingValue(object streamBindings, Type requestType, Type responseType)
|
||||
{
|
||||
var bindingBox = GetPairCacheValue(streamBindings, requestType, responseType);
|
||||
if (bindingBox is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var method = bindingBox.GetType().GetMethod(
|
||||
"Get",
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
|
||||
Assert.That(method, Is.Not.Null, $"Missing stream binding accessor on {bindingBox.GetType().FullName}.");
|
||||
|
||||
return method!
|
||||
.MakeGenericMethod(responseType)
|
||||
.Invoke(bindingBox, Array.Empty<object>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调用目标对象上的实例方法。
|
||||
/// </summary>
|
||||
private static object? InvokeInstanceMethod(object target, string methodName, params object[] arguments)
|
||||
{
|
||||
var method = target.GetType().GetMethod(
|
||||
methodName,
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
|
||||
Assert.That(method, Is.Not.Null, $"Missing method {target.GetType().FullName}.{methodName}.");
|
||||
|
||||
return method!.Invoke(target, arguments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 枚举并收集当前异步流中的全部元素,便于断言 generated stream invoker 的输出。
|
||||
/// </summary>
|
||||
|
||||
@ -138,6 +138,68 @@ internal sealed class CqrsHandlerRegistrarFallbackFailureTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当 generated registry 是抽象类型时,registrar 会记录告警并回退到反射扫描。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void RegisterHandlers_Should_Fall_Back_To_Reflection_When_Generated_Registry_Is_Abstract()
|
||||
{
|
||||
var generatedAssembly = CreateGeneratedRegistryAssembly(
|
||||
"GFramework.Cqrs.Tests.Cqrs.AbstractGeneratedRegistryAssembly, Version=1.0.0.0",
|
||||
typeof(AbstractGeneratedNotificationHandlerRegistry));
|
||||
generatedAssembly
|
||||
.Setup(static assembly => assembly.GetTypes())
|
||||
.Returns([typeof(GeneratedRegistryNotificationHandler)]);
|
||||
|
||||
var container = new MicrosoftDiContainer();
|
||||
CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(
|
||||
GetGeneratedRegistryNotificationHandlerTypes(container),
|
||||
Is.EqualTo([typeof(GeneratedRegistryNotificationHandler)]));
|
||||
Assert.That(
|
||||
GetWarningLogs().Any(log =>
|
||||
log.Message.Contains("because it is abstract", StringComparison.Ordinal)),
|
||||
Is.True);
|
||||
});
|
||||
|
||||
generatedAssembly.Verify(static assembly => assembly.GetTypes(), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当 generated registry 不暴露可访问无参构造器时,registrar 会记录告警并回退到反射扫描。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void RegisterHandlers_Should_Fall_Back_To_Reflection_When_Generated_Registry_Has_No_Parameterless_Constructor()
|
||||
{
|
||||
var generatedAssembly = CreateGeneratedRegistryAssembly(
|
||||
"GFramework.Cqrs.Tests.Cqrs.NoParameterlessGeneratedRegistryAssembly, Version=1.0.0.0",
|
||||
typeof(ConstructorArgumentNotificationHandlerRegistry));
|
||||
generatedAssembly
|
||||
.Setup(static assembly => assembly.GetTypes())
|
||||
.Returns([typeof(GeneratedRegistryNotificationHandler)]);
|
||||
|
||||
var container = new MicrosoftDiContainer();
|
||||
CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(
|
||||
GetGeneratedRegistryNotificationHandlerTypes(container),
|
||||
Is.EqualTo([typeof(GeneratedRegistryNotificationHandler)]));
|
||||
Assert.That(
|
||||
GetWarningLogs().Any(log =>
|
||||
log.Message.Contains(
|
||||
"does not expose an accessible parameterless constructor",
|
||||
StringComparison.Ordinal)),
|
||||
Is.True);
|
||||
});
|
||||
|
||||
generatedAssembly.Verify(static assembly => assembly.GetTypes(), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个仅通过 generated registry 注册主 handler、并附带指定 fallback 元数据的程序集替身。
|
||||
/// </summary>
|
||||
@ -161,6 +223,24 @@ internal sealed class CqrsHandlerRegistrarFallbackFailureTests
|
||||
return generatedAssembly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个只声明 generated registry attribute 的程序集替身,用于验证 registry 激活失败后的回退行为。
|
||||
/// </summary>
|
||||
/// <param name="assemblyName">用于日志与缓存键的程序集名。</param>
|
||||
/// <param name="registryType">要暴露给 registrar 的 generated registry 类型。</param>
|
||||
/// <returns>已完成基础接线的程序集 mock。</returns>
|
||||
private static Mock<Assembly> CreateGeneratedRegistryAssembly(string assemblyName, Type registryType)
|
||||
{
|
||||
var generatedAssembly = new Mock<Assembly>();
|
||||
generatedAssembly
|
||||
.SetupGet(static assembly => assembly.FullName)
|
||||
.Returns(assemblyName);
|
||||
generatedAssembly
|
||||
.Setup(static assembly => assembly.GetCustomAttributes(typeof(CqrsHandlerRegistryAttribute), false))
|
||||
.Returns([new CqrsHandlerRegistryAttribute(registryType)]);
|
||||
return generatedAssembly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提取容器中针对 generated notification 注册的处理器实现类型。
|
||||
/// </summary>
|
||||
@ -259,4 +339,55 @@ internal sealed class CqrsHandlerRegistrarFallbackFailureTests
|
||||
.Where(static log => log.Level == LogLevel.Warning)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 generated registry 被错误声明为抽象类型时的激活失败场景。
|
||||
/// </summary>
|
||||
private abstract class AbstractGeneratedNotificationHandlerRegistry : ICqrsHandlerRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// 抽象 registry 即便具备注册逻辑,也不应被运行时实例化。
|
||||
/// </summary>
|
||||
/// <param name="services">承载处理器映射的服务集合。</param>
|
||||
/// <param name="logger">记录注册诊断的日志器。</param>
|
||||
public void Register(IServiceCollection services, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
services.AddTransient(
|
||||
typeof(INotificationHandler<GeneratedRegistryNotification>),
|
||||
typeof(GeneratedRegistryNotificationHandler));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 generated registry 缺少可访问无参构造器时的激活失败场景。
|
||||
/// </summary>
|
||||
private sealed class ConstructorArgumentNotificationHandlerRegistry : ICqrsHandlerRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一个只能通过额外参数构造的测试 registry。
|
||||
/// </summary>
|
||||
/// <param name="marker">用于区分测试场景的占位参数。</param>
|
||||
public ConstructorArgumentNotificationHandlerRegistry(string marker)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(marker);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 此实现仅用于满足接口契约;本用例关注的是实例化失败前的回退行为。
|
||||
/// </summary>
|
||||
/// <param name="services">承载处理器映射的服务集合。</param>
|
||||
/// <param name="logger">记录注册诊断的日志器。</param>
|
||||
public void Register(IServiceCollection services, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
services.AddTransient(
|
||||
typeof(INotificationHandler<GeneratedRegistryNotification>),
|
||||
typeof(GeneratedRegistryNotificationHandler));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ using GFramework.Core.Architectures;
|
||||
using GFramework.Core.Ioc;
|
||||
using GFramework.Core.Logging;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs;
|
||||
using GFramework.Cqrs.Internal;
|
||||
using GFramework.Cqrs.Tests.Logging;
|
||||
|
||||
namespace GFramework.Cqrs.Tests.Cqrs;
|
||||
@ -170,6 +171,74 @@ internal sealed class CqrsHandlerRegistrarTests
|
||||
Is.EqualTo([typeof(GeneratedRegistryNotificationHandler)]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 direct generated-registry 激活入口在 registry 为抽象类型时会抛出异常,并保留契约告警。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void RegisterGeneratedRegistry_Should_Throw_When_Generated_Registry_Is_Abstract()
|
||||
{
|
||||
var capturingProvider = new CapturingLoggerFactoryProvider(LogLevel.Warning);
|
||||
var logger = capturingProvider.CreateLogger(nameof(CqrsHandlerRegistrarTests));
|
||||
var container = new MicrosoftDiContainer();
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
CqrsHandlerRegistrar.RegisterGeneratedRegistry(
|
||||
container,
|
||||
typeof(AbstractGeneratedNotificationHandlerRegistry),
|
||||
logger));
|
||||
|
||||
var warningLogs = capturingProvider.Loggers
|
||||
.SelectMany(static createdLogger => createdLogger.Logs)
|
||||
.Where(static log => log.Level == LogLevel.Warning)
|
||||
.ToArray();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(exception, Is.Not.Null);
|
||||
Assert.That(exception!.Message, Does.Contain(typeof(AbstractGeneratedNotificationHandlerRegistry).FullName));
|
||||
Assert.That(
|
||||
warningLogs.Any(log =>
|
||||
log.Message.Contains("because it is abstract", StringComparison.Ordinal)),
|
||||
Is.True);
|
||||
Assert.That(container.GetServicesUnsafe, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 direct generated-registry 激活入口在 registry 缺少无参构造器时会抛出异常,并保留契约告警。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void RegisterGeneratedRegistry_Should_Throw_When_Generated_Registry_Has_No_Parameterless_Constructor()
|
||||
{
|
||||
var capturingProvider = new CapturingLoggerFactoryProvider(LogLevel.Warning);
|
||||
var logger = capturingProvider.CreateLogger(nameof(CqrsHandlerRegistrarTests));
|
||||
var container = new MicrosoftDiContainer();
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
CqrsHandlerRegistrar.RegisterGeneratedRegistry(
|
||||
container,
|
||||
typeof(ConstructorArgumentNotificationHandlerRegistry),
|
||||
logger));
|
||||
|
||||
var warningLogs = capturingProvider.Loggers
|
||||
.SelectMany(static createdLogger => createdLogger.Logs)
|
||||
.Where(static log => log.Level == LogLevel.Warning)
|
||||
.ToArray();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(exception, Is.Not.Null);
|
||||
Assert.That(exception!.Message, Does.Contain(typeof(ConstructorArgumentNotificationHandlerRegistry).FullName));
|
||||
Assert.That(
|
||||
warningLogs.Any(log =>
|
||||
log.Message.Contains(
|
||||
"does not expose an accessible parameterless constructor",
|
||||
StringComparison.Ordinal)),
|
||||
Is.True);
|
||||
Assert.That(container.GetServicesUnsafe, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当生成注册器元数据损坏时,运行时会记录告警并回退到反射扫描路径。
|
||||
/// </summary>
|
||||
@ -695,4 +764,55 @@ internal sealed class CqrsHandlerRegistrarTests
|
||||
return typeof(CqrsReflectionFallbackAttribute).Assembly
|
||||
.GetType("GFramework.Cqrs.Internal.CqrsHandlerRegistrar", throwOnError: true)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟被错误声明为抽象类型的 generated registry。
|
||||
/// </summary>
|
||||
private abstract class AbstractGeneratedNotificationHandlerRegistry : ICqrsHandlerRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// 抽象 registry 即便具备注册逻辑,也不应被 direct 激活入口实例化。
|
||||
/// </summary>
|
||||
/// <param name="services">承载处理器映射的服务集合。</param>
|
||||
/// <param name="logger">记录注册诊断的日志器。</param>
|
||||
public void Register(IServiceCollection services, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
services.AddTransient(
|
||||
typeof(INotificationHandler<GeneratedRegistryNotification>),
|
||||
typeof(GeneratedRegistryNotificationHandler));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟缺少无参构造器的 generated registry。
|
||||
/// </summary>
|
||||
private sealed class ConstructorArgumentNotificationHandlerRegistry : ICqrsHandlerRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一个只能通过额外参数构造的测试 registry。
|
||||
/// </summary>
|
||||
/// <param name="marker">用于区分测试场景的占位参数。</param>
|
||||
public ConstructorArgumentNotificationHandlerRegistry(string marker)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(marker);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 此实现仅用于满足接口契约;本用例关注的是构造阶段失败后的异常语义。
|
||||
/// </summary>
|
||||
/// <param name="services">承载处理器映射的服务集合。</param>
|
||||
/// <param name="logger">记录注册诊断的日志器。</param>
|
||||
public void Register(IServiceCollection services, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
services.AddTransient(
|
||||
typeof(INotificationHandler<GeneratedRegistryNotification>),
|
||||
typeof(GeneratedRegistryNotificationHandler));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,6 +91,110 @@ internal sealed class CqrsNotificationPublisherTests
|
||||
Assert.That(handler.ObservedContext, Is.SameAs(architectureContext.Object));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当容器里可见多个通知发布策略时,dispatcher 会拒绝在歧义状态下继续发布。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void PublishAsync_Should_Throw_When_Multiple_NotificationPublishers_Are_Registered()
|
||||
{
|
||||
var runtime = CreateRuntime(
|
||||
container =>
|
||||
{
|
||||
container
|
||||
.Setup(currentContainer => currentContainer.GetAll(typeof(INotificationHandler<PublisherNotification>)))
|
||||
.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."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 dispatcher 在首次发布时解析通知发布器后,会复用同一实例并停止继续查询容器。
|
||||
/// </summary>
|
||||
[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<PublisherNotification>)))
|
||||
.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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当容器里没有任何通知发布器时,dispatcher 会回退到内置顺序发布器,
|
||||
/// 并在首次解析后缓存该 fallback 结果而不是在后续发布时重新查询容器。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task PublishAsync_Should_Fallback_To_SequentialNotificationPublisher_And_Cache_It_When_None_Is_Registered()
|
||||
{
|
||||
var invocationOrder = new List<string>();
|
||||
var notificationPublisherLookupCount = 0;
|
||||
var runtime = CreateRuntime(
|
||||
container =>
|
||||
{
|
||||
container
|
||||
.Setup(currentContainer => currentContainer.GetAll(typeof(INotificationHandler<PublisherNotification>)))
|
||||
.Returns(
|
||||
[
|
||||
new RecordingNotificationHandler("first", invocationOrder),
|
||||
new RecordingNotificationHandler("second", invocationOrder)
|
||||
]);
|
||||
container
|
||||
.Setup(currentContainer => currentContainer.GetAll(typeof(INotificationPublisher)))
|
||||
.Returns(() =>
|
||||
{
|
||||
notificationPublisherLookupCount++;
|
||||
if (notificationPublisherLookupCount == 1)
|
||||
{
|
||||
return Array.Empty<object>();
|
||||
}
|
||||
|
||||
throw new AssertionException("Notification publisher should not be resolved more than 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(invocationOrder, Is.EqualTo(["first", "second", "first", "second"]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证内置 `TaskWhenAll` 发布器会继续调度所有处理器,而不是沿用默认顺序发布器的失败即停语义。
|
||||
/// </summary>
|
||||
@ -183,6 +287,11 @@ internal sealed class CqrsNotificationPublisherTests
|
||||
var container = new Mock<IIocContainer>(MockBehavior.Strict);
|
||||
var logger = new TestLogger(nameof(CqrsNotificationPublisherTests), LogLevel.Debug);
|
||||
|
||||
// 默认 runtime 会延迟解析通知发布器;strict mock 需要声明“未注册自定义 publisher”的空集合返回。
|
||||
container
|
||||
.Setup(currentContainer => currentContainer.GetAll(typeof(INotificationPublisher)))
|
||||
.Returns(Array.Empty<object>());
|
||||
|
||||
configureContainer(container);
|
||||
return CqrsRuntimeFactory.CreateRuntime(container.Object, logger, notificationPublisher);
|
||||
}
|
||||
@ -257,6 +366,11 @@ internal sealed class CqrsNotificationPublisherTests
|
||||
/// </summary>
|
||||
public bool WasCalled { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前发布器累计执行发布的次数。
|
||||
/// </summary>
|
||||
public int PublishCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录当前发布器已被调用,并继续按当前顺序执行所有处理器。
|
||||
/// </summary>
|
||||
@ -270,6 +384,7 @@ internal sealed class CqrsNotificationPublisherTests
|
||||
where TNotification : INotification
|
||||
{
|
||||
WasCalled = true;
|
||||
PublishCallCount++;
|
||||
|
||||
foreach (var handler in context.Handlers)
|
||||
{
|
||||
|
||||
@ -13,6 +13,24 @@ namespace GFramework.Cqrs.Tests.Cqrs;
|
||||
[TestFixture]
|
||||
internal sealed class CqrsRegistrationServiceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证空程序集输入不会触发底层注册,也不会产生重复跳过日志。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void RegisterHandlers_Should_Not_Invoke_Registrar_When_Assemblies_Are_Empty()
|
||||
{
|
||||
var logger = new TestLogger("DefaultCqrsRegistrationService", LogLevel.Debug);
|
||||
var registrar = new Mock<ICqrsHandlerRegistrar>(MockBehavior.Strict);
|
||||
var service = CqrsRuntimeFactory.CreateRegistrationService(registrar.Object, logger);
|
||||
|
||||
service.RegisterHandlers([]);
|
||||
|
||||
registrar.Verify(
|
||||
static currentRegistrar => currentRegistrar.RegisterHandlers(It.IsAny<IEnumerable<Assembly>>()),
|
||||
Times.Never);
|
||||
Assert.That(logger.Logs, Has.Count.EqualTo(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证同一次调用内出现重复程序集键时,底层注册器只会接收到一次注册请求。
|
||||
/// </summary>
|
||||
@ -87,17 +105,201 @@ internal sealed class CqrsRegistrationServiceTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证协调器会忽略空项,并按稳定程序集键排序后仅注册当前调用内的唯一程序集。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void RegisterHandlers_Should_Ignore_Null_Entries_And_Register_Unique_Assemblies_In_Stable_Key_Order()
|
||||
{
|
||||
var logger = new TestLogger("DefaultCqrsRegistrationService", LogLevel.Debug);
|
||||
var registrar = new Mock<ICqrsHandlerRegistrar>(MockBehavior.Strict);
|
||||
var assemblyC = CreateAssembly("GFramework.Cqrs.Tests.Sorting.C, Version=1.0.0.0");
|
||||
var assemblyA = CreateAssembly("GFramework.Cqrs.Tests.Sorting.A, Version=1.0.0.0");
|
||||
var duplicateAssemblyA = CreateAssembly("GFramework.Cqrs.Tests.Sorting.A, Version=1.0.0.0");
|
||||
var assemblyB = CreateAssembly("GFramework.Cqrs.Tests.Sorting.B, Version=1.0.0.0");
|
||||
var registeredAssemblies = new List<Assembly>();
|
||||
|
||||
registrar
|
||||
.Setup(static currentRegistrar => currentRegistrar.RegisterHandlers(It.IsAny<IEnumerable<Assembly>>()))
|
||||
.Callback<IEnumerable<Assembly>>(assemblies => registeredAssemblies.AddRange(assemblies));
|
||||
|
||||
var service = CqrsRuntimeFactory.CreateRegistrationService(registrar.Object, logger);
|
||||
|
||||
service.RegisterHandlers([assemblyC.Object, null!, assemblyA.Object, duplicateAssemblyA.Object, assemblyB.Object, null!]);
|
||||
|
||||
registrar.Verify(
|
||||
static currentRegistrar => currentRegistrar.RegisterHandlers(It.IsAny<IEnumerable<Assembly>>()),
|
||||
Times.Exactly(3));
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(
|
||||
registeredAssemblies,
|
||||
Is.EqualTo([assemblyA.Object, assemblyB.Object, assemblyC.Object]));
|
||||
Assert.That(logger.Logs, Has.Count.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证跨调用遇到已注册程序集键时,协调器会跳过重复项,同时继续按稳定程序集键顺序处理剩余新程序集。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void RegisterHandlers_Should_Skip_Previously_Registered_Keys_And_Keep_Stable_Order_For_Remaining_Assemblies()
|
||||
{
|
||||
var logger = new TestLogger("DefaultCqrsRegistrationService", LogLevel.Debug);
|
||||
var registrar = new Mock<ICqrsHandlerRegistrar>(MockBehavior.Strict);
|
||||
var firstAssembly = CreateAssembly("GFramework.Cqrs.Tests.Sorting.B, Version=1.0.0.0");
|
||||
var duplicateAssembly = CreateAssembly("GFramework.Cqrs.Tests.Sorting.B, Version=1.0.0.0");
|
||||
var assemblyC = CreateAssembly("GFramework.Cqrs.Tests.Sorting.C, Version=1.0.0.0");
|
||||
var assemblyA = CreateAssembly("GFramework.Cqrs.Tests.Sorting.A, Version=1.0.0.0");
|
||||
var registeredAssemblies = new List<Assembly>();
|
||||
|
||||
registrar
|
||||
.Setup(static currentRegistrar => currentRegistrar.RegisterHandlers(It.IsAny<IEnumerable<Assembly>>()))
|
||||
.Callback<IEnumerable<Assembly>>(assemblies => registeredAssemblies.AddRange(assemblies));
|
||||
|
||||
var service = CqrsRuntimeFactory.CreateRegistrationService(registrar.Object, logger);
|
||||
|
||||
service.RegisterHandlers([firstAssembly.Object]);
|
||||
service.RegisterHandlers([assemblyC.Object, duplicateAssembly.Object, assemblyA.Object]);
|
||||
|
||||
registrar.Verify(
|
||||
static currentRegistrar => currentRegistrar.RegisterHandlers(It.IsAny<IEnumerable<Assembly>>()),
|
||||
Times.Exactly(3));
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(
|
||||
registeredAssemblies,
|
||||
Is.EqualTo([firstAssembly.Object, assemblyA.Object, assemblyC.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.Sorting.B, Version=1.0.0.0"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当 <see cref="Assembly.FullName" /> 缺失时,协调器会退化到 <see cref="AssemblyName.Name" /> 作为稳定程序集键。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void RegisterHandlers_Should_Fallback_To_Simple_Name_When_Full_Name_Is_Missing()
|
||||
{
|
||||
var logger = new TestLogger("DefaultCqrsRegistrationService", LogLevel.Debug);
|
||||
var registrar = new Mock<ICqrsHandlerRegistrar>(MockBehavior.Strict);
|
||||
var firstAssembly = CreateAssembly(
|
||||
assemblyFullName: null,
|
||||
assemblySimpleName: "GFramework.Cqrs.Tests.SimpleNameFallback",
|
||||
assemblyDisplayName: "DisplayName-A");
|
||||
var secondAssembly = CreateAssembly(
|
||||
assemblyFullName: null,
|
||||
assemblySimpleName: "GFramework.Cqrs.Tests.SimpleNameFallback",
|
||||
assemblyDisplayName: "DisplayName-B");
|
||||
IEnumerable<Assembly>? registeredAssemblies = null;
|
||||
|
||||
registrar
|
||||
.Setup(static currentRegistrar => currentRegistrar.RegisterHandlers(It.IsAny<IEnumerable<Assembly>>()))
|
||||
.Callback<IEnumerable<Assembly>>(assemblies => registeredAssemblies = assemblies.ToArray());
|
||||
|
||||
var service = CqrsRuntimeFactory.CreateRegistrationService(registrar.Object, logger);
|
||||
|
||||
service.RegisterHandlers([firstAssembly.Object]);
|
||||
service.RegisterHandlers([secondAssembly.Object]);
|
||||
|
||||
registrar.Verify(
|
||||
static currentRegistrar => currentRegistrar.RegisterHandlers(It.IsAny<IEnumerable<Assembly>>()),
|
||||
Times.Once);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(registeredAssemblies, Is.EqualTo([firstAssembly.Object]));
|
||||
var debugMessages = logger.Logs
|
||||
.Where(static log => log.Level == LogLevel.Debug)
|
||||
.Select(static log => log.Message)
|
||||
.ToArray();
|
||||
Assert.That(debugMessages, Has.Length.EqualTo(1));
|
||||
Assert.That(debugMessages[0], Does.Contain("GFramework.Cqrs.Tests.SimpleNameFallback"));
|
||||
Assert.That(debugMessages[0], Does.Not.Contain("DisplayName-B"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当 <see cref="Assembly.FullName" /> 与 <see cref="AssemblyName.Name" /> 均缺失时,
|
||||
/// 协调器会退化到 <see cref="object.ToString" /> 结果作为稳定程序集键。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void RegisterHandlers_Should_Fallback_To_ToString_When_Full_Name_And_Simple_Name_Are_Missing()
|
||||
{
|
||||
var logger = new TestLogger("DefaultCqrsRegistrationService", LogLevel.Debug);
|
||||
var registrar = new Mock<ICqrsHandlerRegistrar>(MockBehavior.Strict);
|
||||
const string assemblyDisplayName = "GFramework.Cqrs.Tests.ToStringFallback";
|
||||
var firstAssembly = CreateAssembly(
|
||||
assemblyFullName: null,
|
||||
assemblySimpleName: null,
|
||||
assemblyDisplayName: assemblyDisplayName);
|
||||
var secondAssembly = CreateAssembly(
|
||||
assemblyFullName: null,
|
||||
assemblySimpleName: null,
|
||||
assemblyDisplayName: assemblyDisplayName);
|
||||
IEnumerable<Assembly>? registeredAssemblies = null;
|
||||
|
||||
registrar
|
||||
.Setup(static currentRegistrar => currentRegistrar.RegisterHandlers(It.IsAny<IEnumerable<Assembly>>()))
|
||||
.Callback<IEnumerable<Assembly>>(assemblies => registeredAssemblies = assemblies.ToArray());
|
||||
|
||||
var service = CqrsRuntimeFactory.CreateRegistrationService(registrar.Object, logger);
|
||||
|
||||
service.RegisterHandlers([firstAssembly.Object]);
|
||||
service.RegisterHandlers([secondAssembly.Object]);
|
||||
|
||||
registrar.Verify(
|
||||
static currentRegistrar => currentRegistrar.RegisterHandlers(It.IsAny<IEnumerable<Assembly>>()),
|
||||
Times.Once);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(registeredAssemblies, Is.EqualTo([firstAssembly.Object]));
|
||||
var debugMessages = logger.Logs
|
||||
.Where(static log => log.Level == LogLevel.Debug)
|
||||
.Select(static log => log.Message)
|
||||
.ToArray();
|
||||
Assert.That(debugMessages, Has.Length.EqualTo(1));
|
||||
Assert.That(debugMessages[0], Does.Contain(assemblyDisplayName));
|
||||
Assert.That(debugMessages[0], Does.Contain("already registered"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个带稳定程序集键的程序集 mock,用于模拟不同 <see cref="Assembly" /> 实例表示同一程序集的场景。
|
||||
/// </summary>
|
||||
/// <param name="assemblyFullName">要返回的程序集完整名称。</param>
|
||||
/// <returns>配置好完整名称的程序集 mock。</returns>
|
||||
private static Mock<Assembly> CreateAssembly(string assemblyFullName)
|
||||
{
|
||||
return CreateAssembly(assemblyFullName, assemblySimpleName: null, assemblyDisplayName: assemblyFullName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个可配置程序集元数据退化路径的程序集 mock,用于验证稳定程序集键的回退顺序。
|
||||
/// </summary>
|
||||
/// <param name="assemblyFullName">要返回的程序集完整名称;为 <see langword="null" /> 时模拟缺失完整名称。</param>
|
||||
/// <param name="assemblySimpleName">要返回的程序集简单名称;为 <see langword="null" /> 时模拟缺失简单名称。</param>
|
||||
/// <param name="assemblyDisplayName">当需要退化到 <see cref="object.ToString" /> 时返回的显示名称。</param>
|
||||
/// <returns>配置好程序集元数据的程序集 mock。</returns>
|
||||
private static Mock<Assembly> CreateAssembly(string? assemblyFullName, string? assemblySimpleName, string assemblyDisplayName)
|
||||
{
|
||||
var assembly = new Mock<Assembly>();
|
||||
var assemblyName = new AssemblyName();
|
||||
assembly
|
||||
.SetupGet(static currentAssembly => currentAssembly.FullName)
|
||||
.Returns(assemblyFullName);
|
||||
assemblyName.Name = assemblySimpleName;
|
||||
assembly
|
||||
.Setup(static currentAssembly => currentAssembly.GetName())
|
||||
.Returns(assemblyName);
|
||||
assembly
|
||||
.Setup(static currentAssembly => currentAssembly.ToString())
|
||||
.Returns(assemblyDisplayName);
|
||||
|
||||
return assembly;
|
||||
}
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs;
|
||||
|
||||
namespace GFramework.Cqrs.Tests.Cqrs;
|
||||
|
||||
/// <summary>
|
||||
/// 处理 <see cref="DispatcherZeroPipelineStreamRequest" />,用于验证零管道 stream 的 dispatcher 缓存路径。
|
||||
/// </summary>
|
||||
internal sealed class DispatcherZeroPipelineStreamHandler : IStreamRequestHandler<DispatcherZeroPipelineStreamRequest, int>
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回一个单元素异步流,便于在缓存测试中最小化处理噪音。
|
||||
/// </summary>
|
||||
/// <param name="request">当前零管道 stream 请求。</param>
|
||||
/// <param name="cancellationToken">用于终止异步枚举的取消令牌。</param>
|
||||
/// <returns>只包含一个元素的异步响应流。</returns>
|
||||
public async IAsyncEnumerable<int> Handle(
|
||||
DispatcherZeroPipelineStreamRequest request,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
yield return 1;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using GFramework.Cqrs.Abstractions.Cqrs;
|
||||
|
||||
namespace GFramework.Cqrs.Tests.Cqrs;
|
||||
|
||||
/// <summary>
|
||||
/// 表示未注册任何 stream pipeline behavior 的最小缓存验证请求。
|
||||
/// </summary>
|
||||
internal sealed record DispatcherZeroPipelineStreamRequest : IStreamRequest<int>;
|
||||
@ -33,6 +33,8 @@ internal sealed class NotificationPublisherRegistrationExtensionsTests
|
||||
container.Register<INotificationHandler<TestNotification>>(trailingHandler);
|
||||
CqrsTestRuntime.RegisterInfrastructure(container);
|
||||
container.Freeze();
|
||||
Assert.That(container.GetAll(typeof(INotificationHandler<TestNotification>)), Has.Count.EqualTo(2));
|
||||
Assert.That(container.GetAll(typeof(INotificationPublisher)), Has.Count.EqualTo(1));
|
||||
|
||||
var context = new ArchitectureContext(container);
|
||||
var publishTask = context.PublishAsync(new TestNotification()).AsTask();
|
||||
@ -66,6 +68,8 @@ internal sealed class NotificationPublisherRegistrationExtensionsTests
|
||||
container.Register<INotificationHandler<TestNotification>>(trailingHandler);
|
||||
CqrsTestRuntime.RegisterInfrastructure(container);
|
||||
container.Freeze();
|
||||
Assert.That(container.GetAll(typeof(INotificationHandler<TestNotification>)), Has.Count.EqualTo(2));
|
||||
Assert.That(container.GetAll(typeof(INotificationPublisher)), Has.Count.EqualTo(1));
|
||||
|
||||
var context = new ArchitectureContext(container);
|
||||
|
||||
@ -91,6 +95,52 @@ internal sealed class NotificationPublisherRegistrationExtensionsTests
|
||||
Assert.That(container.Get<INotificationPublisher>(), Is.SameAs(publisher));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证泛型组合根注册入口会把指定的 publisher 类型注册为容器内唯一的单例策略。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void UseNotificationPublisher_Generic_Overload_Should_Register_Configured_Type()
|
||||
{
|
||||
var container = new MicrosoftDiContainer();
|
||||
|
||||
var returnedContainer = container.UseNotificationPublisher<TrackingNotificationPublisher>();
|
||||
container.Freeze();
|
||||
|
||||
Assert.That(returnedContainer, Is.SameAs(container));
|
||||
Assert.That(container.HasRegistration(typeof(INotificationPublisher)), Is.True);
|
||||
Assert.That(container.GetAll(typeof(INotificationPublisher)), Has.Count.EqualTo(1));
|
||||
Assert.That(container.GetRequired<INotificationPublisher>(), Is.TypeOf<TrackingNotificationPublisher>());
|
||||
Assert.That(container.GetRequired<INotificationPublisher>(), Is.SameAs(container.GetRequired<INotificationPublisher>()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当自定义 publisher 依赖其他容器服务时,泛型组合根入口仍会被默认 runtime 基础设施正确复用。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task UseNotificationPublisher_Generic_Overload_Should_Be_Used_By_Default_Runtime_Infrastructure()
|
||||
{
|
||||
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider();
|
||||
|
||||
var probe = new NotificationPublisherProbe();
|
||||
var handler = new RecordingNotificationHandler();
|
||||
var container = new MicrosoftDiContainer();
|
||||
container.Register(probe);
|
||||
container.UseNotificationPublisher<DependencyAwareNotificationPublisher>();
|
||||
container.Register<INotificationHandler<TestNotification>>(handler);
|
||||
CqrsTestRuntime.RegisterInfrastructure(container);
|
||||
container.Freeze();
|
||||
Assert.That(container.GetAll(typeof(INotificationHandler<TestNotification>)), Has.Count.EqualTo(1));
|
||||
Assert.That(container.GetAll(typeof(INotificationPublisher)), Has.Count.EqualTo(1));
|
||||
|
||||
var context = new ArchitectureContext(container);
|
||||
|
||||
await context.PublishAsync(new TestNotification()).ConfigureAwait(false);
|
||||
|
||||
Assert.That(probe.WasCalled, Is.True);
|
||||
Assert.That(handler.WasInvoked, Is.True);
|
||||
Assert.That(container.GetRequired<INotificationPublisher>(), Is.TypeOf<DependencyAwareNotificationPublisher>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证组合根扩展会阻止重复 notification publisher 注册,避免 runtime 创建阶段才暴露歧义。
|
||||
/// </summary>
|
||||
@ -105,6 +155,20 @@ internal sealed class NotificationPublisherRegistrationExtensionsTests
|
||||
Throws.InvalidOperationException.With.Message.Contains(nameof(INotificationPublisher)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当容器已存在 notification publisher 注册时,泛型组合根入口也会拒绝重复策略声明。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void UseNotificationPublisher_Generic_Overload_Should_Throw_When_NotificationPublisher_Already_Registered()
|
||||
{
|
||||
var container = new MicrosoftDiContainer();
|
||||
container.UseSequentialNotificationPublisher();
|
||||
|
||||
Assert.That(
|
||||
() => container.UseNotificationPublisher<TrackingNotificationPublisher>(),
|
||||
Throws.InvalidOperationException.With.Message.Contains(nameof(INotificationPublisher)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为本组测试提供最小 notification 类型。
|
||||
/// </summary>
|
||||
@ -166,4 +230,48 @@ internal sealed class NotificationPublisherRegistrationExtensionsTests
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录泛型 publisher 是否真正参与了 publish 调用的探针。
|
||||
/// </summary>
|
||||
private sealed class NotificationPublisherProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取探针是否已被自定义 publisher 标记为执行过。
|
||||
/// </summary>
|
||||
public bool WasCalled { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录当前自定义 publisher 已接管本次通知发布。
|
||||
/// </summary>
|
||||
public void MarkCalled()
|
||||
{
|
||||
WasCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 依赖容器内探针服务的自定义 publisher,用于验证泛型重载确实走过了 provider 构造路径。
|
||||
/// </summary>
|
||||
private sealed class DependencyAwareNotificationPublisher(NotificationPublisherProbe probe) : INotificationPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 publisher 已参与调用,再按当前处理器顺序继续执行。
|
||||
/// </summary>
|
||||
public async ValueTask PublishAsync<TNotification>(
|
||||
NotificationPublishContext<TNotification> context,
|
||||
CancellationToken cancellationToken = default)
|
||||
where TNotification : INotification
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
probe.MarkCalled();
|
||||
|
||||
foreach (var handler in context.Handlers)
|
||||
{
|
||||
await context.InvokeHandlerAsync(handler, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,10 @@ public static class CqrsRuntimeFactory
|
||||
/// <param name="container">目标依赖注入容器。</param>
|
||||
/// <param name="logger">用于 runtime 诊断的日志器。</param>
|
||||
/// <returns>默认 CQRS runtime。</returns>
|
||||
/// <remarks>
|
||||
/// 若调用方未显式传入 notification publisher,runtime 会在真正发布通知时优先复用容器里声明的
|
||||
/// <see cref="INotificationPublisher" />;若仍未声明,则回退到默认顺序发布器。
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <paramref name="container" /> 或 <paramref name="logger" /> 为 <see langword="null" />。
|
||||
/// </exception>
|
||||
@ -37,7 +41,10 @@ public static class CqrsRuntimeFactory
|
||||
/// </summary>
|
||||
/// <param name="container">目标依赖注入容器。</param>
|
||||
/// <param name="logger">用于 runtime 诊断的日志器。</param>
|
||||
/// <param name="notificationPublisher">可选的通知发布策略;若为 <see langword="null" /> 则使用默认顺序发布器。</param>
|
||||
/// <param name="notificationPublisher">
|
||||
/// 可选的通知发布策略;若为 <see langword="null" />,runtime 会在发布时优先尝试解析容器中已声明的
|
||||
/// <see cref="INotificationPublisher" />,否则再回退到默认顺序发布器。
|
||||
/// </param>
|
||||
/// <returns>默认 CQRS runtime。</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <paramref name="container" /> 或 <paramref name="logger" /> 为 <see langword="null" />。
|
||||
@ -53,7 +60,7 @@ public static class CqrsRuntimeFactory
|
||||
return new CqrsDispatcher(
|
||||
container,
|
||||
logger,
|
||||
notificationPublisher ?? new SequentialNotificationPublisher());
|
||||
notificationPublisher);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -19,8 +19,17 @@ namespace GFramework.Cqrs.Internal;
|
||||
internal sealed class CqrsDispatcher(
|
||||
IIocContainer container,
|
||||
ILogger logger,
|
||||
INotificationPublisher notificationPublisher) : ICqrsRuntime
|
||||
INotificationPublisher? notificationPublisher) : ICqrsRuntime
|
||||
{
|
||||
// 实例级热路径缓存:默认 runtime 在容器冻结前创建,但请求/stream 行为注册在架构生命周期内保持稳定。
|
||||
// 因此这里按 behavior service type 记住“当前 dispatcher 对应容器里是否存在该行为”,避免 0-pipeline steady-state
|
||||
// 每次 SendAsync 都重复询问容器。缓存值只反映当前 dispatcher 持有容器的注册可见性,不跨 runtime 共享。
|
||||
private readonly ConcurrentDictionary<Type, bool> _requestBehaviorPresenceCache = new();
|
||||
|
||||
// 与 request 路径相同,stream 的 behavior 注册可见性在当前 dispatcher 生命周期内保持稳定。
|
||||
// 这里缓存 “CreateStream(...) 对应 behaviorType 是否存在注册”,避免零管道 stream 每次建流都重复询问容器。
|
||||
private readonly ConcurrentDictionary<Type, bool> _streamBehaviorPresenceCache = new();
|
||||
|
||||
// 卸载安全的进程级缓存:当 generated registry 提供 request invoker 元数据时,
|
||||
// registrar 会按请求/响应类型对把它们写入这里;若类型被卸载,条目会自然失效。
|
||||
private static readonly WeakTypePairCache<GeneratedRequestInvokerMetadata>
|
||||
@ -36,8 +45,9 @@ internal sealed class CqrsDispatcher(
|
||||
private static readonly WeakKeyCache<Type, NotificationDispatchBinding>
|
||||
NotificationDispatchBindings = new();
|
||||
|
||||
// 卸载安全的进程级缓存:请求/响应类型对采用弱键缓存,避免流式消息类型被静态字典永久保留。
|
||||
private static readonly WeakTypePairCache<StreamDispatchBinding>
|
||||
// 卸载安全的进程级缓存:流式请求/响应类型对命中后复用强类型 dispatch binding 盒子,
|
||||
// 避免 stream 响应元素在热路径上退化为 object 桥接,同时仍保持弱键卸载安全语义。
|
||||
private static readonly WeakTypePairCache<StreamDispatchBindingBox>
|
||||
StreamDispatchBindings = new();
|
||||
|
||||
// 卸载安全的进程级缓存:请求/响应类型对命中后复用强类型 dispatch binding;
|
||||
@ -61,9 +71,14 @@ internal sealed class CqrsDispatcher(
|
||||
private static readonly MethodInfo StreamPipelineInvokerMethodDefinition = typeof(CqrsDispatcher)
|
||||
.GetMethod(nameof(InvokeStreamPipelineExecutor), BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
|
||||
private readonly INotificationPublisher _notificationPublisher = notificationPublisher
|
||||
?? throw new ArgumentNullException(
|
||||
nameof(notificationPublisher));
|
||||
// runtime 通常会在容器冻结前创建;此时通过实现类型注册的 notification publisher
|
||||
// 还没有被底层 provider 物化,因此不能只在构造阶段抓取一次。
|
||||
// 显式传入实例时仍优先复用该实例;否则在真正 publish 时再尝试从容器解析。
|
||||
private readonly INotificationPublisher? _notificationPublisher = notificationPublisher;
|
||||
|
||||
// 容器冻结后 notification publisher 解析结果在当前 dispatcher 生命周期内保持稳定;
|
||||
// 因此首次 publish 后缓存最终策略实例,避免后续热路径重复查容器和重复分配默认 publisher。
|
||||
private INotificationPublisher? _resolvedNotificationPublisher;
|
||||
|
||||
/// <summary>
|
||||
/// 发布通知到所有已注册处理器。
|
||||
@ -94,7 +109,7 @@ internal sealed class CqrsDispatcher(
|
||||
}
|
||||
|
||||
var publishContext = CreateNotificationPublishContext(notification, handlers, context, dispatchBinding.Invoker);
|
||||
await _notificationPublisher.PublishAsync(publishContext, cancellationToken).ConfigureAwait(false);
|
||||
await ResolveNotificationPublisher().PublishAsync(publishContext, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -122,7 +137,7 @@ internal sealed class CqrsDispatcher(
|
||||
$"No CQRS request handler registered for {requestType.FullName}.");
|
||||
|
||||
PrepareHandler(handler, context);
|
||||
if (!container.HasRegistration(dispatchBinding.BehaviorType))
|
||||
if (!HasRequestBehaviorRegistration(dispatchBinding.BehaviorType))
|
||||
{
|
||||
return dispatchBinding.RequestInvoker(handler, request, cancellationToken);
|
||||
}
|
||||
@ -144,6 +159,21 @@ internal sealed class CqrsDispatcher(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取当前 dispatcher 容器里是否存在指定 request pipeline 行为注册,并在首次命中后缓存结果。
|
||||
/// </summary>
|
||||
/// <param name="behaviorType">目标 pipeline 行为服务类型。</param>
|
||||
/// <returns>存在注册时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
private bool HasRequestBehaviorRegistration(Type behaviorType)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(behaviorType);
|
||||
|
||||
return _requestBehaviorPresenceCache.GetOrAdd(
|
||||
behaviorType,
|
||||
static (cachedBehaviorType, currentContainer) => currentContainer.HasRegistration(cachedBehaviorType),
|
||||
container);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建流式请求并返回异步响应序列。
|
||||
/// </summary>
|
||||
@ -161,18 +191,15 @@ internal sealed class CqrsDispatcher(
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var requestType = request.GetType();
|
||||
var dispatchBinding = StreamDispatchBindings.GetOrAdd(
|
||||
requestType,
|
||||
typeof(TResponse),
|
||||
static (requestType, responseType) => CreateStreamDispatchBinding(requestType, responseType));
|
||||
var dispatchBinding = GetStreamDispatchBinding<TResponse>(requestType);
|
||||
var handler = container.Get(dispatchBinding.HandlerType)
|
||||
?? throw new InvalidOperationException(
|
||||
$"No CQRS stream handler registered for {requestType.FullName}.");
|
||||
|
||||
PrepareHandler(handler, context);
|
||||
if (!container.HasRegistration(dispatchBinding.BehaviorType))
|
||||
if (!HasStreamBehaviorRegistration(dispatchBinding.BehaviorType))
|
||||
{
|
||||
return (IAsyncEnumerable<TResponse>)dispatchBinding.StreamInvoker(handler, request, cancellationToken);
|
||||
return dispatchBinding.StreamInvoker(handler, request, cancellationToken);
|
||||
}
|
||||
|
||||
var behaviors = container.GetAll(dispatchBinding.BehaviorType);
|
||||
@ -182,10 +209,25 @@ internal sealed class CqrsDispatcher(
|
||||
PrepareHandler(behavior, context);
|
||||
}
|
||||
|
||||
return (IAsyncEnumerable<TResponse>)dispatchBinding.GetPipelineExecutor(behaviors.Count)
|
||||
return dispatchBinding.GetPipelineExecutor(behaviors.Count)
|
||||
.Invoke(handler, behaviors, dispatchBinding.StreamInvoker, request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取当前 dispatcher 容器里是否存在指定 stream pipeline 行为注册,并在首次命中后缓存结果。
|
||||
/// </summary>
|
||||
/// <param name="behaviorType">目标 stream pipeline 行为服务类型。</param>
|
||||
/// <returns>存在注册时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
private bool HasStreamBehaviorRegistration(Type behaviorType)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(behaviorType);
|
||||
|
||||
return _streamBehaviorPresenceCache.GetOrAdd(
|
||||
behaviorType,
|
||||
static (cachedBehaviorType, currentContainer) => currentContainer.HasRegistration(cachedBehaviorType),
|
||||
container);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为上下文感知处理器注入当前 CQRS 分发上下文。
|
||||
/// </summary>
|
||||
@ -203,6 +245,44 @@ internal sealed class CqrsDispatcher(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析当前 publish 调用应使用的 notification publisher。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 显式传入实例的路径优先;若调用方只在组合根里声明了 <see cref="INotificationPublisher" /> 类型映射,
|
||||
/// 则在容器冻结后的首次 publish 才能拿到底层 provider 构造出来的实例。
|
||||
/// 若容器中仍未声明任何策略,则回退到默认顺序发布器。
|
||||
/// </remarks>
|
||||
private INotificationPublisher ResolveNotificationPublisher()
|
||||
{
|
||||
if (_notificationPublisher is not null)
|
||||
{
|
||||
return _notificationPublisher;
|
||||
}
|
||||
|
||||
var resolvedNotificationPublisher = _resolvedNotificationPublisher;
|
||||
if (resolvedNotificationPublisher is not null)
|
||||
{
|
||||
return resolvedNotificationPublisher;
|
||||
}
|
||||
|
||||
var registeredPublishers = container.GetAll(typeof(INotificationPublisher));
|
||||
resolvedNotificationPublisher = registeredPublishers.Count switch
|
||||
{
|
||||
0 => new SequentialNotificationPublisher(),
|
||||
1 => (INotificationPublisher)registeredPublishers[0],
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Multiple {typeof(INotificationPublisher).FullName} instances are registered. Remove duplicate notification publisher strategies before publishing notifications.")
|
||||
};
|
||||
|
||||
Interlocked.CompareExchange(
|
||||
ref _resolvedNotificationPublisher,
|
||||
resolvedNotificationPublisher,
|
||||
comparand: null);
|
||||
|
||||
return _resolvedNotificationPublisher;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为指定请求类型构造完整分发绑定,把服务类型与强类型调用委托一次性收敛到同一缓存项。
|
||||
/// </summary>
|
||||
@ -317,75 +397,114 @@ internal sealed class CqrsDispatcher(
|
||||
/// <summary>
|
||||
/// 为指定流式请求类型构造完整分发绑定,把服务类型与调用委托聚合到同一缓存项。
|
||||
/// </summary>
|
||||
private static StreamDispatchBinding CreateStreamDispatchBinding(Type requestType, Type responseType)
|
||||
private static StreamDispatchBinding<TResponse> CreateStreamDispatchBinding<TResponse>(Type requestType)
|
||||
{
|
||||
var generatedDescriptor = TryGetGeneratedStreamInvokerDescriptor(requestType, responseType);
|
||||
var generatedDescriptor = TryGetGeneratedStreamInvokerDescriptor<TResponse>(requestType);
|
||||
if (generatedDescriptor is not null)
|
||||
{
|
||||
var resolvedGeneratedDescriptor = generatedDescriptor.Value;
|
||||
return new StreamDispatchBinding(
|
||||
return new StreamDispatchBinding<TResponse>(
|
||||
resolvedGeneratedDescriptor.HandlerType,
|
||||
typeof(IStreamPipelineBehavior<,>).MakeGenericType(requestType, responseType),
|
||||
typeof(IStreamPipelineBehavior<,>).MakeGenericType(requestType, typeof(TResponse)),
|
||||
requestType,
|
||||
responseType,
|
||||
resolvedGeneratedDescriptor.Invoker);
|
||||
}
|
||||
|
||||
return new StreamDispatchBinding(
|
||||
typeof(IStreamRequestHandler<,>).MakeGenericType(requestType, responseType),
|
||||
typeof(IStreamPipelineBehavior<,>).MakeGenericType(requestType, responseType),
|
||||
return new StreamDispatchBinding<TResponse>(
|
||||
typeof(IStreamRequestHandler<,>).MakeGenericType(requestType, typeof(TResponse)),
|
||||
typeof(IStreamPipelineBehavior<,>).MakeGenericType(requestType, typeof(TResponse)),
|
||||
requestType,
|
||||
responseType,
|
||||
CreateStreamInvoker(requestType, responseType));
|
||||
CreateStreamInvoker<TResponse>(requestType));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定流式请求/响应类型对的 dispatch binding;若缓存未命中则按当前加载状态创建。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">流式响应元素类型。</typeparam>
|
||||
/// <param name="requestType">流式请求运行时类型。</param>
|
||||
/// <returns>当前请求/响应类型对对应的强类型 stream dispatch binding。</returns>
|
||||
private static StreamDispatchBinding<TResponse> GetStreamDispatchBinding<TResponse>(Type requestType)
|
||||
{
|
||||
var bindingBox = StreamDispatchBindings.GetOrAdd(
|
||||
requestType,
|
||||
typeof(TResponse),
|
||||
static (cachedRequestType, cachedResponseType) =>
|
||||
CreateStreamDispatchBindingBox<TResponse>(cachedRequestType, cachedResponseType));
|
||||
return bindingBox.Get<TResponse>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为弱键流式请求缓存创建强类型 binding 盒子,避免响应元素走 object 结果桥接。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">流式响应元素类型。</typeparam>
|
||||
/// <param name="requestType">流式请求运行时类型。</param>
|
||||
/// <param name="responseType">缓存命中的响应运行时类型。</param>
|
||||
/// <returns>可放入弱键缓存的强类型 binding 盒子。</returns>
|
||||
private static StreamDispatchBindingBox CreateStreamDispatchBindingBox<TResponse>(
|
||||
Type requestType,
|
||||
Type responseType)
|
||||
{
|
||||
if (responseType != typeof(TResponse))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Stream dispatch binding cache expected response type {typeof(TResponse).FullName}, but received {responseType.FullName}.");
|
||||
}
|
||||
|
||||
return StreamDispatchBindingBox.Create(CreateStreamDispatchBinding<TResponse>(requestType));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试从容器已注册的 generated stream invoker provider 中获取指定流式请求/响应类型对的元数据。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">流式响应元素类型。</typeparam>
|
||||
/// <param name="requestType">流式请求运行时类型。</param>
|
||||
/// <param name="responseType">流式响应元素类型。</param>
|
||||
/// <returns>命中时返回强类型化后的描述符;否则返回 <see langword="null" />。</returns>
|
||||
private static StreamInvokerDescriptor? TryGetGeneratedStreamInvokerDescriptor(Type requestType, Type responseType)
|
||||
private static StreamInvokerDescriptor<TResponse>? TryGetGeneratedStreamInvokerDescriptor<TResponse>(Type requestType)
|
||||
{
|
||||
return GeneratedStreamInvokers.TryGetValue(requestType, responseType, out var metadata) &&
|
||||
return GeneratedStreamInvokers.TryGetValue(requestType, typeof(TResponse), out var metadata) &&
|
||||
metadata is not null
|
||||
? CreateStreamInvokerDescriptor(requestType, responseType, metadata)
|
||||
? CreateStreamInvokerDescriptor<TResponse>(requestType, metadata)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把 provider 返回的弱类型描述符转换为 dispatcher 内部使用的 stream invoker 描述符。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">流式响应元素类型。</typeparam>
|
||||
/// <param name="requestType">流式请求运行时类型。</param>
|
||||
/// <param name="responseType">流式响应元素类型。</param>
|
||||
/// <param name="descriptor">provider 返回的弱类型描述符。</param>
|
||||
/// <returns>可直接用于创建 stream dispatch binding 的描述符。</returns>
|
||||
/// <exception cref="InvalidOperationException">当 provider 返回的委托签名与当前流式请求/响应类型对不匹配时抛出。</exception>
|
||||
private static StreamInvokerDescriptor CreateStreamInvokerDescriptor(
|
||||
private static StreamInvokerDescriptor<TResponse> CreateStreamInvokerDescriptor<TResponse>(
|
||||
Type requestType,
|
||||
Type responseType,
|
||||
GeneratedStreamInvokerMetadata descriptor)
|
||||
{
|
||||
if (!descriptor.InvokerMethod.IsStatic)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Generated CQRS stream invoker provider returned a non-static invoker method for request type {requestType.FullName} and response type {responseType.FullName}.");
|
||||
$"Generated CQRS stream invoker provider returned a non-static invoker method for request type {requestType.FullName} and response type {typeof(TResponse).FullName}.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (Delegate.CreateDelegate(typeof(StreamInvoker), descriptor.InvokerMethod) is not StreamInvoker invoker)
|
||||
if (Delegate.CreateDelegate(typeof(WeakStreamInvoker), descriptor.InvokerMethod) is not
|
||||
WeakStreamInvoker weakInvoker)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Generated CQRS stream invoker provider returned an incompatible invoker for request type {requestType.FullName} and response type {responseType.FullName}.");
|
||||
$"Generated CQRS stream invoker provider returned an incompatible invoker for request type {requestType.FullName} and response type {typeof(TResponse).FullName}.");
|
||||
}
|
||||
|
||||
return new StreamInvokerDescriptor(descriptor.HandlerType, invoker);
|
||||
// generated stream descriptor 的公开契约仍以 object 返回值暴露异步流;
|
||||
// 这里在 binding 创建时只做一次适配,把后续 CreateStream 热路径保持为强类型调用。
|
||||
var adapter = new GeneratedStreamInvokerAdapter<TResponse>(weakInvoker);
|
||||
StreamInvoker<TResponse> invoker = (handler, request, cancellationToken) =>
|
||||
adapter.Invoke(handler, request, cancellationToken);
|
||||
return new StreamInvokerDescriptor<TResponse>(descriptor.HandlerType, invoker);
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Generated CQRS stream invoker provider returned an incompatible invoker for request type {requestType.FullName} and response type {responseType.FullName}.",
|
||||
$"Generated CQRS stream invoker provider returned an incompatible invoker for request type {requestType.FullName} and response type {typeof(TResponse).FullName}.",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
@ -457,11 +576,11 @@ internal sealed class CqrsDispatcher(
|
||||
/// <summary>
|
||||
/// 生成流式处理器调用委托,避免每次创建流都重复反射。
|
||||
/// </summary>
|
||||
private static StreamInvoker CreateStreamInvoker(Type requestType, Type responseType)
|
||||
private static StreamInvoker<TResponse> CreateStreamInvoker<TResponse>(Type requestType)
|
||||
{
|
||||
var method = StreamHandlerInvokerMethodDefinition
|
||||
.MakeGenericMethod(requestType, responseType);
|
||||
return (StreamInvoker)Delegate.CreateDelegate(typeof(StreamInvoker), method);
|
||||
.MakeGenericMethod(requestType, typeof(TResponse));
|
||||
return (StreamInvoker<TResponse>)Delegate.CreateDelegate(typeof(StreamInvoker<TResponse>), method);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -512,7 +631,7 @@ internal sealed class CqrsDispatcher(
|
||||
/// <summary>
|
||||
/// 执行已强类型化的流式处理器调用。
|
||||
/// </summary>
|
||||
private static object InvokeStreamHandler<TRequest, TResponse>(
|
||||
private static IAsyncEnumerable<TResponse> InvokeStreamHandler<TRequest, TResponse>(
|
||||
object handler,
|
||||
object request,
|
||||
CancellationToken cancellationToken)
|
||||
@ -527,10 +646,10 @@ internal sealed class CqrsDispatcher(
|
||||
/// 执行指定行为数量的强类型 stream pipeline executor。
|
||||
/// 该入口本身是缓存的固定 executor 形状;每次建流只绑定当前 handler 与 behaviors 实例。
|
||||
/// </summary>
|
||||
private static object InvokeStreamPipelineExecutor<TRequest, TResponse>(
|
||||
private static IAsyncEnumerable<TResponse> InvokeStreamPipelineExecutor<TRequest, TResponse>(
|
||||
object handler,
|
||||
IReadOnlyList<object> behaviors,
|
||||
StreamInvoker streamInvoker,
|
||||
StreamInvoker<TResponse> streamInvoker,
|
||||
object request,
|
||||
CancellationToken cancellationToken)
|
||||
where TRequest : IStreamRequest<TResponse>
|
||||
@ -556,12 +675,17 @@ internal sealed class CqrsDispatcher(
|
||||
private delegate ValueTask NotificationInvoker(object handler, object notification,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
private delegate object StreamInvoker(object handler, object request, CancellationToken cancellationToken);
|
||||
private delegate IAsyncEnumerable<TResponse> StreamInvoker<TResponse>(
|
||||
object handler,
|
||||
object request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
private delegate object StreamPipelineInvoker(
|
||||
private delegate object WeakStreamInvoker(object handler, object request, CancellationToken cancellationToken);
|
||||
|
||||
private delegate IAsyncEnumerable<TResponse> StreamPipelineInvoker<TResponse>(
|
||||
object handler,
|
||||
IReadOnlyList<object> behaviors,
|
||||
StreamInvoker streamInvoker,
|
||||
StreamInvoker<TResponse> streamInvoker,
|
||||
object request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
@ -610,6 +734,72 @@ internal sealed class CqrsDispatcher(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将不同响应类型的 stream dispatch binding 包装到统一弱缓存值中,
|
||||
/// 同时保留强类型流式委托,避免响应元素退化为 object 桥接。
|
||||
/// </summary>
|
||||
private abstract class StreamDispatchBindingBox
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建一个新的强类型 stream dispatch binding 盒子。
|
||||
/// </summary>
|
||||
public static StreamDispatchBindingBox Create<TResponse>(StreamDispatchBinding<TResponse> binding)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(binding);
|
||||
return new StreamDispatchBindingBox<TResponse>(binding);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取指定响应类型的 stream dispatch binding。
|
||||
/// </summary>
|
||||
public abstract StreamDispatchBinding<TResponse> Get<TResponse>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存特定响应类型的 stream dispatch binding。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">流式响应元素类型。</typeparam>
|
||||
private sealed class StreamDispatchBindingBox<TResponse>(StreamDispatchBinding<TResponse> binding)
|
||||
: StreamDispatchBindingBox
|
||||
{
|
||||
private readonly StreamDispatchBinding<TResponse> _binding = binding;
|
||||
|
||||
/// <summary>
|
||||
/// 以原始强类型返回当前 binding;若请求的响应类型不匹配则抛出异常。
|
||||
/// </summary>
|
||||
public override StreamDispatchBinding<TRequestedResponse> Get<TRequestedResponse>()
|
||||
{
|
||||
if (typeof(TRequestedResponse) != typeof(TResponse))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cached stream dispatch binding for {typeof(TResponse).FullName} cannot be used as {typeof(TRequestedResponse).FullName}.");
|
||||
}
|
||||
|
||||
return (StreamDispatchBinding<TRequestedResponse>)(object)_binding;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 generated stream provider 的弱类型开放静态入口适配为 dispatcher 内部的强类型流式委托。
|
||||
/// 适配对象与 binding 同生命周期缓存,避免在每次建流时重复创建桥接闭包。
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">流式响应元素类型。</typeparam>
|
||||
private sealed class GeneratedStreamInvokerAdapter<TResponse>(WeakStreamInvoker invoker)
|
||||
{
|
||||
private readonly WeakStreamInvoker _invoker = invoker;
|
||||
|
||||
/// <summary>
|
||||
/// 调用 generated provider 暴露的弱类型入口,并把返回结果物化为当前响应类型的异步流。
|
||||
/// </summary>
|
||||
public IAsyncEnumerable<TResponse> Invoke(
|
||||
object handler,
|
||||
object request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return (IAsyncEnumerable<TResponse>)_invoker(handler, request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存通知分发路径所需的服务类型与强类型调用委托。
|
||||
/// 该绑定把“容器解析哪个服务类型”与“如何调用处理器”聚合到同一缓存项中。
|
||||
@ -640,17 +830,16 @@ internal sealed class CqrsDispatcher(
|
||||
/// 保存流式请求分发路径所需的服务类型与调用委托。
|
||||
/// 该绑定让建流热路径只需一次缓存命中即可获得解析与调用所需元数据。
|
||||
/// </summary>
|
||||
private sealed class StreamDispatchBinding(
|
||||
private sealed class StreamDispatchBinding<TResponse>(
|
||||
Type handlerType,
|
||||
Type behaviorType,
|
||||
Type requestType,
|
||||
Type responseType,
|
||||
StreamInvoker streamInvoker)
|
||||
StreamInvoker<TResponse> streamInvoker)
|
||||
{
|
||||
// 线程安全:该缓存按 behaviorCount 复用 stream pipeline executor 形状,缓存项只保存委托与数量信息,
|
||||
// 不会跨建流缓存 handler 或 behavior 实例。若不同请求持续出现新的行为数量组合,字典会随之增长。
|
||||
private readonly ConcurrentDictionary<int, StreamPipelineExecutor> _pipelineExecutors = new();
|
||||
private readonly StreamPipelineInvoker _pipelineInvoker = CreateStreamPipelineInvoker(requestType, responseType);
|
||||
private readonly ConcurrentDictionary<int, StreamPipelineExecutor<TResponse>> _pipelineExecutors = new();
|
||||
private readonly StreamPipelineInvoker<TResponse> _pipelineInvoker = CreateStreamPipelineInvoker<TResponse>(requestType);
|
||||
|
||||
/// <summary>
|
||||
/// 获取流式请求处理器在容器中的服务类型。
|
||||
@ -665,19 +854,19 @@ internal sealed class CqrsDispatcher(
|
||||
/// <summary>
|
||||
/// 获取执行流式请求处理器的调用委托。
|
||||
/// </summary>
|
||||
public StreamInvoker StreamInvoker { get; } = streamInvoker;
|
||||
public StreamInvoker<TResponse> StreamInvoker { get; } = streamInvoker;
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定行为数量对应的 stream pipeline executor。
|
||||
/// executor 形状会按行为数量缓存,但不会缓存 handler 或 behavior 实例。
|
||||
/// </summary>
|
||||
public StreamPipelineExecutor GetPipelineExecutor(int behaviorCount)
|
||||
public StreamPipelineExecutor<TResponse> GetPipelineExecutor(int behaviorCount)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(behaviorCount);
|
||||
return _pipelineExecutors.GetOrAdd(
|
||||
return _pipelineExecutors.GetOrAdd<StreamPipelineExecutorFactoryState<TResponse>>(
|
||||
behaviorCount,
|
||||
static (count, state) => new StreamPipelineExecutor(count, state.PipelineInvoker),
|
||||
new StreamPipelineExecutorFactoryState(_pipelineInvoker));
|
||||
static (count, state) => CreateStreamPipelineExecutor(count, state.PipelineInvoker),
|
||||
new StreamPipelineExecutorFactoryState<TResponse>(_pipelineInvoker));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -840,27 +1029,41 @@ internal sealed class CqrsDispatcher(
|
||||
/// </summary>
|
||||
/// <param name="HandlerType">流式请求处理器在容器中的服务类型。</param>
|
||||
/// <param name="Invoker">执行流式请求处理器的调用委托。</param>
|
||||
private readonly record struct StreamInvokerDescriptor(
|
||||
private readonly record struct StreamInvokerDescriptor<TResponse>(
|
||||
Type HandlerType,
|
||||
StreamInvoker Invoker);
|
||||
StreamInvoker<TResponse> Invoker);
|
||||
|
||||
/// <summary>
|
||||
/// 为指定流式请求类型创建可跨多个 behaviorCount 复用的 typed pipeline invoker。
|
||||
/// </summary>
|
||||
private static StreamPipelineInvoker CreateStreamPipelineInvoker(Type requestType, Type responseType)
|
||||
private static StreamPipelineInvoker<TResponse> CreateStreamPipelineInvoker<TResponse>(Type requestType)
|
||||
{
|
||||
var method = StreamPipelineInvokerMethodDefinition
|
||||
.MakeGenericMethod(requestType, responseType);
|
||||
return (StreamPipelineInvoker)Delegate.CreateDelegate(typeof(StreamPipelineInvoker), method);
|
||||
.MakeGenericMethod(requestType, typeof(TResponse));
|
||||
return (StreamPipelineInvoker<TResponse>)Delegate.CreateDelegate(
|
||||
typeof(StreamPipelineInvoker<TResponse>),
|
||||
method);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为指定流式请求/响应类型与固定行为数量创建 pipeline executor。
|
||||
/// 行为数量用于表达缓存形状,实际建流仍会消费本次容器解析出的 handler 与 behaviors 实例。
|
||||
/// </summary>
|
||||
private static StreamPipelineExecutor<TResponse> CreateStreamPipelineExecutor<TResponse>(
|
||||
int behaviorCount,
|
||||
StreamPipelineInvoker<TResponse> invoker)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(behaviorCount);
|
||||
return new StreamPipelineExecutor<TResponse>(behaviorCount, invoker);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存固定行为数量下的 typed stream pipeline executor 形状。
|
||||
/// 该对象自身可跨建流复用,但每次调用都只绑定当前 handler 与 behavior 实例。
|
||||
/// </summary>
|
||||
private sealed class StreamPipelineExecutor(
|
||||
private sealed class StreamPipelineExecutor<TResponse>(
|
||||
int behaviorCount,
|
||||
StreamPipelineInvoker invoker)
|
||||
StreamPipelineInvoker<TResponse> invoker)
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取此 executor 预期处理的行为数量。
|
||||
@ -870,10 +1073,10 @@ internal sealed class CqrsDispatcher(
|
||||
/// <summary>
|
||||
/// 使用当前 handler / behaviors / request 执行缓存的 pipeline 形状。
|
||||
/// </summary>
|
||||
public object Invoke(
|
||||
public IAsyncEnumerable<TResponse> Invoke(
|
||||
object handler,
|
||||
IReadOnlyList<object> behaviors,
|
||||
StreamInvoker streamInvoker,
|
||||
StreamInvoker<TResponse> streamInvoker,
|
||||
object request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@ -890,8 +1093,8 @@ internal sealed class CqrsDispatcher(
|
||||
/// <summary>
|
||||
/// 为 stream pipeline executor 缓存携带 typed pipeline invoker,避免按行为数量建缓存时创建闭包。
|
||||
/// </summary>
|
||||
private readonly record struct StreamPipelineExecutorFactoryState(
|
||||
StreamPipelineInvoker PipelineInvoker);
|
||||
private readonly record struct StreamPipelineExecutorFactoryState<TResponse>(
|
||||
StreamPipelineInvoker<TResponse> PipelineInvoker);
|
||||
|
||||
/// <summary>
|
||||
/// 供 registrar 在 generated registry 激活后登记 request invoker 元数据。
|
||||
@ -1029,12 +1232,12 @@ internal sealed class CqrsDispatcher(
|
||||
/// </summary>
|
||||
private sealed class StreamPipelineInvocation<TRequest, TResponse>(
|
||||
IStreamRequestHandler<TRequest, TResponse> handler,
|
||||
StreamInvoker streamInvoker,
|
||||
StreamInvoker<TResponse> streamInvoker,
|
||||
IReadOnlyList<object> behaviors)
|
||||
where TRequest : IStreamRequest<TResponse>
|
||||
{
|
||||
private readonly IStreamRequestHandler<TRequest, TResponse> _handler = handler;
|
||||
private readonly StreamInvoker _streamInvoker = streamInvoker;
|
||||
private readonly StreamInvoker<TResponse> _streamInvoker = streamInvoker;
|
||||
private readonly IReadOnlyList<object> _behaviors = behaviors;
|
||||
private readonly StreamMessageHandlerDelegate<TRequest, TResponse>?[] _continuations =
|
||||
new StreamMessageHandlerDelegate<TRequest, TResponse>?[behaviors.Count + 1];
|
||||
@ -1085,7 +1288,7 @@ internal sealed class CqrsDispatcher(
|
||||
/// </summary>
|
||||
private IAsyncEnumerable<TResponse> InvokeHandler(TRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return (IAsyncEnumerable<TResponse>)_streamInvoker(_handler, request, cancellationToken);
|
||||
return _streamInvoker(_handler, request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -16,6 +16,13 @@ namespace GFramework.Cqrs.Internal;
|
||||
/// </summary>
|
||||
internal static class CqrsHandlerRegistrar
|
||||
{
|
||||
/// <summary>
|
||||
/// 描述 generated invoker descriptor 在 registrar 预热阶段使用的 request/response 类型对键。
|
||||
/// </summary>
|
||||
/// <param name="RequestType">请求运行时类型。</param>
|
||||
/// <param name="ResponseType">响应运行时类型。</param>
|
||||
private readonly record struct InvokerDescriptorKey(Type RequestType, Type ResponseType);
|
||||
|
||||
// 卸载安全的进程级缓存:程序集元数据只按弱键复用。
|
||||
// 若程序集来自 collectible AssemblyLoadContext,被回收后会重新分析,而不会被静态缓存永久钉住。
|
||||
private static readonly WeakKeyCache<Assembly, AssemblyRegistrationMetadata> AssemblyMetadataCache =
|
||||
@ -321,8 +328,49 @@ internal static class CqrsHandlerRegistrar
|
||||
if (provider is not IEnumeratesCqrsRequestInvokerDescriptors descriptorSource)
|
||||
return;
|
||||
|
||||
foreach (var descriptorEntry in descriptorSource.GetDescriptors())
|
||||
IReadOnlyList<CqrsRequestInvokerDescriptorEntry>? 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<InvokerDescriptorKey>();
|
||||
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<CqrsStreamInvokerDescriptorEntry>? 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<InvokerDescriptorKey>();
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验 request descriptor 枚举项是否与 provider 的显式查询结果保持一致。
|
||||
/// </summary>
|
||||
/// <param name="provider">当前正在预热的 request invoker provider。</param>
|
||||
/// <param name="descriptorEntry">当前枚举到的描述符条目。</param>
|
||||
/// <param name="assemblyName">当前程序集的稳定名称。</param>
|
||||
/// <param name="logger">日志记录器。</param>
|
||||
/// <returns>当该枚举项可安全写入 dispatcher 缓存时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验 stream descriptor 枚举项是否与 provider 的显式查询结果保持一致。
|
||||
/// </summary>
|
||||
/// <param name="provider">当前正在预热的 stream invoker provider。</param>
|
||||
/// <param name="descriptorEntry">当前枚举到的描述符条目。</param>
|
||||
/// <param name="assemblyName">当前程序集的稳定名称。</param>
|
||||
/// <param name="logger">日志记录器。</param>
|
||||
/// <returns>当该枚举项可安全写入 dispatcher 缓存时返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 generated registry 的 fallback 元数据转换为统一的注册结果,并记录下一阶段是定向补扫还是整程序集扫描。
|
||||
/// </summary>
|
||||
|
||||
@ -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,15 +128,17 @@ var playerId = await this.SendAsync(new CreatePlayerCommand(new CreatePlayerInpu
|
||||
- 通知分发
|
||||
- 通知会分发给所有已注册 `INotificationHandler<>`;零处理器时默认静默完成。
|
||||
- 若容器在 runtime 创建前已显式注册 `INotificationPublisher`,默认 runtime 会复用该策略;未注册时回退到内置 `SequentialNotificationPublisher`。
|
||||
- notification publish 不存在 generated invoker 通道;它始终基于当前已注册的 `INotificationHandler<>` 集合和选定的 `INotificationPublisher` 策略执行。
|
||||
- 默认 runtime 只消费一个 `INotificationPublisher`;如果容器里已经存在该注册,再调用 `UseNotificationPublisher(...)`、`UseNotificationPublisher<TPublisher>()`、`UseSequentialNotificationPublisher()` 或 `UseTaskWhenAllNotificationPublisher()` 会直接报错,而不是按“后注册覆盖前注册”处理。
|
||||
- 内置 notification publisher 的推荐选择如下:
|
||||
|
||||
| 策略 | 推荐场景 | 执行顺序 | 失败语义 | 备注 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `SequentialNotificationPublisher` | 需要保持容器顺序,且希望首个失败立即停止后续分发 | 保证按容器解析顺序逐个执行 | 首个处理器抛出异常时立即停止 | 也是默认回退策略 |
|
||||
| `TaskWhenAllNotificationPublisher` | 需要让全部处理器并行完成,并在结束后统一观察失败或取消 | 不保证顺序 | 不会在首个失败时停止其余处理器;会聚合最终异常或取消结果 | 更适合语义补齐,不是性能开关 |
|
||||
| `UseNotificationPublisher(...)` 自定义实例 | 需要接入仓库外的自定义策略或第三方策略 | 取决于具体实现 | 取决于具体实现 | 仅在内置顺序 / 并行策略都不满足时使用 |
|
||||
| `UseNotificationPublisher(...)` / `UseNotificationPublisher<TPublisher>()` | 需要接入仓库外的自定义策略或第三方策略 | 取决于具体实现 | 取决于具体实现 | 前者复用现成实例,后者让容器负责单例生命周期 |
|
||||
|
||||
- 若只是为了降低 fixed fan-out publish 的 steady-state 成本,当前 benchmark 并不表明 `TaskWhenAllNotificationPublisher` 会优于默认顺序发布器;它更适合你需要“等待全部处理器完成并统一观察失败”的场景。
|
||||
- 若只是为了降低 fixed fan-out publish 的 steady-state 成本,当前 benchmark 并不表明 `TaskWhenAllNotificationPublisher` 会优于默认顺序发布器;它更适合你需要“等待全部处理器完成并统一观察失败”的场景,而不是把 publish 切成另一条 generated 或更快的分发通道。
|
||||
|
||||
如果你需要显式保留默认顺序语义,也可以在组合根里直接声明:
|
||||
|
||||
@ -161,6 +165,14 @@ using GFramework.Cqrs.Notification;
|
||||
container.UseNotificationPublisher(new TaskWhenAllNotificationPublisher());
|
||||
```
|
||||
|
||||
如果你希望由容器负责创建并长期复用自定义 publisher,也可以改用泛型重载:
|
||||
|
||||
```csharp
|
||||
using GFramework.Cqrs.Extensions;
|
||||
|
||||
container.UseNotificationPublisher<MyCustomNotificationPublisher>();
|
||||
```
|
||||
|
||||
对于走标准 `GFramework.Core` 启动路径的架构,这些组合根扩展会被默认基础设施自动复用;如果你直接调用 `CqrsRuntimeFactory.CreateRuntime(...)`,也仍然可以像以前一样显式传入 publisher 实例。
|
||||
- 流式请求
|
||||
- 通过 `IStreamRequest<TResponse>` 和 `IStreamRequestHandler<,>` 返回 `IAsyncEnumerable<TResponse>`。
|
||||
@ -183,18 +195,20 @@ container.UseNotificationPublisher(new TaskWhenAllNotificationPublisher());
|
||||
- 优先尝试消费端程序集上的 `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` 的统一入口;只有在需要更换通知策略、接入额外程序集或搭裸容器测试时,再显式配置组合根。
|
||||
|
||||
## 文档入口
|
||||
|
||||
|
||||
52
GFramework.Game.Abstractions/Input/IInputBindingStore.cs
Normal file
52
GFramework.Game.Abstractions/Input/IInputBindingStore.cs
Normal file
@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
namespace GFramework.Game.Abstractions.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 定义逻辑动作绑定的查询、修改与快照导入导出契约。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该接口承担框架输入系统的持久化与重绑定边界。
|
||||
/// 宿主层可以把自己的原生输入系统适配到这里,上层业务则只依赖动作名和绑定描述,不直接接触宿主输入事件。
|
||||
/// </remarks>
|
||||
public interface IInputBindingStore
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定动作的当前绑定。
|
||||
/// </summary>
|
||||
/// <param name="actionName">动作名称。</param>
|
||||
/// <returns>动作绑定快照。</returns>
|
||||
InputActionBinding GetBindings(string actionName);
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有动作的当前绑定快照。
|
||||
/// </summary>
|
||||
/// <returns>全量输入绑定快照。</returns>
|
||||
InputBindingSnapshot ExportSnapshot();
|
||||
|
||||
/// <summary>
|
||||
/// 使用给定快照替换当前绑定。
|
||||
/// </summary>
|
||||
/// <param name="snapshot">要导入的快照。</param>
|
||||
void ImportSnapshot(InputBindingSnapshot snapshot);
|
||||
|
||||
/// <summary>
|
||||
/// 把指定绑定设置为动作的主绑定。
|
||||
/// </summary>
|
||||
/// <param name="actionName">动作名称。</param>
|
||||
/// <param name="binding">新绑定。</param>
|
||||
/// <param name="swapIfTaken">是否在冲突时交换已占用绑定。</param>
|
||||
void SetPrimaryBinding(string actionName, InputBindingDescriptor binding, bool swapIfTaken = true);
|
||||
|
||||
/// <summary>
|
||||
/// 将指定动作恢复为默认绑定。
|
||||
/// </summary>
|
||||
/// <param name="actionName">动作名称。</param>
|
||||
void ResetAction(string actionName);
|
||||
|
||||
/// <summary>
|
||||
/// 将所有动作恢复为默认绑定。
|
||||
/// </summary>
|
||||
void ResetAll();
|
||||
}
|
||||
15
GFramework.Game.Abstractions/Input/IInputDeviceTracker.cs
Normal file
15
GFramework.Game.Abstractions/Input/IInputDeviceTracker.cs
Normal file
@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
namespace GFramework.Game.Abstractions.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 定义当前活跃输入设备上下文的查询入口。
|
||||
/// </summary>
|
||||
public interface IInputDeviceTracker
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取当前输入设备上下文。
|
||||
/// </summary>
|
||||
InputDeviceContext CurrentDevice { get; }
|
||||
}
|
||||
20
GFramework.Game.Abstractions/Input/IUiInputActionMap.cs
Normal file
20
GFramework.Game.Abstractions/Input/IUiInputActionMap.cs
Normal file
@ -0,0 +1,20 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using GFramework.Game.Abstractions.UI;
|
||||
|
||||
namespace GFramework.Game.Abstractions.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 定义逻辑动作名到 UI 语义动作的映射规则。
|
||||
/// </summary>
|
||||
public interface IUiInputActionMap
|
||||
{
|
||||
/// <summary>
|
||||
/// 尝试把逻辑动作映射为 UI 语义动作。
|
||||
/// </summary>
|
||||
/// <param name="actionName">逻辑动作名称。</param>
|
||||
/// <param name="action">映射出的 UI 语义动作。</param>
|
||||
/// <returns>如果映射成功则返回 <see langword="true" />。</returns>
|
||||
bool TryMap(string actionName, out UiInputAction action);
|
||||
}
|
||||
17
GFramework.Game.Abstractions/Input/IUiInputDispatcher.cs
Normal file
17
GFramework.Game.Abstractions/Input/IUiInputDispatcher.cs
Normal file
@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
namespace GFramework.Game.Abstractions.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 定义面向 UI 语义动作的输入分发入口。
|
||||
/// </summary>
|
||||
public interface IUiInputDispatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// 尝试把逻辑动作分发到当前 UI 路由。
|
||||
/// </summary>
|
||||
/// <param name="actionName">逻辑动作名称。</param>
|
||||
/// <returns>如果该动作被映射为 UI 动作并成功分发,则返回 <see langword="true" />。</returns>
|
||||
bool TryDispatch(string actionName);
|
||||
}
|
||||
37
GFramework.Game.Abstractions/Input/InputActionBinding.cs
Normal file
37
GFramework.Game.Abstractions/Input/InputActionBinding.cs
Normal file
@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
namespace GFramework.Game.Abstractions.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 描述一个逻辑动作当前持有的绑定集合。
|
||||
/// </summary>
|
||||
public sealed class InputActionBinding
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一个动作绑定快照。
|
||||
/// </summary>
|
||||
/// <param name="actionName">动作名称。</param>
|
||||
/// <param name="bindings">当前绑定列表。</param>
|
||||
/// <exception cref="ArgumentException">当 <paramref name="actionName" /> 为空时抛出。</exception>
|
||||
public InputActionBinding(string actionName, IReadOnlyList<InputBindingDescriptor> bindings)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(actionName))
|
||||
{
|
||||
throw new ArgumentException("Action name cannot be null or whitespace.", nameof(actionName));
|
||||
}
|
||||
|
||||
ActionName = actionName;
|
||||
Bindings = bindings ?? Array.Empty<InputBindingDescriptor>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取动作名称。
|
||||
/// </summary>
|
||||
public string ActionName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前绑定列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<InputBindingDescriptor> Bindings { get; }
|
||||
}
|
||||
67
GFramework.Game.Abstractions/Input/InputBindingDescriptor.cs
Normal file
67
GFramework.Game.Abstractions/Input/InputBindingDescriptor.cs
Normal file
@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
namespace GFramework.Game.Abstractions.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 描述一个框架无关的动作绑定。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该模型是运行时输入系统与宿主适配层之间的稳定交换格式。
|
||||
/// 宿主层负责把原生输入事件转成此描述,抽象层和默认运行时只根据这些字段做查询、冲突检测和持久化。
|
||||
/// </remarks>
|
||||
public sealed class InputBindingDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一个动作绑定描述。
|
||||
/// </summary>
|
||||
/// <param name="deviceKind">设备族。</param>
|
||||
/// <param name="bindingKind">绑定类型。</param>
|
||||
/// <param name="code">宿主无关的物理码值。</param>
|
||||
/// <param name="displayName">用于设置界面展示的名称。</param>
|
||||
/// <param name="axisDirection">轴向方向;非轴向绑定时为 <see langword="null" />。</param>
|
||||
/// <exception cref="ArgumentException">当 <paramref name="code" /> 为空时抛出。</exception>
|
||||
public InputBindingDescriptor(
|
||||
InputDeviceKind deviceKind,
|
||||
InputBindingKind bindingKind,
|
||||
string code,
|
||||
string displayName,
|
||||
float? axisDirection = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
throw new ArgumentException("Binding code cannot be null or whitespace.", nameof(code));
|
||||
}
|
||||
|
||||
DeviceKind = deviceKind;
|
||||
BindingKind = bindingKind;
|
||||
Code = code;
|
||||
DisplayName = displayName ?? string.Empty;
|
||||
AxisDirection = axisDirection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取设备族。
|
||||
/// </summary>
|
||||
public InputDeviceKind DeviceKind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取绑定类型。
|
||||
/// </summary>
|
||||
public InputBindingKind BindingKind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取宿主无关的物理码值。
|
||||
/// </summary>
|
||||
public string Code { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于展示的标签。
|
||||
/// </summary>
|
||||
public string DisplayName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取轴向方向。
|
||||
/// </summary>
|
||||
public float? AxisDirection { get; }
|
||||
}
|
||||
35
GFramework.Game.Abstractions/Input/InputBindingKind.cs
Normal file
35
GFramework.Game.Abstractions/Input/InputBindingKind.cs
Normal file
@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
namespace GFramework.Game.Abstractions.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 描述一个逻辑绑定使用的物理输入类型。
|
||||
/// </summary>
|
||||
public enum InputBindingKind
|
||||
{
|
||||
/// <summary>
|
||||
/// 未指定。
|
||||
/// </summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 键盘按键。
|
||||
/// </summary>
|
||||
Key = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 鼠标按钮。
|
||||
/// </summary>
|
||||
MouseButton = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 手柄按钮。
|
||||
/// </summary>
|
||||
GamepadButton = 3,
|
||||
|
||||
/// <summary>
|
||||
/// 手柄轴向。
|
||||
/// </summary>
|
||||
GamepadAxis = 4
|
||||
}
|
||||
24
GFramework.Game.Abstractions/Input/InputBindingSnapshot.cs
Normal file
24
GFramework.Game.Abstractions/Input/InputBindingSnapshot.cs
Normal file
@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
namespace GFramework.Game.Abstractions.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 描述一组动作绑定的可持久化快照。
|
||||
/// </summary>
|
||||
public sealed class InputBindingSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一个输入绑定快照。
|
||||
/// </summary>
|
||||
/// <param name="actions">动作绑定集合。</param>
|
||||
public InputBindingSnapshot(IReadOnlyList<InputActionBinding> actions)
|
||||
{
|
||||
Actions = actions ?? Array.Empty<InputActionBinding>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取动作绑定集合。
|
||||
/// </summary>
|
||||
public IReadOnlyList<InputActionBinding> Actions { get; }
|
||||
}
|
||||
41
GFramework.Game.Abstractions/Input/InputDeviceContext.cs
Normal file
41
GFramework.Game.Abstractions/Input/InputDeviceContext.cs
Normal file
@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
namespace GFramework.Game.Abstractions.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 描述当前活跃输入设备上下文。
|
||||
/// </summary>
|
||||
public sealed class InputDeviceContext
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一个输入设备上下文。
|
||||
/// </summary>
|
||||
/// <param name="deviceKind">当前设备族。</param>
|
||||
/// <param name="deviceIndex">设备索引;未知时为 <see langword="null" />。</param>
|
||||
/// <param name="deviceName">宿主归一化后的设备名称。</param>
|
||||
public InputDeviceContext(
|
||||
InputDeviceKind deviceKind,
|
||||
int? deviceIndex = null,
|
||||
string? deviceName = null)
|
||||
{
|
||||
DeviceKind = deviceKind;
|
||||
DeviceIndex = deviceIndex;
|
||||
DeviceName = deviceName ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前设备族。
|
||||
/// </summary>
|
||||
public InputDeviceKind DeviceKind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前设备索引。
|
||||
/// </summary>
|
||||
public int? DeviceIndex { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取宿主归一化后的设备名称。
|
||||
/// </summary>
|
||||
public string DeviceName { get; }
|
||||
}
|
||||
34
GFramework.Game.Abstractions/Input/InputDeviceKind.cs
Normal file
34
GFramework.Game.Abstractions/Input/InputDeviceKind.cs
Normal file
@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
namespace GFramework.Game.Abstractions.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 描述框架级输入设备族。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该枚举用于跨宿主共享“当前输入来自哪一类设备”的语义。
|
||||
/// 它故意避免暴露 Godot、Unity 或平台 SDK 的原生事件类型,确保上层业务只依赖稳定的设备族判断。
|
||||
/// </remarks>
|
||||
public enum InputDeviceKind
|
||||
{
|
||||
/// <summary>
|
||||
/// 未识别或尚未产生任何输入。
|
||||
/// </summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 键盘与鼠标输入。
|
||||
/// </summary>
|
||||
KeyboardMouse = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 游戏手柄输入。
|
||||
/// </summary>
|
||||
Gamepad = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 触摸输入。
|
||||
/// </summary>
|
||||
Touch = 3
|
||||
}
|
||||
@ -13,6 +13,7 @@
|
||||
- 典型使用场景:
|
||||
- 定义 `IScene`、`IUiPage`、`ISettingsData`、`IData` 等业务对象
|
||||
- 让 feature 包只感知 `IConfigRegistry`、`ISaveRepository<T>`、`ISettingsModel`、`IUiRouter`、`ISceneRouter`
|
||||
- 在输入层共享动作绑定、设备上下文和 UI 语义桥接契约
|
||||
- 在引擎适配层之外共享设置、场景参数、UI 参数、存档数据类型
|
||||
|
||||
## 与相邻包的关系
|
||||
@ -131,6 +132,18 @@ UI 页面与路由契约。
|
||||
|
||||
`IUiRouter` 不只覆盖页面栈,还覆盖 Overlay / Modal / Toast / Topmost 等层级 UI 语义。
|
||||
|
||||
### `Input/`
|
||||
|
||||
- `InputBindingDescriptor`
|
||||
- `InputActionBinding`
|
||||
- `InputBindingSnapshot`
|
||||
- `IInputBindingStore`
|
||||
- `IInputDeviceTracker`
|
||||
- `IUiInputActionMap`
|
||||
- `IUiInputDispatcher`
|
||||
|
||||
这一层定义的是统一输入抽象、绑定快照与 UI 语义桥接契约。
|
||||
|
||||
### `Routing/`
|
||||
|
||||
- `IRoute`
|
||||
@ -164,6 +177,7 @@ Scene 与 UI 路由共享这套基础约定。
|
||||
| `Setting/` | `ISettingsData`、`ISettingsModel`、`ISettingsSystem`、`LocalizationSettings` | 看设置数据、应用语义、迁移接口和内置设置对象 |
|
||||
| `Scene/` | `IScene`、`ISceneRouter`、`ISceneFactory`、`SceneTransitionEvent` | 看场景行为、路由、工厂 / root 边界与转场事件模型 |
|
||||
| `UI/` | `IUiPage`、`IUiRouter`、`IUiFactory`、`UiInteractionProfile`、`UiTransitionHandlerOptions` | 看页面栈、层级 UI、输入动作与 UI 转场契约 |
|
||||
| `Input/` | `InputBindingDescriptor`、`IInputBindingStore`、`IInputDeviceTracker`、`IUiInputDispatcher` | 看动作绑定、设备上下文和 UI 输入桥接契约 |
|
||||
| `Routing/` `Storage/` `Asset/` `Enums/` | `IRoute`、`IRouteContext`、`IFileStorage`、`IAssetRegistry<T>`、`UiLayer`、`SceneTransitionType` | 看公共路由上下文、存储角色、资源注册表与跨层共享枚举 |
|
||||
|
||||
## 最小接入路径
|
||||
@ -267,6 +281,7 @@ public sealed class ContinueGameCommandHandler
|
||||
- 序列化系统:[序列化系统](../docs/zh-CN/game/serialization.md)
|
||||
- 场景系统:[场景系统](../docs/zh-CN/game/scene.md)
|
||||
- UI 系统:[UI 系统](../docs/zh-CN/game/ui.md)
|
||||
- 输入系统:[输入系统](../docs/zh-CN/game/input.md)
|
||||
|
||||
## 选择建议
|
||||
|
||||
|
||||
@ -21,3 +21,4 @@
|
||||
GF_ConfigSchema_014 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
|
||||
GF_ConfigSchema_015 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
|
||||
GF_ConfigSchema_016 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
|
||||
GF_ConfigSchema_017 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
|
||||
|
||||
@ -237,6 +237,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
return TryValidateStringFormatMetadataRecursively(filePath, "<root>", root, out diagnostic) &&
|
||||
TryValidateUnsupportedCombinatorKeywordsRecursively(filePath, "<root>", root, out diagnostic) &&
|
||||
TryValidateUnsupportedOpenObjectKeywordsRecursively(filePath, "<root>", root, out diagnostic) &&
|
||||
TryValidateUnsupportedArrayShapeKeywordsRecursively(filePath, "<root>", root, out diagnostic) &&
|
||||
TryValidateDependentRequiredMetadataRecursively(filePath, "<root>", root, out diagnostic) &&
|
||||
TryValidateDependentSchemasMetadataRecursively(filePath, "<root>", root, out diagnostic) &&
|
||||
TryValidateAllOfMetadataRecursively(filePath, "<root>", root, out diagnostic) &&
|
||||
@ -916,6 +917,40 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
out diagnostic);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 递归拒绝当前共享子集尚未支持的数组形状关键字。
|
||||
/// 当前配置系统只接受单个 object-typed <c>items</c> schema,
|
||||
/// 并继续拒绝 tuple / open-array 关键字,避免生成器对数组元素形状
|
||||
/// 比运行时与工具链更宽松。
|
||||
/// </summary>
|
||||
/// <param name="filePath">Schema 文件路径。</param>
|
||||
/// <param name="displayPath">逻辑字段路径。</param>
|
||||
/// <param name="element">当前 schema 节点。</param>
|
||||
/// <param name="diagnostic">失败时返回的诊断。</param>
|
||||
/// <returns>当前节点树是否未声明不支持的数组形状关键字。</returns>
|
||||
private static bool TryValidateUnsupportedArrayShapeKeywordsRecursively(
|
||||
string filePath,
|
||||
string displayPath,
|
||||
JsonElement element,
|
||||
out Diagnostic? diagnostic)
|
||||
{
|
||||
return TryTraverseSchemaRecursively(
|
||||
filePath,
|
||||
displayPath,
|
||||
element,
|
||||
static (currentFilePath, currentDisplayPath, currentElement, _) =>
|
||||
{
|
||||
return TryValidateUnsupportedArrayShapeKeywords(
|
||||
currentFilePath,
|
||||
currentDisplayPath,
|
||||
currentElement,
|
||||
out var currentDiagnostic)
|
||||
? (true, (Diagnostic?)null)
|
||||
: (false, currentDiagnostic);
|
||||
},
|
||||
out diagnostic);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当前节点是否声明了会改变生成类型形状的未支持组合关键字。
|
||||
/// </summary>
|
||||
@ -976,6 +1011,36 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证当前节点是否声明了当前共享子集尚未支持的数组形状关键字。
|
||||
/// </summary>
|
||||
/// <param name="filePath">Schema 文件路径。</param>
|
||||
/// <param name="displayPath">逻辑字段路径。</param>
|
||||
/// <param name="element">当前 schema 节点。</param>
|
||||
/// <param name="diagnostic">失败时返回的诊断。</param>
|
||||
/// <returns>未声明不支持关键字时返回 <see langword="true" />。</returns>
|
||||
private static bool TryValidateUnsupportedArrayShapeKeywords(
|
||||
string filePath,
|
||||
string displayPath,
|
||||
JsonElement element,
|
||||
out Diagnostic? diagnostic)
|
||||
{
|
||||
diagnostic = null;
|
||||
if (TryGetUnsupportedArrayShapeKeywordName(element) is not { } keywordName)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
diagnostic = Diagnostic.Create(
|
||||
ConfigSchemaDiagnostics.UnsupportedArrayShapeKeyword,
|
||||
CreateFileLocation(filePath),
|
||||
Path.GetFileName(filePath),
|
||||
displayPath,
|
||||
keywordName,
|
||||
"The current config schema subset only accepts one object-valued 'items' schema and rejects tuple or open-array keywords that can change item shape across Runtime, Generator, and Tooling.");
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回当前节点声明的首个未支持组合关键字。
|
||||
/// </summary>
|
||||
@ -1007,6 +1072,19 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回当前节点声明的首个未支持数组形状关键字。
|
||||
/// </summary>
|
||||
/// <param name="element">当前 schema 节点。</param>
|
||||
/// <returns>命中的关键字名称;未声明时返回空。</returns>
|
||||
private static string? TryGetUnsupportedArrayShapeKeywordName(JsonElement element)
|
||||
{
|
||||
return element.TryGetProperty("prefixItems", out _) ? "prefixItems" :
|
||||
element.TryGetProperty("additionalItems", out _) ? "additionalItems" :
|
||||
element.TryGetProperty("unevaluatedItems", out _) ? "unevaluatedItems" :
|
||||
null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。
|
||||
/// 该遍历覆盖对象属性、<c>dependentSchemas</c> / <c>allOf</c> /
|
||||
|
||||
@ -187,4 +187,15 @@ public static class ConfigSchemaDiagnostics
|
||||
SourceGeneratorsConfigCategory,
|
||||
DiagnosticSeverity.Error,
|
||||
true);
|
||||
|
||||
/// <summary>
|
||||
/// schema 节点声明了当前共享子集尚未支持的数组形状关键字。
|
||||
/// </summary>
|
||||
public static readonly DiagnosticDescriptor UnsupportedArrayShapeKeyword = new(
|
||||
"GF_ConfigSchema_017",
|
||||
"Config schema uses an unsupported array-shape keyword",
|
||||
"Property '{1}' in schema file '{0}' uses unsupported array-shape keyword '{2}': {3}",
|
||||
SourceGeneratorsConfigCategory,
|
||||
DiagnosticSeverity.Error,
|
||||
true);
|
||||
}
|
||||
|
||||
@ -1636,6 +1636,68 @@ public class YamlConfigLoaderTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证数组字段声明 tuple / open-array 关键字时,会在 schema 解析阶段被显式拒绝。
|
||||
/// </summary>
|
||||
/// <param name="keywordName">待验证的数组形状关键字名称。</param>
|
||||
/// <param name="keywordValueJson">用于拼接测试 schema 的关键字值 JSON 片段。</param>
|
||||
[TestCase("prefixItems", """
|
||||
[
|
||||
{ "type": "integer" }
|
||||
]
|
||||
""")]
|
||||
[TestCase("additionalItems", "false")]
|
||||
[TestCase("unevaluatedItems", "false")]
|
||||
public void LoadAsync_Should_Throw_When_Array_Schema_Declares_Unsupported_ArrayShape_Keyword(
|
||||
string keywordName,
|
||||
string keywordValueJson)
|
||||
{
|
||||
CreateConfigFile(
|
||||
"monster/slime.yaml",
|
||||
"""
|
||||
id: 1
|
||||
name: Slime
|
||||
dropRates:
|
||||
- 5
|
||||
""");
|
||||
CreateSchemaFile(
|
||||
"schemas/monster.schema.json",
|
||||
$$"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "name", "dropRates"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"{{keywordName}}": {{keywordValueJson}},
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loader = new YamlConfigLoader(_rootPath)
|
||||
.RegisterTable<int, MonsterConfigIntegerArrayStub>("monster", "monster", "schemas/monster.schema.json",
|
||||
static config => config.Id);
|
||||
var registry = new ConfigRegistry();
|
||||
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(() => loader.LoadAsync(registry));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(exception, Is.Not.Null);
|
||||
Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.SchemaUnsupported));
|
||||
Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("dropRates"));
|
||||
Assert.That(exception.Message, Does.Contain($"unsupported '{keywordName}' metadata"));
|
||||
Assert.That(exception.Message, Does.Contain("only accepts one object-valued 'items' schema"));
|
||||
Assert.That(registry.Count, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证对象数组的 <c>contains</c> 试匹配会按声明属性子集工作,而不会因额外字段误判为不匹配。
|
||||
/// </summary>
|
||||
|
||||
95
GFramework.Game.Tests/Input/InputBindingStoreTests.cs
Normal file
95
GFramework.Game.Tests/Input/InputBindingStoreTests.cs
Normal file
@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using GFramework.Game.Abstractions.Input;
|
||||
using GFramework.Game.Input;
|
||||
|
||||
namespace GFramework.Game.Tests.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 验证默认输入绑定存储的重绑定、冲突交换与默认恢复行为。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class InputBindingStoreTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证主绑定冲突时,会把原绑定交换回被占用动作。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void SetPrimaryBinding_WhenBindingOwnedByAnotherAction_SwapsBindings()
|
||||
{
|
||||
var store = CreateStore();
|
||||
var replacement = new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.Key,
|
||||
"key:68",
|
||||
"D");
|
||||
|
||||
store.SetPrimaryBinding("move_left", replacement);
|
||||
|
||||
var moveLeft = store.GetBindings("move_left");
|
||||
var moveRight = store.GetBindings("move_right");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(moveLeft.Bindings[0].Code, Is.EqualTo("key:68"));
|
||||
Assert.That(moveRight.Bindings[0].Code, Is.EqualTo("key:65"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证重置全部绑定时,会回退到初始化默认快照。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ResetAll_Should_Restore_DefaultSnapshot()
|
||||
{
|
||||
var store = CreateStore();
|
||||
store.SetPrimaryBinding(
|
||||
"move_left",
|
||||
new InputBindingDescriptor(InputDeviceKind.KeyboardMouse, InputBindingKind.Key, "key:81", "Q"));
|
||||
|
||||
store.ResetAll();
|
||||
var snapshot = store.ExportSnapshot();
|
||||
|
||||
Assert.That(
|
||||
snapshot.Actions.Single(action => string.Equals(action.ActionName, "move_left", StringComparison.Ordinal)).Bindings[0].Code,
|
||||
Is.EqualTo("key:65"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证查询不存在的动作时,不会把空条目写回当前快照。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void GetBindings_WhenActionMissing_Should_NotMutateSnapshot()
|
||||
{
|
||||
var store = CreateStore();
|
||||
|
||||
var missingBindings = store.GetBindings("jump");
|
||||
var snapshot = store.ExportSnapshot();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(missingBindings.ActionName, Is.EqualTo("jump"));
|
||||
Assert.That(missingBindings.Bindings, Is.Empty);
|
||||
Assert.That(snapshot.Actions.Any(action => string.Equals(action.ActionName, "jump", StringComparison.Ordinal)), Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
private static InputBindingStore CreateStore()
|
||||
{
|
||||
return new InputBindingStore(
|
||||
new InputBindingSnapshot(
|
||||
[
|
||||
new InputActionBinding(
|
||||
"move_left",
|
||||
[
|
||||
new InputBindingDescriptor(InputDeviceKind.KeyboardMouse, InputBindingKind.Key, "key:65", "A")
|
||||
]),
|
||||
new InputActionBinding(
|
||||
"move_right",
|
||||
[
|
||||
new InputBindingDescriptor(InputDeviceKind.KeyboardMouse, InputBindingKind.Key, "key:68", "D")
|
||||
])
|
||||
]));
|
||||
}
|
||||
}
|
||||
52
GFramework.Game.Tests/Input/UiInputDispatcherTests.cs
Normal file
52
GFramework.Game.Tests/Input/UiInputDispatcherTests.cs
Normal file
@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using GFramework.Game.Abstractions.Input;
|
||||
using GFramework.Game.Input;
|
||||
|
||||
namespace GFramework.Game.Tests.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 验证逻辑动作到 UI 路由分发的默认桥接行为。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class UiInputDispatcherTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证 `ui_cancel` 会被映射为 `UiInputAction.Cancel` 并继续分发给路由器。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void TryDispatch_WhenActionCanMapToUiAction_ForwardsToRouter()
|
||||
{
|
||||
var router = new Mock<IUiRouter>();
|
||||
router.Setup(mock => mock.TryDispatchUiAction(UiInputAction.Cancel)).Returns(true);
|
||||
|
||||
var dispatcher = new UiInputDispatcher(new UiInputActionMap(), router.Object);
|
||||
|
||||
var dispatched = dispatcher.TryDispatch("ui_cancel");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(dispatched, Is.True);
|
||||
router.Verify(mock => mock.TryDispatchUiAction(UiInputAction.Cancel), Times.Once);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证未映射的逻辑动作不会触发 UI 路由。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void TryDispatch_WhenActionIsUnknown_ReturnsFalseWithoutRouting()
|
||||
{
|
||||
var router = new Mock<IUiRouter>();
|
||||
var dispatcher = new UiInputDispatcher(new UiInputActionMap(), router.Object);
|
||||
|
||||
var dispatched = dispatcher.TryDispatch("inventory_toggle");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(dispatched, Is.False);
|
||||
router.Verify(mock => mock.TryDispatchUiAction(It.IsAny<UiInputAction>()), Times.Never);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -326,6 +326,7 @@ internal static partial class YamlConfigSchemaValidator
|
||||
{
|
||||
ValidateUnsupportedCombinatorKeywords(tableName, schemaPath, propertyPath, element);
|
||||
ValidateUnsupportedOpenObjectKeywords(tableName, schemaPath, propertyPath, element);
|
||||
ValidateUnsupportedArrayShapeKeywords(tableName, schemaPath, propertyPath, element);
|
||||
var typeName = ResolveNodeTypeName(tableName, schemaPath, propertyPath, element);
|
||||
var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element);
|
||||
ValidateObjectOnlyKeywords(tableName, schemaPath, propertyPath, element, typeName);
|
||||
@ -401,6 +402,36 @@ internal static partial class YamlConfigSchemaValidator
|
||||
displayPath: GetDiagnosticPath(propertyPath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显式拒绝当前共享子集中尚未支持的数组形状关键字。
|
||||
/// 当前配置系统只接受单个 object-valued <c>items</c> schema,
|
||||
/// 并继续拒绝 tuple / open-array 关键字,避免 Runtime / Generator / Tooling
|
||||
/// 对数组元素形状产生静默漂移。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">当前节点的逻辑属性路径。</param>
|
||||
/// <param name="element">当前 schema 节点。</param>
|
||||
private static void ValidateUnsupportedArrayShapeKeywords(
|
||||
string tableName,
|
||||
string schemaPath,
|
||||
string propertyPath,
|
||||
JsonElement element)
|
||||
{
|
||||
if (TryGetUnsupportedArrayShapeKeywordName(element) is not { } keywordName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw ConfigLoadExceptionFactory.Create(
|
||||
ConfigLoadFailureKind.SchemaUnsupported,
|
||||
tableName,
|
||||
$"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported '{keywordName}' metadata. " +
|
||||
"The current config schema subset only accepts one object-valued 'items' schema and rejects tuple or open-array keywords that can change item shape across Runtime, Generator, and Tooling.",
|
||||
schemaPath: schemaPath,
|
||||
displayPath: GetDiagnosticPath(propertyPath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回当前节点声明的首个未支持组合关键字。
|
||||
/// </summary>
|
||||
@ -432,6 +463,19 @@ internal static partial class YamlConfigSchemaValidator
|
||||
null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回当前节点声明的首个未支持数组形状关键字。
|
||||
/// </summary>
|
||||
/// <param name="element">当前 schema 节点。</param>
|
||||
/// <returns>命中的关键字名称;未声明时返回空。</returns>
|
||||
private static string? TryGetUnsupportedArrayShapeKeywordName(JsonElement element)
|
||||
{
|
||||
return element.TryGetProperty("prefixItems", out _) ? "prefixItems" :
|
||||
element.TryGetProperty("additionalItems", out _) ? "additionalItems" :
|
||||
element.TryGetProperty("unevaluatedItems", out _) ? "unevaluatedItems" :
|
||||
null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析 schema 节点声明的类型名称,并在缺失或类型错误时立刻给出定位清晰的诊断。
|
||||
/// </summary>
|
||||
|
||||
192
GFramework.Game/Input/InputBindingStore.cs
Normal file
192
GFramework.Game/Input/InputBindingStore.cs
Normal file
@ -0,0 +1,192 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using GFramework.Game.Abstractions.Input;
|
||||
|
||||
namespace GFramework.Game.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 提供基于内存快照的默认输入绑定存储实现。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该实现聚焦于框架级动作绑定管理语义:默认值恢复、主绑定替换、冲突交换与快照导入导出。
|
||||
/// 它不依赖具体宿主输入事件,适合作为 `Game` 层默认运行时与单元测试基线。
|
||||
/// 该类型内部使用普通 `Dictionary` / `List` 保存可变状态,不提供额外同步原语。
|
||||
/// 宿主应在同一输入线程或受控的串行配置阶段访问它;如果存在跨线程读写需求,应由外层协调同步。
|
||||
/// </remarks>
|
||||
public sealed class InputBindingStore : IInputBindingStore
|
||||
{
|
||||
private readonly Dictionary<string, List<InputBindingDescriptor>> _defaultBindings;
|
||||
private readonly Dictionary<string, List<InputBindingDescriptor>> _currentBindings;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化输入绑定存储。
|
||||
/// </summary>
|
||||
/// <param name="defaultSnapshot">默认绑定快照。</param>
|
||||
public InputBindingStore(InputBindingSnapshot defaultSnapshot)
|
||||
{
|
||||
_defaultBindings = ToDictionary(defaultSnapshot);
|
||||
_currentBindings = CloneDictionary(_defaultBindings);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public InputActionBinding GetBindings(string actionName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionName);
|
||||
|
||||
return _currentBindings.TryGetValue(actionName, out var bindings)
|
||||
? new InputActionBinding(actionName, bindings.ToArray())
|
||||
: new InputActionBinding(actionName, Array.Empty<InputBindingDescriptor>());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public InputBindingSnapshot ExportSnapshot()
|
||||
{
|
||||
var actions = _currentBindings
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(static pair => new InputActionBinding(pair.Key, pair.Value.ToArray()))
|
||||
.ToArray();
|
||||
|
||||
return new InputBindingSnapshot(actions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ImportSnapshot(InputBindingSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
_currentBindings.Clear();
|
||||
foreach (var action in snapshot.Actions)
|
||||
{
|
||||
_currentBindings[action.ActionName] = [..action.Bindings];
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetPrimaryBinding(string actionName, InputBindingDescriptor binding, bool swapIfTaken = true)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionName);
|
||||
ArgumentNullException.ThrowIfNull(binding);
|
||||
|
||||
var targetBindings = GetOrCreateBindings(actionName);
|
||||
var existingOwner = FindOwner(actionName, binding);
|
||||
|
||||
if (existingOwner is not null)
|
||||
{
|
||||
if (!swapIfTaken)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var previousPrimary = targetBindings.Count > 0 ? targetBindings[0] : null;
|
||||
var ownerBindings = GetOrCreateBindings(existingOwner);
|
||||
ReplaceBinding(ownerBindings, binding, previousPrimary);
|
||||
}
|
||||
|
||||
RemoveBinding(targetBindings, binding);
|
||||
targetBindings.Insert(0, binding);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetAction(string actionName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionName);
|
||||
|
||||
if (_defaultBindings.TryGetValue(actionName, out var bindings))
|
||||
{
|
||||
_currentBindings[actionName] = [..bindings];
|
||||
return;
|
||||
}
|
||||
|
||||
_currentBindings.Remove(actionName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetAll()
|
||||
{
|
||||
_currentBindings.Clear();
|
||||
foreach (var pair in _defaultBindings)
|
||||
{
|
||||
_currentBindings[pair.Key] = [..pair.Value];
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<InputBindingDescriptor>> ToDictionary(InputBindingSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
return snapshot.Actions.ToDictionary(
|
||||
static action => action.ActionName,
|
||||
static action => action.Bindings.ToList(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<InputBindingDescriptor>> CloneDictionary(
|
||||
IReadOnlyDictionary<string, List<InputBindingDescriptor>> source)
|
||||
{
|
||||
return source.ToDictionary(
|
||||
static pair => pair.Key,
|
||||
static pair => pair.Value.ToList(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static void RemoveBinding(List<InputBindingDescriptor> bindings, InputBindingDescriptor binding)
|
||||
{
|
||||
bindings.RemoveAll(existing => AreEquivalent(existing, binding));
|
||||
}
|
||||
|
||||
private static void ReplaceBinding(
|
||||
List<InputBindingDescriptor> bindings,
|
||||
InputBindingDescriptor bindingToReplace,
|
||||
InputBindingDescriptor? replacement)
|
||||
{
|
||||
var index = bindings.FindIndex(existing => AreEquivalent(existing, bindingToReplace));
|
||||
if (index < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bindings.RemoveAt(index);
|
||||
if (replacement is not null)
|
||||
{
|
||||
bindings.Insert(index, replacement);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AreEquivalent(InputBindingDescriptor left, InputBindingDescriptor right)
|
||||
{
|
||||
return left.DeviceKind == right.DeviceKind
|
||||
&& left.BindingKind == right.BindingKind
|
||||
&& string.Equals(left.Code, right.Code, StringComparison.Ordinal)
|
||||
&& Nullable.Equals(left.AxisDirection, right.AxisDirection);
|
||||
}
|
||||
|
||||
private List<InputBindingDescriptor> GetOrCreateBindings(string actionName)
|
||||
{
|
||||
if (!_currentBindings.TryGetValue(actionName, out var bindings))
|
||||
{
|
||||
bindings = [];
|
||||
_currentBindings[actionName] = bindings;
|
||||
}
|
||||
|
||||
return bindings;
|
||||
}
|
||||
|
||||
private string? FindOwner(string excludedActionName, InputBindingDescriptor binding)
|
||||
{
|
||||
foreach (var pair in _currentBindings)
|
||||
{
|
||||
if (string.Equals(pair.Key, excludedActionName, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pair.Value.Any(existing => AreEquivalent(existing, binding)))
|
||||
{
|
||||
return pair.Key;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
41
GFramework.Game/Input/InputDeviceTracker.cs
Normal file
41
GFramework.Game/Input/InputDeviceTracker.cs
Normal file
@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using GFramework.Game.Abstractions.Input;
|
||||
|
||||
namespace GFramework.Game.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 提供可由宿主侧更新的默认输入设备跟踪器。
|
||||
/// </summary>
|
||||
public sealed class InputDeviceTracker : IInputDeviceTracker
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化输入设备跟踪器。
|
||||
/// </summary>
|
||||
public InputDeviceTracker()
|
||||
{
|
||||
CurrentDevice = new InputDeviceContext(InputDeviceKind.Unknown);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// 该属性不提供额外同步原语。
|
||||
/// 宿主应在同一输入线程内调用 <see cref="Update" /> 并读取当前值,例如 Godot 的主线程或输入事件线程。
|
||||
/// </remarks>
|
||||
public InputDeviceContext CurrentDevice { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用新的宿主设备上下文覆盖当前状态。
|
||||
/// </summary>
|
||||
/// <param name="context">新的设备上下文。</param>
|
||||
/// <remarks>
|
||||
/// 该方法设计给宿主输入线程串行调用。
|
||||
/// 如果宿主需要跨线程读取设备上下文,应在外层提供自己的同步策略,而不是依赖此类型完成可见性保证。
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="context" /> 为 <see langword="null" /> 时抛出。</exception>
|
||||
public void Update(InputDeviceContext context)
|
||||
{
|
||||
CurrentDevice = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
}
|
||||
39
GFramework.Game/Input/UiInputActionMap.cs
Normal file
39
GFramework.Game/Input/UiInputActionMap.cs
Normal file
@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using GFramework.Game.Abstractions.Input;
|
||||
using GFramework.Game.Abstractions.UI;
|
||||
|
||||
namespace GFramework.Game.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 提供动作名称到 UI 语义动作的默认映射实现。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 默认映射只负责桥接现有 `UiInputAction` 语义,并通过字符串别名兼容 Godot 常见 `ui_*` 动作命名。
|
||||
/// 更复杂的项目级 action map 可以通过自定义实现覆盖该行为。
|
||||
/// </remarks>
|
||||
public sealed class UiInputActionMap : IUiInputActionMap
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, UiInputAction> DefaultMappings =
|
||||
new Dictionary<string, UiInputAction>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["cancel"] = UiInputAction.Cancel,
|
||||
["ui_cancel"] = UiInputAction.Cancel,
|
||||
["confirm"] = UiInputAction.Confirm,
|
||||
["ui_accept"] = UiInputAction.Confirm,
|
||||
["submit"] = UiInputAction.Confirm
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryMap(string actionName, out UiInputAction action)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(actionName))
|
||||
{
|
||||
action = UiInputAction.None;
|
||||
return false;
|
||||
}
|
||||
|
||||
return DefaultMappings.TryGetValue(actionName, out action);
|
||||
}
|
||||
}
|
||||
40
GFramework.Game/Input/UiInputDispatcher.cs
Normal file
40
GFramework.Game/Input/UiInputDispatcher.cs
Normal file
@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using GFramework.Game.Abstractions.Input;
|
||||
using GFramework.Game.Abstractions.UI;
|
||||
|
||||
namespace GFramework.Game.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 提供逻辑动作到 UI 路由语义分发的默认桥接。
|
||||
/// </summary>
|
||||
public sealed class UiInputDispatcher : IUiInputDispatcher
|
||||
{
|
||||
private readonly IUiInputActionMap _actionMap;
|
||||
private readonly IUiRouter _router;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 UI 输入分发器。
|
||||
/// </summary>
|
||||
/// <param name="actionMap">动作映射表。</param>
|
||||
/// <param name="router">目标 UI 路由器。</param>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="actionMap" /> 为 <see langword="null" /> 时抛出。</exception>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="router" /> 为 <see langword="null" /> 时抛出。</exception>
|
||||
public UiInputDispatcher(IUiInputActionMap actionMap, IUiRouter router)
|
||||
{
|
||||
_actionMap = actionMap ?? throw new ArgumentNullException(nameof(actionMap));
|
||||
_router = router ?? throw new ArgumentNullException(nameof(router));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryDispatch(string actionName)
|
||||
{
|
||||
if (!_actionMap.TryMap(actionName, out var action))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _router.TryDispatchUiAction(action);
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@
|
||||
- 数据与存档:`Data/`
|
||||
- 设置系统:`Setting/`
|
||||
- 场景与 UI 路由基类:`Scene/`、`UI/`
|
||||
- 动作绑定与 UI 输入桥接:`Input/`
|
||||
- 序列化与文件存储:`Serializer/`、`Storage/`
|
||||
|
||||
## 与相邻包的关系
|
||||
@ -158,6 +159,22 @@
|
||||
- [场景系统](../docs/zh-CN/game/scene.md)
|
||||
- [UI 系统](../docs/zh-CN/game/ui.md)
|
||||
|
||||
### `Input/`
|
||||
|
||||
- `InputBindingStore`
|
||||
- 纯托管动作绑定存储
|
||||
- 提供默认快照恢复、主绑定替换、冲突交换与快照导入导出
|
||||
- `InputDeviceTracker`
|
||||
- 提供当前活跃设备上下文的默认持有者
|
||||
- `UiInputActionMap`
|
||||
- 把 `ui_accept` / `ui_cancel` 等逻辑动作桥接到 `UiInputAction`
|
||||
- `UiInputDispatcher`
|
||||
- 把逻辑动作名继续分发给 `IUiRouter`
|
||||
|
||||
对应文档:
|
||||
|
||||
- [输入系统](../docs/zh-CN/game/input.md)
|
||||
|
||||
### `Routing/` 与 `State/`
|
||||
|
||||
- `Routing/RouterBase<TRoute, TContext>`
|
||||
@ -176,6 +193,7 @@
|
||||
| `Config/` | `YamlConfigLoader`、`ConfigRegistry`、`GameConfigBootstrap`、`YamlConfigSchemaValidator` | 看 YAML 加载、schema 校验、模块接入与热重载边界 |
|
||||
| `Data/` `Storage/` `Serializer/` | `DataRepository`、`SaveRepository<TSaveData>`、`UnifiedSettingsDataRepository`、`FileStorage`、`JsonSerializer` | 看持久化布局、槽位存档、统一设置文件和底层序列化 / 存储实现 |
|
||||
| `Setting/` | `SettingsModel<TRepository>`、`SettingsSystem`、`SettingsAppliedEvent<T>` | 看初始化、应用、保存、重置等设置生命周期编排 |
|
||||
| `Input/` | `InputBindingStore`、`InputDeviceTracker`、`UiInputActionMap`、`UiInputDispatcher` | 看动作绑定、设备上下文和 UI 输入桥接的默认运行时实现 |
|
||||
| `Scene/` `UI/` `Routing/` | `SceneRouterBase`、`UiRouterBase`、`SceneTransitionPipeline`、`UiTransitionPipeline`、`RouterBase<TRoute, TContext>` | 看路由基类、转换处理器和项目层需要自己提供的 factory / root 边界 |
|
||||
| `Extensions/` `Internal/` `State/` | `DataLocationExtensions`、`VersionedMigrationRunner`、`GameStateMachineSystem` | 看辅助扩展、内部迁移执行逻辑和游戏态状态机封装 |
|
||||
|
||||
@ -354,6 +372,7 @@ public sealed class MyUiRouter : UiRouterBase
|
||||
- 序列化系统:[序列化系统](../docs/zh-CN/game/serialization.md)
|
||||
- 场景系统:[场景系统](../docs/zh-CN/game/scene.md)
|
||||
- UI 系统:[UI 系统](../docs/zh-CN/game/ui.md)
|
||||
- 输入系统:[输入系统](../docs/zh-CN/game/input.md)
|
||||
|
||||
## 什么时候不该直接依赖本包
|
||||
|
||||
|
||||
316
GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs
Normal file
316
GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs
Normal file
@ -0,0 +1,316 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using GFramework.Game.Abstractions.Input;
|
||||
using GFramework.Godot.Input;
|
||||
|
||||
namespace GFramework.Godot.Tests.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Godot 输入绑定存储在纯托管后端上的动作快照、导入与冲突交换语义。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class GodotInputBindingStoreTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证导出快照会反映后端提供的框架绑定描述。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ExportSnapshot_Should_ReturnBackendBindings()
|
||||
{
|
||||
var backend = new FakeInputMapBackend(
|
||||
new InputBindingSnapshot(
|
||||
[
|
||||
new InputActionBinding(
|
||||
"ui_accept",
|
||||
[
|
||||
new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.Key,
|
||||
"key:13",
|
||||
"Enter")
|
||||
])
|
||||
]));
|
||||
|
||||
var store = new GodotInputBindingStore(backend);
|
||||
var snapshot = store.ExportSnapshot();
|
||||
var acceptBindings = snapshot.Actions.Single(
|
||||
action => string.Equals(action.ActionName, "ui_accept", StringComparison.Ordinal));
|
||||
|
||||
Assert.That(acceptBindings.Bindings[0].Code, Is.EqualTo("key:13"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证导入快照后会把新绑定回写到后端,并能重新导出。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ImportSnapshot_Should_UpdateBackendBindings()
|
||||
{
|
||||
var backend = new FakeInputMapBackend(
|
||||
new InputBindingSnapshot(
|
||||
[
|
||||
new InputActionBinding(
|
||||
"ui_accept",
|
||||
[
|
||||
new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.Key,
|
||||
"key:13",
|
||||
"Enter")
|
||||
])
|
||||
]));
|
||||
|
||||
var store = new GodotInputBindingStore(backend);
|
||||
store.ImportSnapshot(
|
||||
new InputBindingSnapshot(
|
||||
[
|
||||
new InputActionBinding(
|
||||
"ui_accept",
|
||||
[
|
||||
new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.Key,
|
||||
"key:32",
|
||||
"Space")
|
||||
])
|
||||
]));
|
||||
|
||||
var snapshot = store.ExportSnapshot();
|
||||
var acceptBindings = snapshot.Actions.Single(
|
||||
action => string.Equals(action.ActionName, "ui_accept", StringComparison.Ordinal));
|
||||
|
||||
Assert.That(acceptBindings.Bindings[0].Code, Is.EqualTo("key:32"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证导入快照时,会清空快照中未出现动作的后端绑定。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ImportSnapshot_WhenActionMissingFromSnapshot_Should_ClearBackendBindings()
|
||||
{
|
||||
var backend = new FakeInputMapBackend(
|
||||
new InputBindingSnapshot(
|
||||
[
|
||||
new InputActionBinding(
|
||||
"ui_accept",
|
||||
[
|
||||
new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.Key,
|
||||
"key:13",
|
||||
"Enter")
|
||||
]),
|
||||
new InputActionBinding(
|
||||
"ui_cancel",
|
||||
[
|
||||
new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.Key,
|
||||
"key:27",
|
||||
"Escape")
|
||||
])
|
||||
]));
|
||||
|
||||
var store = new GodotInputBindingStore(backend);
|
||||
store.ImportSnapshot(
|
||||
new InputBindingSnapshot(
|
||||
[
|
||||
new InputActionBinding(
|
||||
"ui_accept",
|
||||
[
|
||||
new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.Key,
|
||||
"key:32",
|
||||
"Space")
|
||||
])
|
||||
]));
|
||||
|
||||
var snapshot = store.ExportSnapshot();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(
|
||||
snapshot.Actions.Single(action => string.Equals(action.ActionName, "ui_accept", StringComparison.Ordinal)).Bindings[0].Code,
|
||||
Is.EqualTo("key:32"));
|
||||
Assert.That(
|
||||
snapshot.Actions.Single(action => string.Equals(action.ActionName, "ui_cancel", StringComparison.Ordinal)).Bindings,
|
||||
Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证从纯托管绑定设置主绑定时,会保留 `Game` 层冲突交换语义。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void SetPrimaryBinding_WhenBindingTaken_SwapsBackendBindings()
|
||||
{
|
||||
var backend = new FakeInputMapBackend(
|
||||
new InputBindingSnapshot(
|
||||
[
|
||||
new InputActionBinding(
|
||||
"move_left",
|
||||
[
|
||||
new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.Key,
|
||||
"key:65",
|
||||
"A")
|
||||
]),
|
||||
new InputActionBinding(
|
||||
"move_right",
|
||||
[
|
||||
new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.Key,
|
||||
"key:68",
|
||||
"D")
|
||||
])
|
||||
]));
|
||||
|
||||
var store = new GodotInputBindingStore(backend);
|
||||
store.SetPrimaryBinding(
|
||||
"move_left",
|
||||
new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.Key,
|
||||
"key:68",
|
||||
"D"));
|
||||
|
||||
var snapshot = store.ExportSnapshot();
|
||||
var moveLeft = snapshot.Actions.Single(
|
||||
action => string.Equals(action.ActionName, "move_left", StringComparison.Ordinal));
|
||||
var moveRight = snapshot.Actions.Single(
|
||||
action => string.Equals(action.ActionName, "move_right", StringComparison.Ordinal));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(moveLeft.Bindings[0].Code, Is.EqualTo("key:68"));
|
||||
Assert.That(moveRight.Bindings[0].Code, Is.EqualTo("key:65"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证重置全部绑定时,会移除运行时新增且默认快照中不存在的动作。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ResetAll_WhenRuntimeActionIsNotInDefaults_Should_RemoveAction()
|
||||
{
|
||||
var backend = new FakeInputMapBackend(
|
||||
new InputBindingSnapshot(
|
||||
[
|
||||
new InputActionBinding(
|
||||
"ui_accept",
|
||||
[
|
||||
new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.Key,
|
||||
"key:13",
|
||||
"Enter")
|
||||
])
|
||||
]));
|
||||
|
||||
var store = new GodotInputBindingStore(backend);
|
||||
store.ImportSnapshot(
|
||||
new InputBindingSnapshot(
|
||||
[
|
||||
new InputActionBinding(
|
||||
"ui_accept",
|
||||
[
|
||||
new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.Key,
|
||||
"key:13",
|
||||
"Enter")
|
||||
]),
|
||||
new InputActionBinding(
|
||||
"debug_toggle",
|
||||
[
|
||||
new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.Key,
|
||||
"key:192",
|
||||
"QuoteLeft")
|
||||
])
|
||||
]));
|
||||
|
||||
store.ResetAll();
|
||||
|
||||
var snapshot = store.ExportSnapshot();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(
|
||||
snapshot.Actions.Any(action => string.Equals(action.ActionName, "ui_accept", StringComparison.Ordinal)),
|
||||
Is.True);
|
||||
Assert.That(
|
||||
snapshot.Actions.Any(action => string.Equals(action.ActionName, "debug_toggle", StringComparison.Ordinal)),
|
||||
Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试用的纯托管 InputMap 后端。
|
||||
/// </summary>
|
||||
private sealed class FakeInputMapBackend : IGodotInputMapBackend
|
||||
{
|
||||
private readonly Dictionary<string, List<InputBindingDescriptor>> _defaults;
|
||||
private readonly Dictionary<string, List<InputBindingDescriptor>> _current;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化测试后端。
|
||||
/// </summary>
|
||||
/// <param name="snapshot">初始快照。</param>
|
||||
public FakeInputMapBackend(InputBindingSnapshot snapshot)
|
||||
{
|
||||
_defaults = snapshot.Actions.ToDictionary(
|
||||
static action => action.ActionName,
|
||||
static action => action.Bindings.ToList(),
|
||||
StringComparer.Ordinal);
|
||||
_current = snapshot.Actions.ToDictionary(
|
||||
static action => action.ActionName,
|
||||
static action => action.Bindings.ToList(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> GetActionNames()
|
||||
{
|
||||
return [.._current.Keys.OrderBy(static key => key, StringComparer.Ordinal)];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<InputBindingDescriptor> GetBindings(string actionName)
|
||||
{
|
||||
return _current.TryGetValue(actionName, out var bindings) ? [..bindings] : Array.Empty<InputBindingDescriptor>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetBindings(string actionName, IReadOnlyList<InputBindingDescriptor> bindings)
|
||||
{
|
||||
_current[actionName] = [..bindings];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetAction(string actionName)
|
||||
{
|
||||
if (_defaults.TryGetValue(actionName, out var bindings))
|
||||
{
|
||||
_current[actionName] = [..bindings];
|
||||
return;
|
||||
}
|
||||
|
||||
_current.Remove(actionName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetAll()
|
||||
{
|
||||
_current.Clear();
|
||||
foreach (var pair in _defaults)
|
||||
{
|
||||
_current[pair.Key] = [..pair.Value];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
GFramework.Godot/Input/GodotInputBindingCodec.cs
Normal file
191
GFramework.Godot/Input/GodotInputBindingCodec.cs
Normal file
@ -0,0 +1,191 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using System.Globalization;
|
||||
using GFramework.Game.Abstractions.Input;
|
||||
|
||||
namespace GFramework.Godot.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 负责在 Godot 原生输入事件与框架绑定描述之间做双向转换。
|
||||
/// </summary>
|
||||
internal static class GodotInputBindingCodec
|
||||
{
|
||||
/// <summary>
|
||||
/// 尝试把原生输入事件转换成框架绑定描述。
|
||||
/// </summary>
|
||||
/// <param name="inputEvent">原生输入事件。</param>
|
||||
/// <param name="binding">转换后的绑定描述。</param>
|
||||
/// <returns>如果转换成功则返回 <see langword="true" />。</returns>
|
||||
public static bool TryCreateBinding(InputEvent inputEvent, out InputBindingDescriptor binding)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inputEvent);
|
||||
|
||||
switch (inputEvent)
|
||||
{
|
||||
case InputEventKey keyEvent:
|
||||
var keyCode = GetKeyCode(keyEvent);
|
||||
binding = new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.Key,
|
||||
FormattableString.Invariant($"key:{(int)keyCode}"),
|
||||
keyCode.ToString());
|
||||
return true;
|
||||
case InputEventMouseButton mouseButtonEvent:
|
||||
binding = new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.MouseButton,
|
||||
FormattableString.Invariant($"mouse:{(int)mouseButtonEvent.ButtonIndex}"),
|
||||
mouseButtonEvent.ButtonIndex.ToString());
|
||||
return true;
|
||||
case InputEventJoypadButton joypadButtonEvent:
|
||||
binding = new InputBindingDescriptor(
|
||||
InputDeviceKind.Gamepad,
|
||||
InputBindingKind.GamepadButton,
|
||||
FormattableString.Invariant($"joy-button:{(int)joypadButtonEvent.ButtonIndex}"),
|
||||
joypadButtonEvent.ButtonIndex.ToString());
|
||||
return true;
|
||||
case InputEventJoypadMotion joypadMotionEvent:
|
||||
var direction = joypadMotionEvent.AxisValue >= 0f ? 1f : -1f;
|
||||
binding = new InputBindingDescriptor(
|
||||
InputDeviceKind.Gamepad,
|
||||
InputBindingKind.GamepadAxis,
|
||||
FormattableString.Invariant($"joy-axis:{(int)joypadMotionEvent.Axis}:{direction.ToString(CultureInfo.InvariantCulture)}"),
|
||||
GetAxisDisplayName(joypadMotionEvent.Axis, direction),
|
||||
direction);
|
||||
return true;
|
||||
default:
|
||||
binding = null!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把框架绑定描述还原为 Godot 输入事件。
|
||||
/// </summary>
|
||||
/// <param name="binding">绑定描述。</param>
|
||||
/// <returns>可写回 `InputMap` 的输入事件。</returns>
|
||||
/// <exception cref="ArgumentException">当绑定描述无法转换时抛出。</exception>
|
||||
public static InputEvent CreateInputEvent(InputBindingDescriptor binding)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(binding);
|
||||
|
||||
return binding.BindingKind switch
|
||||
{
|
||||
InputBindingKind.Key => CreateKeyEvent(binding),
|
||||
InputBindingKind.MouseButton => CreateMouseButtonEvent(binding),
|
||||
InputBindingKind.GamepadButton => CreateGamepadButtonEvent(binding),
|
||||
InputBindingKind.GamepadAxis => CreateGamepadAxisEvent(binding),
|
||||
_ => throw new ArgumentException($"Unsupported binding kind '{binding.BindingKind}'.", nameof(binding))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从原生输入事件推断当前设备上下文。
|
||||
/// </summary>
|
||||
/// <param name="inputEvent">原生输入事件。</param>
|
||||
/// <returns>推断出的设备上下文。</returns>
|
||||
public static InputDeviceContext GetDeviceContext(InputEvent inputEvent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inputEvent);
|
||||
|
||||
return inputEvent switch
|
||||
{
|
||||
InputEventKey => new InputDeviceContext(InputDeviceKind.KeyboardMouse),
|
||||
InputEventMouse => new InputDeviceContext(InputDeviceKind.KeyboardMouse),
|
||||
InputEventJoypadButton joypadButtonEvent => CreateGamepadContext(joypadButtonEvent.Device),
|
||||
InputEventJoypadMotion joypadMotionEvent => CreateGamepadContext(joypadMotionEvent.Device),
|
||||
InputEventScreenTouch => new InputDeviceContext(InputDeviceKind.Touch),
|
||||
_ => new InputDeviceContext(InputDeviceKind.Unknown)
|
||||
};
|
||||
}
|
||||
|
||||
private static InputDeviceContext CreateGamepadContext(int deviceIndex)
|
||||
{
|
||||
return new InputDeviceContext(
|
||||
InputDeviceKind.Gamepad,
|
||||
deviceIndex,
|
||||
"gamepad");
|
||||
}
|
||||
|
||||
private static InputEventKey CreateKeyEvent(InputBindingDescriptor binding)
|
||||
{
|
||||
var code = ParseSingleSegment(binding.Code, "key");
|
||||
return new InputEventKey
|
||||
{
|
||||
Keycode = (Key)code,
|
||||
PhysicalKeycode = (Key)code
|
||||
};
|
||||
}
|
||||
|
||||
private static InputEventMouseButton CreateMouseButtonEvent(InputBindingDescriptor binding)
|
||||
{
|
||||
var buttonIndex = ParseSingleSegment(binding.Code, "mouse");
|
||||
return new InputEventMouseButton
|
||||
{
|
||||
ButtonIndex = (MouseButton)buttonIndex
|
||||
};
|
||||
}
|
||||
|
||||
private static InputEventJoypadButton CreateGamepadButtonEvent(InputBindingDescriptor binding)
|
||||
{
|
||||
var buttonIndex = ParseSingleSegment(binding.Code, "joy-button");
|
||||
return new InputEventJoypadButton
|
||||
{
|
||||
ButtonIndex = (JoyButton)buttonIndex
|
||||
};
|
||||
}
|
||||
|
||||
private static InputEventJoypadMotion CreateGamepadAxisEvent(InputBindingDescriptor binding)
|
||||
{
|
||||
var parts = binding.Code.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 3 || !string.Equals(parts[0], "joy-axis", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException($"Binding code '{binding.Code}' is not a valid joy-axis code.", nameof(binding));
|
||||
}
|
||||
|
||||
if (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var axis))
|
||||
{
|
||||
throw new ArgumentException($"Binding code '{binding.Code}' does not contain a valid axis index.", nameof(binding));
|
||||
}
|
||||
|
||||
if (!float.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var direction))
|
||||
{
|
||||
throw new ArgumentException($"Binding code '{binding.Code}' does not contain a valid axis direction.", nameof(binding));
|
||||
}
|
||||
|
||||
return new InputEventJoypadMotion
|
||||
{
|
||||
Axis = (JoyAxis)axis,
|
||||
AxisValue = direction
|
||||
};
|
||||
}
|
||||
|
||||
private static int ParseSingleSegment(string code, string prefix)
|
||||
{
|
||||
var parts = code.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 2 || !string.Equals(parts[0], prefix, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException($"Binding code '{code}' is not a valid {prefix} code.", nameof(code));
|
||||
}
|
||||
|
||||
if (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||
{
|
||||
throw new ArgumentException($"Binding code '{code}' does not contain a valid numeric value.", nameof(code));
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Key GetKeyCode(InputEventKey keyEvent)
|
||||
{
|
||||
return keyEvent.PhysicalKeycode != Key.None ? keyEvent.PhysicalKeycode : keyEvent.Keycode;
|
||||
}
|
||||
|
||||
private static string GetAxisDisplayName(JoyAxis axis, float direction)
|
||||
{
|
||||
return direction >= 0f
|
||||
? FormattableString.Invariant($"{axis} Positive")
|
||||
: FormattableString.Invariant($"{axis} Negative");
|
||||
}
|
||||
}
|
||||
148
GFramework.Godot/Input/GodotInputBindingStore.cs
Normal file
148
GFramework.Godot/Input/GodotInputBindingStore.cs
Normal file
@ -0,0 +1,148 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using GFramework.Game.Abstractions.Input;
|
||||
using GFramework.Game.Input;
|
||||
|
||||
namespace GFramework.Godot.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 提供基于 Godot `InputMap` 的输入绑定存储实现。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该类型把 Godot 原生 `InputEvent` / `InputMap` 适配到 `GFramework.Game.Abstractions.Input` 契约。
|
||||
/// 项目可以直接用它做重绑定、动作快照导出导入,以及“当前活跃设备”识别。
|
||||
/// </remarks>
|
||||
public sealed class GodotInputBindingStore : IInputBindingStore, IInputDeviceTracker
|
||||
{
|
||||
private readonly IGodotInputMapBackend _backend;
|
||||
private readonly InputBindingStore _state;
|
||||
private readonly InputDeviceTracker _deviceTracker;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化一个基于全局 `InputMap` 的输入绑定存储。
|
||||
/// </summary>
|
||||
public GodotInputBindingStore()
|
||||
: this(new GodotInputMapBackend())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化一个可测试的输入绑定存储。
|
||||
/// </summary>
|
||||
/// <param name="backend">要使用的 `InputMap` 后端。</param>
|
||||
internal GodotInputBindingStore(IGodotInputMapBackend backend)
|
||||
{
|
||||
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
|
||||
_state = new InputBindingStore(CaptureSnapshotFromBackend());
|
||||
_deviceTracker = new InputDeviceTracker();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public InputDeviceContext CurrentDevice => _deviceTracker.CurrentDevice;
|
||||
|
||||
/// <inheritdoc />
|
||||
public InputActionBinding GetBindings(string actionName)
|
||||
{
|
||||
ReloadFromBackend();
|
||||
return _state.GetBindings(actionName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public InputBindingSnapshot ExportSnapshot()
|
||||
{
|
||||
ReloadFromBackend();
|
||||
return _state.ExportSnapshot();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ImportSnapshot(InputBindingSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
ReloadFromBackend();
|
||||
var snapshotActionNames = snapshot.Actions
|
||||
.Select(static action => action.ActionName)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var removedActionNames = _state.ExportSnapshot().Actions
|
||||
.Select(static action => action.ActionName)
|
||||
.Where(actionName => !snapshotActionNames.Contains(actionName))
|
||||
.ToArray();
|
||||
|
||||
foreach (var actionName in removedActionNames)
|
||||
{
|
||||
_backend.SetBindings(actionName, Array.Empty<InputBindingDescriptor>());
|
||||
}
|
||||
|
||||
foreach (var action in snapshot.Actions)
|
||||
{
|
||||
ApplyActionBindings(action);
|
||||
}
|
||||
|
||||
ReloadFromBackend();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetPrimaryBinding(string actionName, InputBindingDescriptor binding, bool swapIfTaken = true)
|
||||
{
|
||||
ReloadFromBackend();
|
||||
_state.SetPrimaryBinding(actionName, binding, swapIfTaken);
|
||||
ApplySnapshot(_state.ExportSnapshot());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetAction(string actionName)
|
||||
{
|
||||
_backend.ResetAction(actionName);
|
||||
ReloadFromBackend();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetAll()
|
||||
{
|
||||
_backend.ResetAll();
|
||||
ReloadFromBackend();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用 Godot 原生输入事件更新当前活跃设备上下文。
|
||||
/// </summary>
|
||||
/// <param name="inputEvent">原生输入事件。</param>
|
||||
public void UpdateDeviceFromInput(InputEvent inputEvent)
|
||||
{
|
||||
var context = GodotInputBindingCodec.GetDeviceContext(inputEvent);
|
||||
_deviceTracker.Update(context);
|
||||
}
|
||||
|
||||
private void ApplyActionBindings(InputActionBinding actionBinding)
|
||||
{
|
||||
_backend.SetBindings(actionBinding.ActionName, actionBinding.Bindings);
|
||||
}
|
||||
|
||||
private void ApplySnapshot(InputBindingSnapshot snapshot)
|
||||
{
|
||||
foreach (var actionBinding in snapshot.Actions)
|
||||
{
|
||||
ApplyActionBindings(actionBinding);
|
||||
}
|
||||
}
|
||||
|
||||
private InputBindingSnapshot CaptureSnapshotFromBackend()
|
||||
{
|
||||
var actions = _backend.GetActionNames()
|
||||
.Select(CreateActionBinding)
|
||||
.ToArray();
|
||||
|
||||
return new InputBindingSnapshot(actions);
|
||||
}
|
||||
|
||||
private InputActionBinding CreateActionBinding(string actionName)
|
||||
{
|
||||
return new InputActionBinding(actionName, _backend.GetBindings(actionName).ToArray());
|
||||
}
|
||||
|
||||
private void ReloadFromBackend()
|
||||
{
|
||||
_state.ImportSnapshot(CaptureSnapshotFromBackend());
|
||||
}
|
||||
}
|
||||
99
GFramework.Godot/Input/GodotInputMapBackend.cs
Normal file
99
GFramework.Godot/Input/GodotInputMapBackend.cs
Normal file
@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using GFramework.Game.Abstractions.Input;
|
||||
|
||||
namespace GFramework.Godot.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 基于 Godot `InputMap` 的默认后端实现。
|
||||
/// </summary>
|
||||
internal sealed class GodotInputMapBackend : IGodotInputMapBackend
|
||||
{
|
||||
private readonly Dictionary<string, List<InputBindingDescriptor>> _defaults;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化后端,并捕获当前 `InputMap` 作为默认快照。
|
||||
/// </summary>
|
||||
public GodotInputMapBackend()
|
||||
{
|
||||
_defaults = GetActionNames().ToDictionary(
|
||||
static actionName => actionName,
|
||||
actionName => GetBindings(actionName).ToList(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> GetActionNames()
|
||||
{
|
||||
return [..InputMap.GetActions().Select(static action => action.ToString())];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<InputBindingDescriptor> GetBindings(string actionName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionName);
|
||||
|
||||
if (!InputMap.HasAction(actionName))
|
||||
{
|
||||
return Array.Empty<InputBindingDescriptor>();
|
||||
}
|
||||
|
||||
var bindings = new List<InputBindingDescriptor>();
|
||||
foreach (var inputEvent in InputMap.ActionGetEvents(actionName))
|
||||
{
|
||||
if (GodotInputBindingCodec.TryCreateBinding(inputEvent, out var binding))
|
||||
{
|
||||
bindings.Add(binding);
|
||||
}
|
||||
}
|
||||
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetBindings(string actionName, IReadOnlyList<InputBindingDescriptor> bindings)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionName);
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
|
||||
if (!InputMap.HasAction(actionName))
|
||||
{
|
||||
InputMap.AddAction(actionName);
|
||||
}
|
||||
|
||||
InputMap.ActionEraseEvents(actionName);
|
||||
foreach (var binding in bindings)
|
||||
{
|
||||
InputMap.ActionAddEvent(actionName, GodotInputBindingCodec.CreateInputEvent(binding));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetAction(string actionName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionName);
|
||||
|
||||
if (_defaults.TryGetValue(actionName, out var bindings))
|
||||
{
|
||||
SetBindings(actionName, bindings);
|
||||
return;
|
||||
}
|
||||
|
||||
if (InputMap.HasAction(actionName))
|
||||
{
|
||||
// Actions absent from the captured default snapshot should disappear after reset
|
||||
// so the live InputMap matches the original project defaults exactly.
|
||||
InputMap.EraseAction(actionName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetAll()
|
||||
{
|
||||
foreach (var actionName in GetActionNames())
|
||||
{
|
||||
ResetAction(actionName);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
GFramework.Godot/Input/IGodotInputMapBackend.cs
Normal file
47
GFramework.Godot/Input/IGodotInputMapBackend.cs
Normal file
@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2025-2026 GeWuYou
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
using GFramework.Game.Abstractions.Input;
|
||||
|
||||
namespace GFramework.Godot.Input;
|
||||
|
||||
/// <summary>
|
||||
/// 定义 `GodotInputBindingStore` 依赖的最小 `InputMap` 后端能力。
|
||||
/// </summary>
|
||||
internal interface IGodotInputMapBackend
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取当前 `InputMap` 中的动作名。
|
||||
/// </summary>
|
||||
/// <returns>动作名列表;永远不会返回 <see langword="null" />。</returns>
|
||||
IReadOnlyList<string> GetActionNames();
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定动作的框架绑定描述集合。
|
||||
/// </summary>
|
||||
/// <param name="actionName">动作名称,不能为 <see langword="null" /> 或空白字符串。</param>
|
||||
/// <returns>框架绑定描述集合;永远不会返回 <see langword="null" />。</returns>
|
||||
/// <exception cref="ArgumentException">当 <paramref name="actionName" /> 为 <see langword="null" /> 或空白字符串时抛出。</exception>
|
||||
IReadOnlyList<InputBindingDescriptor> GetBindings(string actionName);
|
||||
|
||||
/// <summary>
|
||||
/// 用给定绑定集合替换动作当前绑定。
|
||||
/// </summary>
|
||||
/// <param name="actionName">动作名称,不能为 <see langword="null" /> 或空白字符串。</param>
|
||||
/// <param name="bindings">新的绑定集合,不能为 <see langword="null" />,但可以为空集合。</param>
|
||||
/// <exception cref="ArgumentException">当 <paramref name="actionName" /> 为 <see langword="null" /> 或空白字符串时抛出。</exception>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="bindings" /> 为 <see langword="null" /> 时抛出。</exception>
|
||||
void SetBindings(string actionName, IReadOnlyList<InputBindingDescriptor> bindings);
|
||||
|
||||
/// <summary>
|
||||
/// 将指定动作恢复为项目默认绑定。
|
||||
/// </summary>
|
||||
/// <param name="actionName">动作名称,不能为 <see langword="null" /> 或空白字符串。</param>
|
||||
/// <exception cref="ArgumentException">当 <paramref name="actionName" /> 为 <see langword="null" /> 或空白字符串时抛出。</exception>
|
||||
void ResetAction(string actionName);
|
||||
|
||||
/// <summary>
|
||||
/// 将所有动作恢复为项目默认绑定。
|
||||
/// </summary>
|
||||
void ResetAll();
|
||||
}
|
||||
@ -16,6 +16,7 @@ Scene / UI、配置、存储、设置、日志与协程能力接到 `Node`、`Sc
|
||||
- 架构生命周期与场景树绑定:`AbstractArchitecture`、`ArchitectureAnchor`
|
||||
- 节点运行时辅助:`WaitUntilReadyAsync()`、`AddChildXAsync()`、`QueueFreeX()`、`UnRegisterWhenNodeExitTree(...)`
|
||||
- Godot 风格的 Scene / UI 工厂与 registry:`GodotSceneFactory`、`GodotUiFactory`
|
||||
- 基于 `InputMap` 的动作绑定适配:`GodotInputBindingStore`
|
||||
- Godot 特化的配置、存储与设置实现:`GodotYamlConfigLoader`、`GodotFileStorage`、`GodotAudioSettings`
|
||||
- 宿主侧辅助能力:`Signal(...)` fluent API、Godot 时间源、暂停处理、富文本效果
|
||||
|
||||
@ -60,6 +61,12 @@ Scene / UI、配置、存储、设置、日志与协程能力接到 `Node`、`Sc
|
||||
|
||||
这部分负责把 `PackedScene`、`Control`、`CanvasLayer` 等 Godot 对象接入 `GFramework.Game` 的 Scene / UI 契约。
|
||||
|
||||
### `Input/`
|
||||
|
||||
- `GodotInputBindingStore`
|
||||
|
||||
这部分负责把 `InputMap` 默认绑定、动作重绑定与快照导入导出接到 `GFramework.Game.Abstractions.Input` 契约。
|
||||
|
||||
### `Config/`、`Storage/` 与 `Setting/`
|
||||
|
||||
- `GodotYamlConfigLoader`
|
||||
@ -138,6 +145,7 @@ Godot 上。
|
||||
- 架构集成:[Godot 架构集成](../docs/zh-CN/godot/architecture.md)
|
||||
- 场景系统:[Godot 场景系统](../docs/zh-CN/godot/scene.md)
|
||||
- UI 系统:[Godot UI 系统](../docs/zh-CN/godot/ui.md)
|
||||
- 输入系统:[Godot 输入集成](../docs/zh-CN/godot/input.md)
|
||||
- 节点扩展:[Godot 节点扩展](../docs/zh-CN/godot/extensions.md)
|
||||
- 信号系统:[Godot 信号系统](../docs/zh-CN/godot/signal.md)
|
||||
- 日志系统:[Godot 日志系统](../docs/zh-CN/godot/logging.md)
|
||||
|
||||
@ -2017,6 +2017,56 @@ public class SchemaConfigGeneratorTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证生成器会显式拒绝当前共享子集尚未支持的数组形状关键字。
|
||||
/// </summary>
|
||||
/// <param name="keywordName">待验证的数组形状关键字名称。</param>
|
||||
/// <param name="keywordValueJson">用于拼接测试 schema 的关键字值 JSON 片段。</param>
|
||||
[TestCase("prefixItems", """
|
||||
[
|
||||
{ "type": "integer" }
|
||||
]
|
||||
""")]
|
||||
[TestCase("additionalItems", "false")]
|
||||
[TestCase("unevaluatedItems", "false")]
|
||||
public void Run_Should_Report_Diagnostic_When_Array_Schema_Declares_Unsupported_ArrayShape_Keyword(
|
||||
string keywordName,
|
||||
string keywordValueJson)
|
||||
{
|
||||
const string source = DummySource;
|
||||
var schema = $$"""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id", "dropRates"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"{{keywordName}}": {{keywordValueJson}},
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = SchemaGeneratorTestDriver.Run(
|
||||
source,
|
||||
("monster.schema.json", schema));
|
||||
|
||||
var diagnostic = result.Results.Single().Diagnostics.Single();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_017"));
|
||||
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
|
||||
Assert.That(diagnostic.GetMessage(), Does.Contain("dropRates"));
|
||||
Assert.That(diagnostic.GetMessage(), Does.Contain(keywordName));
|
||||
Assert.That(diagnostic.GetMessage(), Does.Contain("only accepts one object-valued 'items' schema"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 <c>then</c> 子 schema 内的非法 <c>format</c> 也会在生成阶段直接给出诊断。
|
||||
/// </summary>
|
||||
|
||||
@ -10,7 +10,6 @@ using GFramework.Core.Ioc;
|
||||
using GFramework.Cqrs;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs;
|
||||
using GFramework.Cqrs.Command;
|
||||
using GFramework.Cqrs.Notification;
|
||||
using LegacyICqrsRuntime = GFramework.Core.Abstractions.Cqrs.ICqrsRuntime;
|
||||
|
||||
namespace GFramework.Tests.Common;
|
||||
@ -65,8 +64,7 @@ public static class CqrsTestRuntime
|
||||
if (container.Get<ICqrsRuntime>() is null)
|
||||
{
|
||||
var runtimeLogger = LoggerFactoryResolver.Provider.CreateLogger("CqrsDispatcher");
|
||||
var notificationPublisher = container.Get<INotificationPublisher>();
|
||||
var runtime = CqrsRuntimeFactory.CreateRuntime(container, runtimeLogger, notificationPublisher);
|
||||
var runtime = CqrsRuntimeFactory.CreateRuntime(container, runtimeLogger);
|
||||
container.Register(runtime);
|
||||
RegisterLegacyRuntimeAlias(container, runtime);
|
||||
}
|
||||
|
||||
@ -21,6 +21,10 @@ help the current worktree land on the right recovery documents without scanning
|
||||
API-reference alignment baseline.
|
||||
- Tracking: `ai-plan/public/documentation-full-coverage-governance/todos/documentation-full-coverage-governance-tracking.md`
|
||||
- Trace: `ai-plan/public/documentation-full-coverage-governance/traces/documentation-full-coverage-governance-trace.md`
|
||||
- `ai-plan-governance`
|
||||
- Purpose: govern repository AI workflow rules, recovery entrypoints, and multi-agent coordination conventions.
|
||||
- Tracking: `ai-plan/public/ai-plan-governance/todos/ai-plan-governance-tracking.md`
|
||||
- Trace: `ai-plan/public/ai-plan-governance/traces/ai-plan-governance-trace.md`
|
||||
- `coroutine-optimization`
|
||||
- Purpose: continue the coroutine semantics, host integration, observability, regression coverage, and migration-doc
|
||||
follow-up work.
|
||||
@ -34,6 +38,10 @@ help the current worktree land on the right recovery documents without scanning
|
||||
- Purpose: continue the data repository persistence hardening plus the settings / serialization follow-up backlog.
|
||||
- Tracking: `ai-plan/public/data-repository-persistence/todos/data-repository-persistence-tracking.md`
|
||||
- Trace: `ai-plan/public/data-repository-persistence/traces/data-repository-persistence-trace.md`
|
||||
- `input-system-godot-integration`
|
||||
- Purpose: establish the shared input abstraction, default binding runtime, and Godot InputMap integration path.
|
||||
- Tracking: `ai-plan/public/input-system-godot-integration/todos/input-system-godot-integration-tracking.md`
|
||||
- Trace: `ai-plan/public/input-system-godot-integration/traces/input-system-godot-integration-trace.md`
|
||||
|
||||
## Worktree To Active Topic Map
|
||||
|
||||
@ -51,6 +59,9 @@ help the current worktree land on the right recovery documents without scanning
|
||||
- Branch: `feat/data-repository-persistence`
|
||||
- Worktree hint: `GFramework-data-repository-persistence`
|
||||
- Priority 1: `data-repository-persistence`
|
||||
- Branch: `feat/input-system-godot-integration`
|
||||
- Worktree hint: `GFramework-input-system-godot-integration`
|
||||
- Priority 1: `input-system-godot-integration`
|
||||
- Branch: `docs/sdk-update-documentation`
|
||||
- Worktree hint: `GFramework-update-documentation`
|
||||
- Priority 1: `documentation-full-coverage-governance`
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
- 已完成 object-focused `if` / `then` / `else`,继续评估下一批仍不改变生成类型形状的共享关键字
|
||||
- 已明确将 `oneOf` / `anyOf` 归类为当前不支持的组合关键字,并在 Runtime / Generator / Tooling 三端显式拒绝,避免静默接受导致形状漂移
|
||||
- 已把开放对象关键字边界收紧为只接受 `additionalProperties: false`,并在 Runtime / Generator / Tooling 三端显式拒绝 `patternProperties`、`propertyNames`、`unevaluatedProperties`
|
||||
- 已把数组形状关键字边界收紧为只接受单个 object-valued `items` schema,并在 Runtime / Generator / Tooling 三端显式拒绝 `prefixItems`、`additionalItems`、`unevaluatedItems`
|
||||
- 已完成 PR #262 的 CodeRabbit follow-up,补齐 latest review body 中 folded `Nitpick comments` 的 skill 解析并按建议收口 Tooling / Tests
|
||||
- 先以 Runtime / Generator / Tooling 三端一致语义为前提筛选下一项,而不是盲目扩全量 JSON Schema
|
||||
- Tooling / Docs 后续改为非阻塞并行 lane;active 入口只保留主线恢复点,把批处理细节下沉到 backlog 文件
|
||||
@ -23,6 +24,8 @@
|
||||
- 缓解措施:`oneOf` / `anyOf` 已改为三端显式拒绝;后续仅继续评估不会引入联合形状、属性合并或分支生成漂移的关键字子集
|
||||
- 开放对象形状风险:如果某一端静默接受 `patternProperties`、`propertyNames`、`unevaluatedProperties` 等关键字,会重新打开对象形状并造成契约漂移
|
||||
- 缓解措施:当前三端已统一把开放对象边界收紧为只接受 `additionalProperties: false`,其余开放对象关键字直接报错
|
||||
- 数组形状漂移风险:如果某一端静默接受 `prefixItems`、`additionalItems`、`unevaluatedItems` 等关键字,会让 tuple / open-array 设计在三端表现不一致
|
||||
- 缓解措施:当前三端已统一把数组形状边界收紧为只接受单个 object-valued `items` schema,其余数组形状关键字直接报错
|
||||
- 工具链验证风险:VS Code 与 CI / 发布管道验证覆盖不足
|
||||
- 缓解措施:继续为新增共享关键字补齐三端测试覆盖,优先保证 C# Runtime 与 Generator 回归通过,并记录 JS 测试与构建验证
|
||||
- PR review 信号漂移风险:CodeRabbit 可能把建议折叠在 latest review body,而不是 issue comments
|
||||
@ -46,6 +49,9 @@
|
||||
- 已明确拒绝会重新打开对象形状的开放对象关键字:
|
||||
- 当前只接受 `additionalProperties: false`
|
||||
- `patternProperties`、`propertyNames`、`unevaluatedProperties` 当前会在 Runtime / Generator / Tooling 三端直接报错,而不是静默忽略
|
||||
- 已明确拒绝会改变数组元素形状的数组关键字:
|
||||
- 当前只接受单个 object-valued `items` schema
|
||||
- `prefixItems`、`additionalItems`、`unevaluatedItems` 当前会在 Runtime / Generator / Tooling 三端直接报错,而不是静默忽略
|
||||
- `if` / `then` / `else` 已按“不改变生成类型形状”的边界落地:
|
||||
- 只允许 object 节点上的 object-typed inline schema
|
||||
- `if` 必填,且必须至少伴随 `then` 或 `else` 之一
|
||||
@ -94,6 +100,9 @@
|
||||
- 最近验证摘要:`2026-04-30` 已完成 Tooling / Docs reader-facing 收口与工具 parser 边界收紧,详细命令、批次背景与验证结果保留在 trace 的 `2026-04-30` 分阶段记录中
|
||||
- 最近验证摘要:`2026-05-06` 已完成开放对象关键字边界收口;Runtime / Generator / Tooling 现统一拒绝 `patternProperties`、`propertyNames`、`unevaluatedProperties`,并保留 `additionalProperties: false` 作为唯一共享闭合对象入口;详细命令与批次背景保留在 trace 的 `2026-05-06` 记录中
|
||||
- 最近验证摘要:`2026-05-06` 已按 PR `#325` latest review follow-up 移除三端开放对象校验中的不可达 `additionalProperties: false` 放行分支,补齐 Tooling 正向回归,并同步拆分 reader-facing docs 对开放对象边界的表述;细节与验证命令保留在 trace 的 `2026-05-06` 追加记录中
|
||||
- 最近验证摘要:`2026-05-06` 已核对 `extension.js` 的对象数组编辑能力与 reader-facing 文档,确认表单当前支持对象数组项内部继续嵌套的对象数组;`tools/gframework-config-tool/README.md` 与 `docs/zh-CN/game/config-tool.md` 已同步收紧回退条件,避免把“仍在共享子集内的嵌套对象数组”误写成默认只能回退 raw YAML;细节与验证命令保留在 trace 的 `2026-05-06` 追加记录中
|
||||
- 最近验证摘要:`2026-05-08` 已把数组形状边界收紧为只接受单个 object-valued `items` schema;Runtime / Generator / Tooling 现统一拒绝 `prefixItems`、`additionalItems`、`unevaluatedItems`,并补齐三端回归测试与 reader-facing 文档说明;细节与验证命令保留在 trace 的 `2026-05-08` 记录中
|
||||
- 最近验证摘要:`2026-05-09` 已按 PR `#343` latest unresolved review threads 补齐两个 public 参数化测试的 XML `<param>` 注释,并为 `2026-05-06` Tooling 文档批次标题追加上下文后缀以消除 `MD024`;细节与定向验证命令保留在 trace 的 `2026-05-09` 记录中
|
||||
- PR `#306` follow-up 摘要:已按 latest open review threads 补齐 Generator `anyOf` 对称回归、Tooling schema type 白名单、object-array 直系收集边界,以及 reader-facing docs 的显式 `additionalProperties: false` / adoption guidance 说明;细节和验证命令保留在 trace 的 `2026-04-30` 新增阶段记录中
|
||||
- PR review 跟进指针:当前分支的 latest review follow-up 与后续本地核验结论以 `ai-first-config-system-trace.md` 为准,active tracking 不再重复展开逐条命令历史
|
||||
|
||||
|
||||
@ -145,7 +145,7 @@
|
||||
- 指向真正承载并行批次细节的 backlog 文件
|
||||
- 本轮不新增代码范围、测试范围或文档范围,只整理 public `ai-plan/**` 的恢复入口表达,避免把治理噪音带回 reader-facing docs
|
||||
|
||||
### 关键决定
|
||||
### 关键决定(Tooling 文档与实际编辑边界对齐)
|
||||
|
||||
- `C# Runtime + Source Generator + Consumer DX` 仍是默认恢复主线
|
||||
- Tooling / Docs 可以并发推进,但后续 batch 应直接以 `ai-first-config-system-csharp-experience-next.md` 为入口,而不是继续扩写 active tracking / trace
|
||||
@ -242,19 +242,19 @@
|
||||
- `patternProperties`、`propertyNames`、`unevaluatedProperties` 当前改为三端直接失败
|
||||
- reader-facing docs 也已同步更新,避免采用文档继续把这类关键字描述成“也许工具没做但运行时可能支持”的灰区
|
||||
|
||||
### 关键决定
|
||||
### 关键决定(Tooling 文档与实际编辑边界对齐)
|
||||
|
||||
- `additionalProperties: false` 仍是唯一共享支持的开放对象相关关键字形状
|
||||
- 任何会重新引入动态字段集的开放对象关键字,都视为当前主线之外的设计,而不是后续工具增强项
|
||||
- 本轮继续保持主线为 `C# Runtime + Source Generator + Consumer DX`,没有把工作重心切回复杂表单或宿主验证
|
||||
|
||||
### Stop Condition
|
||||
### Stop Condition(Tooling 文档与实际编辑边界对齐)
|
||||
|
||||
- Batch baseline:`origin/main` (`a8c6c11e`, `2026-05-05 13:14:24 +0800`)
|
||||
- Primary metric:branch diff vs `origin/main` changed files,阈值 `50`
|
||||
- 本轮执行时的 branch diff 指标仍为 `0`,说明当前批次尚未把 `HEAD` 推进到接近阈值;reviewability headroom 充足
|
||||
|
||||
### 验证
|
||||
### 验证(Tooling 文档与实际编辑边界对齐)
|
||||
|
||||
- 2026-05-06:`bun run test`(`tools/gframework-config-tool`)
|
||||
- 结果:通过(133 tests)
|
||||
@ -275,7 +275,7 @@
|
||||
- 2026-05-06:`git diff --check`
|
||||
- 结果:通过
|
||||
|
||||
### 下一步
|
||||
### 下一步(Tooling 文档与实际编辑边界对齐)
|
||||
|
||||
1. 继续盘点下一批不会改变生成类型形状、也不会重新打开对象形状的共享关键字
|
||||
2. Tooling / Docs 如继续并发推进,优先补真实采用示例,不再重复扩写开放对象边界清单
|
||||
@ -324,3 +324,103 @@
|
||||
1. 执行本轮受影响 Tooling / Runtime / Generator 定向验证,并确认没有新增 warning 或格式漂移
|
||||
2. 若验证通过,重新抓取 PR `#325` review 状态,区分哪些 open threads 会随推送自动折叠
|
||||
3. 继续把 PR review follow-up 约束在“latest unresolved thread + 本地仍成立问题”,不回头追旧 summary 噪音
|
||||
|
||||
### 阶段:Tooling 文档与实际编辑边界对齐(AI-FIRST-CONFIG-RP-003)
|
||||
|
||||
- 已重新核对 `tools/gframework-config-tool/src/extension.js` 的对象数组表单能力,并确认当前实现不只支持对象数组本身
|
||||
- 当前表单还支持“对象数组项内部继续嵌套的对象数组”,前提是内层条目仍保持共享子集允许的对象 / 标量字段 / 标量数组 / 嵌套对象形状
|
||||
- 本轮不扩 Runtime / Generator / Tooling 的 schema 契约,只修正 reader-facing docs 漂移:
|
||||
- `tools/gframework-config-tool/README.md` 不再把“更深层嵌套编辑”笼统描述成默认回退 raw YAML
|
||||
- `docs/zh-CN/game/config-tool.md` 改为明确:只有当对象数组继续嵌套后的结构超出当前共享子集,才需要回到 raw YAML
|
||||
|
||||
### 关键决定(Tooling 文档与实际编辑边界对齐)
|
||||
|
||||
- 这轮批次继续遵守“先核对共享契约,再改文档”的 lane 规则,没有为追求批量推进而硬扩一个收益不明确的新关键字
|
||||
- Tooling 的 reader-facing 说明要以 `extension.js` 当前真实能力为准,避免把已经支持的对象数组路径继续描述成工具缺口
|
||||
- raw YAML 回退条件保留,但需要收敛为“超出共享子集或当前编辑器边界”而不是“只要更深层对象数组就默认回退”
|
||||
|
||||
### Stop Condition(Tooling 文档与实际编辑边界对齐)
|
||||
|
||||
- Batch baseline:`origin/main` (`c01abac0`, `2026-05-06 09:40:08 +0800`)
|
||||
- Primary metric:branch diff vs `origin/main` changed files,阈值 `30`
|
||||
- 本轮开始前 branch diff 指标为 `0` files / `0` changed lines;本批次只触碰 reader-facing docs 与 active `ai-plan`,预计仍远低于阈值
|
||||
|
||||
### 验证(Tooling 文档与实际编辑边界对齐)
|
||||
|
||||
- 2026-05-06:`rg -n "nested object arrays|回退到 raw YAML|更深层对象数组"`(文档 + `extension.js`)
|
||||
- 结果:通过
|
||||
- 备注:确认 `README` / 中文工具文档存在旧边界表述,而 `extension.js` 已支持对象数组项内部继续嵌套的对象数组编辑
|
||||
- 2026-05-06:`git diff --check -- tools/gframework-config-tool/README.md docs/zh-CN/game/config-tool.md ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md`
|
||||
- 结果:通过
|
||||
- 2026-05-06:`python3 scripts/license-header.py --check --paths tools/gframework-config-tool/README.md docs/zh-CN/game/config-tool.md ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md`
|
||||
- 结果:通过
|
||||
- 2026-05-06:`dotnet build GFramework.Game/GFramework.Game.csproj -c Release`
|
||||
- 结果:通过(0 warnings, 0 errors)
|
||||
|
||||
### 下一步(Tooling 文档与实际编辑边界对齐)
|
||||
|
||||
1. 继续优先找“实现已存在但 reader-facing 表述漂移”的低风险 lane,避免在批处理模式下引入收益不明的新 schema contract
|
||||
2. 若下一轮回到主线代码批次,再继续盘点不会改变生成类型形状的共享关键字,而不是重复刷新同一组 Tooling 边界说明
|
||||
|
||||
## 2026-05-08
|
||||
|
||||
### 阶段:数组形状关键字边界收口(AI-FIRST-CONFIG-RP-003)
|
||||
|
||||
- 已在 Runtime、Source Generator 与 VS Code Tooling 三端统一收紧数组形状关键字边界
|
||||
- 本轮不是扩 JSON Schema 能力,而是避免某一端静默接受 tuple / open-array 设计:
|
||||
- 当前共享子集只接受单个 object-valued `items` schema
|
||||
- `prefixItems`、`additionalItems`、`unevaluatedItems` 现在会在三端直接失败,而不是静默忽略
|
||||
- reader-facing docs 也已同步补齐数组形状边界,避免把“标准 JSON Schema 的 tuple/open-array 语义”误读成当前配置系统的隐藏支持范围
|
||||
|
||||
### 验证
|
||||
|
||||
- 2026-05-08:`bun run test`(`tools/gframework-config-tool`)
|
||||
- 目标:验证工具端会拒绝 `prefixItems`、`additionalItems`、`unevaluatedItems`
|
||||
- 2026-05-08:`dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --filter "FullyQualifiedName~SchemaConfigGeneratorTests"`
|
||||
- 目标:验证生成器新增 `GF_ConfigSchema_017`
|
||||
- 2026-05-08:`dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~YamlConfigLoaderTests"`
|
||||
- 目标:验证运行时会拒绝不在共享子集内的数组形状关键字
|
||||
- 2026-05-08:`dotnet build GFramework.Game/GFramework.Game.csproj -c Release`
|
||||
- 目标:验证运行时模块 Release 构建
|
||||
- 2026-05-08:`dotnet build GFramework.Game.SourceGenerators/GFramework.Game.SourceGenerators.csproj -c Release`
|
||||
- 目标:验证生成器模块 Release 构建
|
||||
|
||||
### 下一步
|
||||
|
||||
1. 若本轮验证通过,继续回到“不会改变生成类型形状”的下一批共享关键字盘点
|
||||
2. 继续优先寻找“静默接受但主线不支持”的边界收口项,而不是先扩更复杂的组合语义
|
||||
|
||||
## 2026-05-09
|
||||
|
||||
### 阶段:PR #343 review follow-up(AI-FIRST-CONFIG-RP-003)
|
||||
|
||||
- 已用 `gframework-pr-review` 重新抓取当前分支 PR `#343` 的 latest unresolved review threads
|
||||
- 本轮只处理本地仍成立的 3 条 open threads:
|
||||
- `GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs` 的 public 参数化测试补齐 XML `<param>` 注释
|
||||
- `GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs` 的 public 参数化测试补齐 XML `<param>` 注释
|
||||
- `ai-first-config-system-trace.md` 为 `2026-05-06` Tooling 文档批次的重复三级标题加上上下文后缀,消除 `MD024`
|
||||
- 其余 PR 信号已核对:
|
||||
- 当前没有 failed checks
|
||||
- MegaLinter 仅报告 1 条 `dotnet-format` 相关问题;本轮先按 latest open review threads 收口本地仍成立项
|
||||
- GitHub Test Reporter 显示 `2336` tests passed、`0` failed
|
||||
|
||||
### 验证(PR #343 review follow-up)
|
||||
|
||||
- 2026-05-09:`python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --json-output /tmp/current-pr-review.json`
|
||||
- 结果:通过
|
||||
- 备注:确认 PR `#343` 当前仍有 3 条 latest unresolved CodeRabbit threads,且都可在本地直接复现
|
||||
- 2026-05-09:`git diff --check -- GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md`
|
||||
- 结果:通过
|
||||
- 2026-05-09:`python3 scripts/license-header.py --check --paths GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs`
|
||||
- 结果:通过
|
||||
- 2026-05-09:`dotnet build GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release`
|
||||
- 结果:通过(0 warnings, 0 errors)
|
||||
- 备注:沙箱内并行 restore 命中了 `.nuget.g.props` 已存在的环境型冲突;已按仓库规则在沙箱外重跑原命令,并以外部结果作为准确信号
|
||||
- 2026-05-09:`dotnet build GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release`
|
||||
- 结果:通过(0 warnings, 0 errors)
|
||||
- 备注:已在沙箱外串行重跑原命令,确认本轮 PR review 修复未引入构建问题
|
||||
|
||||
### 下一步(PR #343 review follow-up)
|
||||
|
||||
1. 若本轮定向校验通过,重新抓取 PR `#343` review 状态,确认这 3 条 open threads 是否已具备自动折叠条件
|
||||
2. 若 PR 仍残留 review 信号,继续只处理 latest unresolved thread 中本地仍成立的问题,不回头追旧 summary 噪音
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
# AI-Plan Governance 跟踪
|
||||
|
||||
## 目标
|
||||
|
||||
保持仓库级 AI 工作流规则、公开恢复入口与多 Agent 协作约定在 `AGENTS.md`、`.agents/skills/**` 与
|
||||
`ai-plan/public/**` 之间一致,并让 `boot` 能稳定恢复这类治理主题。
|
||||
|
||||
## 当前恢复点
|
||||
|
||||
- 恢复点编号:`AI-PLAN-GOVERNANCE-RP-002`
|
||||
- 当前阶段:`Phase 2`
|
||||
- 当前焦点:
|
||||
- 已补齐 `ai-plan/public/README.md` 的 active-topic 暴露,使 `feat/cqrs-optimization` 映射到
|
||||
`ai-plan-governance` 时,`boot` 能落到真实的 public tracking / trace 入口。
|
||||
- 已在 `AGENTS.md` 增补主 Agent 协调多 worker wave 时的强约束,明确 critical path、`ai-plan`、
|
||||
review、validation 与 final integration 归主 Agent 所有。
|
||||
- 已新增 `.agents/skills/gframework-multi-agent-batch/`,把“主 Agent 负责派发、核对、更新 `ai-plan`、
|
||||
验收并决定是否继续下一波”的流程沉淀为可复用 skill。
|
||||
- 已轻量更新 `gframework-boot`、`gframework-batch-boot` 与 `.agents/skills/README.md`,把该模式接入既有
|
||||
boot / batch 入口而不重复定义规则。
|
||||
- 当前变更面仍限定在 `AGENTS.md`、`.agents/skills/**` 与 `ai-plan/public/**`,没有扩散到运行时代码或产品
|
||||
模块。
|
||||
|
||||
## 当前状态摘要
|
||||
|
||||
- 已落地“`AGENTS.md` 负责强治理约束,skill 负责可执行流程”的混合方案。
|
||||
- 已修复本 worktree 的一个恢复入口缺口:topic 映射存在,但 active-topic 列表和 public recovery 文件此前缺失。
|
||||
- 当前主题已经具备后续继续演进 AI workflow 规则所需的 public tracking / trace 基线。
|
||||
|
||||
## 当前活跃事实
|
||||
|
||||
- 当前分支:`feat/cqrs-optimization`
|
||||
- `AGENTS.md` 的 `Repository Boot Skill` 现已与仓库实际目录对齐,使用 `.agents/skills/gframework-boot/`
|
||||
而不是失效的 `.codex/skills/gframework-boot/` 路径。
|
||||
- `AGENTS.md` 新增 `Multi-Agent Coordination Rules`,用于约束主 Agent 的职责边界、worker ownership、
|
||||
acceptance gate 与 stop condition。
|
||||
- `.agents/skills/gframework-multi-agent-batch/` 现已包含:
|
||||
- `SKILL.md`
|
||||
- `agents/openai.yaml`
|
||||
- `.agents/skills/gframework-boot/SKILL.md` 与 `.agents/skills/gframework-batch-boot/SKILL.md` 现已明确在
|
||||
orchestration-heavy 场景下切换或让位给 `gframework-multi-agent-batch`。
|
||||
- `.agents/skills/README.md` 已把 `gframework-multi-agent-batch` 纳入公开入口说明。
|
||||
- `ai-plan/public/ai-plan-governance/` 已建立 public tracking / trace 入口,可供后续治理任务继续复用。
|
||||
|
||||
## 当前风险
|
||||
|
||||
- 漂移风险:若后续只改 `AGENTS.md` 或只改 skill,主 Agent 职责定义可能再次分叉。
|
||||
- 入口重叠风险:若 `gframework-batch-boot` 与 `gframework-multi-agent-batch` 的边界被继续模糊,公开入口会变得难以选择。
|
||||
- 恢复噪音风险:若该 topic 后续把每一轮治理细节都堆进 active 文件,`boot` 的默认恢复效率会下降。
|
||||
|
||||
## 验证说明
|
||||
|
||||
- `python3 scripts/license-header.py --check --paths AGENTS.md ai-plan/public/README.md ai-plan/public/ai-plan-governance/todos/ai-plan-governance-tracking.md ai-plan/public/ai-plan-governance/traces/ai-plan-governance-trace.md`
|
||||
- 结果:通过
|
||||
- 备注:本轮受 license-header 规则约束的 public 文档文件均已具备 Apache-2.0 头
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- 备注:本轮治理变更未引入 trailing whitespace 或 patch 格式问题
|
||||
- `dotnet build GFramework.Core.Abstractions/GFramework.Core.Abstractions.csproj -c Release`
|
||||
- 结果:通过
|
||||
- 备注:`0 warning / 0 error`;满足仓库“完成任务前至少通过一条 build validation”的要求
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 后续如再次出现“主 Agent 持续派发并验收多个 worker”的任务,优先直接用真实任务验证 `gframework-multi-agent-batch`
|
||||
的可恢复性与边界清晰度
|
||||
2. 若该 skill 的 stop condition、ownership 或 `ai-plan` 记录格式在实战中出现歧义,再回到本 topic 做下一轮治理收口
|
||||
@ -0,0 +1,43 @@
|
||||
# AI-Plan Governance 追踪
|
||||
|
||||
## 2026-05-09
|
||||
|
||||
### 阶段:多 Agent 协作治理入口落地(AI-PLAN-GOVERNANCE-RP-002)
|
||||
|
||||
- 确认本轮目标采用混合方案,而不是只写 skill 或只写 `AGENTS.md`:
|
||||
- `AGENTS.md` 承载仓库级强约束
|
||||
- `.agents/skills/gframework-multi-agent-batch/` 承载主 Agent 持续派发 / review / `ai-plan` / validation 的执行流程
|
||||
- 修复 repository boot 入口的一处一致性问题:
|
||||
- `AGENTS.md` 先前仍引用 `.codex/skills/gframework-boot/`
|
||||
- 仓库实际技能目录是 `.agents/skills/`
|
||||
- 本轮已对齐为 `.agents/skills/gframework-boot/`,并补入新的多 Agent skill 路径
|
||||
- 本轮已落地的治理变更:
|
||||
- 在 `AGENTS.md` 增加 `Multi-Agent Coordination Rules`
|
||||
- 在 `.agents/skills/README.md` 新增 `gframework-multi-agent-batch` 公开入口说明
|
||||
- 在 `.agents/skills/gframework-boot/SKILL.md` 增加 orchestration-heavy 场景切换说明
|
||||
- 在 `.agents/skills/gframework-batch-boot/SKILL.md` 增加与新 skill 的边界说明
|
||||
- 新建 `.agents/skills/gframework-multi-agent-batch/`
|
||||
- 在 `ai-plan/public/README.md` 补入 `ai-plan-governance` active topic
|
||||
- 新建 `ai-plan/public/ai-plan-governance/` 的 tracking / trace 入口
|
||||
- 关键约束决策:
|
||||
- 主 Agent 负责 critical path、stop condition、`ai-plan`、validation、review、final integration
|
||||
- worker 只能拿到显式且互不冲突的 ownership slice
|
||||
- 继续派发下一波前,主 Agent 必须重算 reviewability、branch diff 与 context budget
|
||||
- 当前收尾目标:
|
||||
- 运行校验
|
||||
- 把验证结果回填到 active tracking / trace
|
||||
- 以 Conventional Commit 提交本轮治理变更
|
||||
|
||||
### 验证里程碑
|
||||
|
||||
- `python3 scripts/license-header.py --check --paths AGENTS.md ai-plan/public/README.md ai-plan/public/ai-plan-governance/todos/ai-plan-governance-tracking.md ai-plan/public/ai-plan-governance/traces/ai-plan-governance-trace.md`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `dotnet build GFramework.Core.Abstractions/GFramework.Core.Abstractions.csproj -c Release`
|
||||
- 结果:通过(`0 warning / 0 error`)
|
||||
|
||||
### 下一步
|
||||
|
||||
1. 提交本轮多 Agent 协作治理变更
|
||||
2. 未来若有真实的 orchestration-heavy 任务进入该 worktree,优先直接用新 skill 驱动并据实补充下一恢复点
|
||||
@ -0,0 +1,536 @@
|
||||
# CQRS 重写迁移跟踪
|
||||
|
||||
## 目标
|
||||
|
||||
围绕 `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 <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`
|
||||
- 当前阶段:`Phase 8`
|
||||
- 当前 PR 锚点:`待重新抓取`
|
||||
- 当前结论:
|
||||
- 当前 `RP-131` 继续沿用 `$gframework-batch-boot 50`,并把上一波定位到的 BenchmarkDotNet 并行运行冲突真正收口到 benchmark 入口层:`Program.cs` 现在支持 `--artifacts-suffix <suffix>`,并在声明 suffix 时自动把当前 benchmark 运行重启到独立的 host 工作目录
|
||||
- 本轮继续沿用已复核的基线:`origin/main` 与本地 `main` 当前都在 `699d0b48`(`2026-05-09 18:39:38 +0800`);当前分支相对 `origin/main` 的累计 branch diff 仍约为 `9 files changed, 953 insertions(+), 83 deletions(-)`,离 `$gframework-batch-boot 50` 还有明显余量
|
||||
- 本轮写面收敛在 benchmark 入口与文档两处:`GFramework.Cqrs.Benchmarks/Program.cs` 与 `GFramework.Cqrs.Benchmarks/README.md`;没有扩散到 benchmark 业务逻辑文件、`GFramework.Cqrs` runtime 或测试项目
|
||||
- `Program.cs` 当前约定为:
|
||||
- 解析并剥离仓库自定义参数 `--artifacts-suffix <suffix>`,避免把它误传给 BenchmarkDotNet CLI
|
||||
- 支持通过环境变量回退复用同一套 suffix / artifacts path 约定
|
||||
- 当 suffix 存在时,先把当前 benchmark 宿主输出复制到 `BenchmarkDotNet.Artifacts/<suffix>/host/`,再从该隔离宿主目录重启运行,使 BenchmarkDotNet 自动生成的 `GFramework.Cqrs.Benchmarks-Job-*` 项目、`OutDir` 与最终 results 都落到 suffix 私有目录下
|
||||
- 并发 smoke 已直接证明这套隔离生效:`RequestLifetimeBenchmarks.SendRequest_*` 与 `StreamInvokerBenchmarks.Stream_*` 在两个终端并发 short-job 时,分别落到 `BenchmarkDotNet.Artifacts/req-lifetime-a/host/...` 与 `BenchmarkDotNet.Artifacts/stream-invoker-b/host/...`,不再共享同一 `.../bin/Release/net10.0/GFramework.Cqrs.Benchmarks-Job-JWUHXL-1/` 生成目录,也没有再出现 `.dll.config being used by another process`
|
||||
- `README` 已同步新的运行约定:当两个带 `--filter` 的 benchmark 需要并发执行时,必须为每个进程传入不同的 `--artifacts-suffix`;该约束只服务于本地输出隔离,不代表 benchmark 业务语义本身需要额外依赖
|
||||
- 下一推荐步骤:
|
||||
- 若继续 benchmark 线,可重新利用新的并发运行隔离约定,单开一波更稳定的 `StreamInvokerBenchmarks` `DrainAll` 排序复核,或并行推进其他不冲突的 benchmark smoke
|
||||
- 若上下文预算仍允许,下一批更适合继续保持在 `GFramework.Cqrs.Benchmarks` 单模块,避免过早把 review 面重新扩到 runtime 或测试层
|
||||
- 更早的 `RP-123` 及之前阶段细节以下方 trace 与归档为准,active 入口不再重复展开旧阶段流水。
|
||||
- 当前分支相对 `origin/main` 的累计 branch diff 启动时为 `9 files`,仍明显低于 `$gframework-batch-boot 50` 的停止阈值;这一批继续保持单模块、低风险、可直接评审的 benchmark 边界
|
||||
- 当前 `RP-113` 已继续沿用 `$gframework-batch-boot 50`,并把 notification 线从 benchmark 对照推进到实际 runtime 能力:新增公开内置 `TaskWhenAllNotificationPublisher`,让 `GFramework.Cqrs` 在保留默认顺序发布器的同时,提供与 `Mediator` `TaskWhenAllPublisher` 对齐的并行 notification publish 策略
|
||||
- `TaskWhenAllNotificationPublisher` 当前语义明确为:零处理器静默完成,单处理器直接透传,多处理器并行启动并等待全部结束;它不保留默认顺序发布器的“首个异常立即停止”语义,而是把全部处理器的失败/取消结果收敛到同一个返回任务
|
||||
- 本轮同时补齐 `CqrsNotificationPublisherTests` 对新内置策略的回归,并更新 `GFramework.Cqrs/README.md` 与 `docs/zh-CN/core/cqrs.md`,把切换方式和语义边界写回用户可见文档;当前已提交 branch diff 仍明显低于 `$gframework-batch-boot 50` 的停止阈值
|
||||
- 这一批选择真正落一个内置 publisher strategy,而不是继续加 notification benchmark 维度;原因是 `RP-111` / `RP-112` 已经把 notification gap 量化清楚,下一步更高价值的是开始收口“能力差距”而不是继续重复建立对照数据
|
||||
- 当前 `RP-112` 已继续沿用 `$gframework-batch-boot 50`,并在 `RP-111` 的单处理器 notification 对照基础上补齐固定 `4 handler` 的 fan-out publish benchmark:新增 `NotificationFanOutBenchmarks`,对比 baseline、`GFramework.Cqrs`、NuGet `Mediator` concrete runtime 与 `MediatR`
|
||||
- `NotificationFanOutBenchmarks` 当前 short-job 基线约为 baseline `8.302 ns / 0 B`、`Mediator` `4.314 ns / 0 B`、`MediatR` `230.304 ns / 1256 B`、`GFramework.Cqrs` `434.413 ns / 408 B`;这说明 notification fan-out 的差距已经不只体现在单处理器 publish,而是在固定 4 处理器场景下依然保持相近量级
|
||||
- 本轮仍然只扩 benchmark 对照口径,没有直接修改 notification runtime 或 publisher 策略语义;原因是当前更高价值的事实是先量化“单处理器”和“固定 fan-out”两条 notification 路径的外部差距,再决定下一批是否值得切进 publisher strategy 或 runtime 热点
|
||||
- 当前 `RP-111` 已继续沿用 `$gframework-batch-boot 50`,并按 skill 规则重新以 `origin/main` 作为基线复核:`origin/main` = `7ca21af9`(`2026-05-08 16:12:20 +0800`),本地 `main` = `c2d22285` 已落后,当前分支 `feat/cqrs-optimization` 与 `origin/main` 的累计 branch diff 为 `0 files / 0 lines`;基于“上下文预算优先、单批可评审边界次之”的停止规则,本轮选择 `NotificationBenchmarks` 这一条仍缺 `Mediator` concrete runtime 对照的单模块 benchmark 切片,而不是为了对称性继续扩展 notification runtime seam
|
||||
- `NotificationBenchmarks` 现已从双方对照扩成三方对照:新增 NuGet `Mediator` source-generated concrete runtime 宿主与 `PublishNotification_Mediator()`,`BenchmarkNotification` / `BenchmarkNotificationHandler` 也同步接上 `Mediator` 的 notification 合同;当前 short-job 基线约为 `Mediator` `1.108 ns / 0 B`、`MediatR` `97.173 ns / 416 B`、`GFramework.Cqrs` `291.582 ns / 392 B`
|
||||
- 本轮只把“notification publish 的高性能外部对照”补齐到 benchmark 层,而没有直接新增 generated notification invoker/provider 或 runtime 语义调整;原因是 notification dispatch 现有反射委托本就只在首次命中时缓存,继续加一层 provider 对 steady-state publish 的收益信号不如先把 `Mediator` concrete runtime 对照补齐来得清晰
|
||||
- 当前 `RP-110` 已再次使用 `$gframework-pr-review` 复核 `PR #341` latest-head review:`BenchmarkHostFactory` 的 legacy runtime alias 防守式类型检查、benchmark 宿主定向 generated registry 激活、以及 `CqrsDispatcher.SendAsync(...)` 的 faulted `ValueTask` 失败语义在当前 head 均已实质收口;本轮仅继续接受仍然成立的 CodeRabbit nitpick,为 `SendAsync_Should_Return_Faulted_ValueTask_When_Handler_Is_Missing()` 补齐 `HasRegistration(...)` / `GetAll(...)` 防御性 mock,并删除 trace 中重复 `本轮权威验证` 的 `本轮下一步` 段落
|
||||
- 当前 `RP-109` 已使用 `$gframework-pr-review` 复核 `PR #341` latest-head review:benchmark 宿主改为定向激活当前场景的 generated registry,避免同一 benchmark 程序集里的其他 registry 扩大冻结服务索引与 `HasRegistration` 基线;`BenchmarkHostFactory` 为 legacy runtime alias 注册补齐防守式类型检查与 stream lifetime 运行时注释;`CqrsDispatcher.SendAsync(...)` 在保留 direct-return 热路径的同时恢复 faulted `ValueTask` 失败语义,并补齐 generated registry 定向接线与 request fault 语义回归测试;`.agents/skills/gframework-batch-boot/SKILL.md` 的 MD005 缩进也已顺手修正
|
||||
- `GFramework.Cqrs` 已完成对外部 `Mediator` 的生产级替代,当前主线已从“是否可替代”转向“仓库内部收口与能力深化顺序”
|
||||
- `dispatch/invoker` 生成前移已扩展到 request / stream 路径,`RP-077` 已补齐 request invoker provider gate 与 stream gate 对称的 descriptor / descriptor entry runtime 合同回归
|
||||
- `RP-078` 已补齐 mixed fallback metadata 在 runtime 不允许多个 fallback attribute 实例时的单字符串 attribute 回退回归
|
||||
- `RP-079` 已补齐 runtime 缺少 generated handler registry interface 时的 generator 静默跳过回归
|
||||
- `RP-080` 已将基础 generation gate 回归扩展到 notification handler interface、stream handler interface 与 registry attribute 缺失分支
|
||||
- `RP-081` 已继续补齐基础 generation gate 的 logging 与 DI runtime contract 缺失分支
|
||||
- 当前 `RP-082` 已补齐基础 generation gate 的 request handler runtime contract 缺失分支
|
||||
- `RP-083` 已补齐 mixed direct / reflected-implementation request 与 stream invoker provider 发射顺序回归
|
||||
- `RP-084` 已引入独立 `GFramework.Cqrs.Benchmarks` 项目,作为持续吸收 `Mediator` benchmark 组织方式的第一落点
|
||||
- `RP-085` 已补齐 stream request benchmark,对齐 `Mediator` messaging benchmark 的第二个核心场景
|
||||
- `RP-086` 已补齐 request pipeline `0 / 1 / 4` 数量矩阵,开始把 benchmark 关注点从单纯 messaging steady-state 扩展到行为编排开销
|
||||
- `RP-087` 已补齐 request startup benchmark,把 initialization 与 cold-start 维度正式纳入 `GFramework.Cqrs.Benchmarks`
|
||||
- 当前 `RP-088` 已补齐 request invoker reflection / generated-provider 对照,开始直接量化 dispatcher 预热 generated descriptor 的收益
|
||||
- 当前 `RP-089` 已补齐 stream invoker reflection / generated-provider 对照,使 generated descriptor 预热收益从 request 扩展到 stream 路径
|
||||
- 当前 `RP-090` 已收敛 `PR #326` benchmark review:统一 benchmark 最小宿主构建、冻结 GFramework 容器、限制 MediatR 扫描范围,并恢复 request startup cold-start 对照
|
||||
- 当前 `RP-091` 已把 benchmark 项目发布面隔离与包清单校验前移到 PR:`GFramework.Cqrs.Benchmarks` 明确保持不可打包,`publish.yml` 与 `ci.yml` 复用同一份 packed-modules 校验脚本
|
||||
- `RP-092` 已补齐 request handler `Singleton / Transient` 生命周期矩阵 benchmark,并明确把 `Scoped` 对照留到具备真实显式作用域边界的宿主模型后再评估
|
||||
- `RP-093` 已把 `GFramework.Core` 的 legacy `SendCommand` / `SendQuery` 兼容入口收敛到底层统一 `GFramework.Cqrs` runtime,同时补充 `Mediator` 未吸收能力差距复核
|
||||
- `RP-094` 已按 `PR #334` latest-head review 收口 legacy bridge 的测试注册方式、模块运行时依赖契约、异步取消语义、XML 文档缺口与兼容文档回退边界
|
||||
- `RP-095` 已继续收口 `PR #334` 剩余 review:把 legacy 同步 bridge 的阻塞等待统一切到线程池隔离 helper、补齐 `ArchitectureContext` / executor 共享 dispatch helper、修正 bridge fixture 的并行与容器释放约束,并为 runtime bridge 与 async void command cancellation 增补回归测试
|
||||
- `RP-096` 已再次使用 `$gframework-pr-review` 复核 `PR #334` latest-head review,确认仍显示为 open 的 AI threads 在本地代码中已无新增仍成立的运行时 / 测试 / 文档缺陷,剩余差异主要是 GitHub thread 未 resolve 的状态滞后
|
||||
- `RP-097` 已继续收口 `PR #334` latest-head nitpick:为 `AsyncQueryExecutorTests` / `CommandExecutorTests` 补齐可观察的上下文保留断言,并让 `RecordingCqrsRuntime` 在测试替身返回错误响应类型时抛出带请求/类型信息的诊断异常
|
||||
- 当前 `RP-098` 已再次使用 `$gframework-pr-review` 复核 `PR #334` latest-head review,并收口 `LegacyCqrsDispatchHelper.TryResolveDispatchContext(...)` 过宽吞掉 `InvalidOperationException` 的真实运行时诊断退化问题;现在仅把“上下文尚未就绪”视为允许 fallback 的信号,并为 fallback / 异常冒泡分别补齐回归测试
|
||||
- `RP-099` 已补齐 `GFramework.Cqrs` 的最小 stream pipeline seam:新增 `IStreamPipelineBehavior<,>` / `StreamMessageHandlerDelegate<,>`、`RegisterCqrsStreamPipelineBehavior<TBehavior>()`、dispatcher 侧 stream pipeline executor 缓存与 generated stream invoker 兼容回归,以及 `Architecture` 公开注册入口与对应文档说明
|
||||
- 当前 `RP-100` 已使用 `$gframework-pr-review` 复核 `PR #339` latest-head review:收口 `RegisterCqrsStreamPipelineBehavior<TBehavior>()` 的异常契约文档、为 `StreamPipelineInvocation.GetContinuation(...)` 补齐并发 continuation 缓存说明、抽取 `MicrosoftDiContainer` 的 CQRS 行为注册公共逻辑,并顺手修复当前 branch diff 内 `ICqrsRequestInvokerProvider.cs` 的 XML 缩进格式问题
|
||||
- 当前 `RP-101` 已按用户新增 benchmark 诉求收口 request 热路径:为 `IIocContainer` 新增不激活实例的 `HasRegistration(Type)`、让 dispatcher 在 `0 pipeline` 场景下跳过空行为解析,并为 `MicrosoftDiContainer` 的热路径查询补齐 debug-level 守卫,避免无效日志字符串分配
|
||||
- 当前 `RP-102` 已把 `GFramework.Cqrs.Benchmarks` 的 `Mediator` 对照组收口为官方 NuGet 引用(`Mediator.Abstractions` / `Mediator.SourceGenerator` `3.0.2`),不再使用本地 `ai-libs/Mediator` project reference;`RequestBenchmarks` 现已新增 source-generated concrete `Mediator` 对照方法,并通过 `RequestLifetimeBenchmarks` 复核 hot path 收口后的新基线
|
||||
- 当前 `RP-102` 已将 `BenchmarkDotNet.Artifacts/` 收口为默认忽略路径,并把 request steady-state / lifetime benchmark 复跑升级为 CQRS 性能相关改动的默认回归门槛;当前阶段目标明确为“持续逼近 source-generated `Mediator`,并至少稳定超过反射版 `MediatR`”
|
||||
- 当前 `RP-103` 已使用 `$gframework-pr-review` 复核 `PR #340` latest-head review:修复 `CreateStream_Should_Throw_When_Stream_Pipeline_Behavior_Context_Does_Not_Implement_IArchitectureContext` 因 strict mock 未配置 `HasRegistration(Type)` 产生的 CI 失败,收紧 `MicrosoftDiContainer.HasRegistration(Type)` 到与 `GetAll(Type)` 一致的服务键可见性语义,补齐 `IIocContainer.HasRegistration(Type)` 的异常/XML 契约与 `docs/zh-CN/core/ioc.md` 的用户接入说明,并同步 benchmark 注释与 active tracking/trace 到当前 PR 锚点
|
||||
- 当前 `RP-104` 已继续沿用 `$gframework-batch-boot 50` 压 request 热路径:先把 `CqrsDispatcher.SendAsync(...)` 改成 direct-return `ValueTask`,移除 dispatcher 自身的 `async/await` 状态机;再让 `MicrosoftDiContainer.HasRegistration(Type)` 在冻结后复用预构建的服务键索引,避免每次命中零 pipeline request 都线性扫描全部描述符;本轮 benchmark 表明第一刀显著压低 steady-state / lifetime request,第二刀在当前短跑下主要确认“无回退、收益不明显”
|
||||
- 当前 `RP-105` 已继续沿用 `$gframework-batch-boot 50` 压默认 request steady-state:为 benchmark 最小宿主补齐 CQRS runtime / registrar / registration service 基础设施,让 `RequestBenchmarks` 不再只测反射路径,而是通过 handwritten generated registry + `RegisterCqrsHandlersFromAssembly(...)` 真实接上 generated request invoker provider;本轮 benchmark 表明默认 request 路径进一步从约 `70.298 ns / 32 B` 压到约 `65.296 ns / 32 B`,`Singleton / Transient` lifetime 也同步收敛到约 `68.772 ns / 32 B` 与 `73.157 ns / 56 B`
|
||||
- 当前 `RP-106` 已把同一套 generated-provider 宿主收口扩展到 `RequestPipelineBenchmarks`:新增 handwritten `GeneratedRequestPipelineBenchmarkRegistry`,并让 `RequestPipelineBenchmarks` 改走 `RegisterCqrsHandlersFromAssembly(...)` + benchmark CQRS 基础设施预接线;本轮 benchmark 表明 `0 pipeline` steady-state 进一步收敛到约 `64.755 ns / 32 B`,`1 pipeline` 约 `353.141 ns / 536 B`,`4 pipeline` 在短跑噪音下维持约 `555.083 ns / 896 B`
|
||||
- 当前 `RP-107` 已把默认 stream steady-state 宿主也切到 generated-provider 路径:新增 handwritten `GeneratedDefaultStreamingBenchmarkRegistry`,让 `StreamingBenchmarks` 改走 `RegisterCqrsHandlersFromAssembly(...)` 并在 setup/cleanup 清理 dispatcher cache;同时将 `gframework-boot` / `gframework-batch-boot` 的默认停止规则改为“AI 上下文预算优先,建议在预计接近约 80% 安全上下文占用前收口”,不再把 changed files 误当作唯一阈值
|
||||
- 当前 `RP-108` 已补齐 stream handler `Singleton / Transient` 生命周期矩阵 benchmark:新增 `StreamLifetimeBenchmarks` 与 `GeneratedStreamLifetimeBenchmarkRegistry`,让 stream 生命周期对照沿用 generated-provider 宿主接线而不是退回纯反射路径;本轮 benchmark 表明 `Singleton` 下 baseline / `GFramework.Cqrs` / `MediatR` 约 `80.144 ns / 137.515 ns / 229.242 ns`,`Transient` 下约 `77.198 ns / 144.998 ns / 228.185 ns`
|
||||
- `ai-plan` active 入口现以 `RP-122` 为最新恢复锚点;`PR #340`、`PR #339`、`PR #334`、`PR #331`、`PR #326`、`PR #323`、`PR #307` 与其他更早阶段细节均以下方归档或说明为准
|
||||
|
||||
## 当前活跃事实
|
||||
|
||||
- 当前分支为 `feat/cqrs-optimization`
|
||||
- 本轮 `$gframework-batch-boot 50` 以 `origin/main` (`d85828c5`, `2026-05-09 12:25:41 +0800`) 为基线;本地 `main` (`c2d22285`, `2026-05-06 21:34:59 +0800`) 已落后,不作为 branch diff 基线
|
||||
- 当前已提交分支相对 `origin/main` 的累计 branch diff 为 `21 files`(`1344 insertions / 194 deletions`),仍明显低于 `$gframework-batch-boot 50` 的 `50 files` 停止阈值
|
||||
- 当前用于收口 `PR #345` review 的写面额外覆盖 `AGENTS.md`、`GFramework.Cqrs.Benchmarks/README.md` 与 `ai-plan/public/cqrs-rewrite/**`;本轮代码层面的唯一触点保持在 `StreamLifetimeBenchmarks.cs`
|
||||
- 当前 `PR #345` latest-head review body 已本地复核完毕;仍成立并已吸收的反馈集中在 `AGENTS.md` 术语说明、`StreamLifetimeBenchmarks` 的取消/注释细节,以及 benchmark README / `ai-plan` 恢复入口漂移
|
||||
- 当前批次后的默认停止依据已改为 AI 上下文预算:若下一轮预计会让活动对话、已加载 recovery 文档、验证输出与当前 diff 接近约 `80%` 安全上下文占用,应在当前自然批次边界停止,即使 branch diff 仍有余量
|
||||
- `GFramework.Cqrs.Benchmarks` 作为 benchmark 基础设施项目,必须持续排除在 NuGet / GitHub Packages 发布集合之外
|
||||
- `GFramework.Cqrs.Benchmarks` 现已覆盖 request steady-state、pipeline 数量矩阵、startup、request/stream generated invoker,以及 request handler `Singleton / Transient` 生命周期矩阵
|
||||
- `GFramework.Cqrs.Benchmarks` 当前以 NuGet 方式引用 `Mediator.Abstractions` / `Mediator.SourceGenerator` `3.0.2`;`ai-libs/Mediator` 只保留为本地源码/README 对照资料,不再参与 benchmark 项目编译
|
||||
- 当前 request steady-state benchmark 已形成 baseline / `Mediator` / `MediatR` / `GFramework.Cqrs` 四方对照:最新约 `5.608 ns / 32 B`、`5.445 ns / 32 B`、`57.071 ns / 232 B`、`64.825 ns / 32 B`
|
||||
- 当前 request lifetime benchmark 已对齐 generated-provider 宿主路径:`Singleton` 下 baseline / `GFramework.Cqrs` / `MediatR` 约为 `5.012 ns / 32 B`、`49.612 ns / 32 B`、`51.796 ns / 232 B`;`Transient` 下约为 `3.962 ns / 32 B`、`50.480 ns / 56 B`、`50.284 ns / 232 B`
|
||||
- 当前 request pipeline benchmark 已改为与默认 request steady-state 相同的 generated-provider 宿主接线路径:`0 pipeline` 约 `64.755 ns / 32 B`,`1 pipeline` 约 `353.141 ns / 536 B`,`4 pipeline` 约 `555.083 ns / 896 B`
|
||||
- 当前 stream steady-state benchmark 也已切到 generated-provider 宿主接线路径:baseline 约 `5.535 ns / 32 B`、`MediatR` 约 `59.499 ns / 232 B`、`GFramework.Cqrs` 约 `66.778 ns / 32 B`
|
||||
- 当前 stream lifetime benchmark 已更新为 `Observation=FirstItem / DrainAll` 双口径:`Singleton + FirstItem` 下 baseline / generated / reflection / `MediatR` 约为 `48.704 ns / 216 B`、`94.629 ns / 216 B`、`95.417 ns / 216 B`、`152.886 ns / 608 B`;`Singleton + DrainAll` 下约为 `73.335 ns / 280 B`、`118.860 ns / 280 B`、`119.632 ns / 280 B`、`205.629 ns / 672 B`
|
||||
- `Transient + FirstItem` 下 baseline / reflection / generated / `MediatR` 约为 `48.293 ns / 216 B`、`97.628 ns / 240 B`、`100.011 ns / 240 B`、`154.149 ns / 608 B`;`Transient + DrainAll` 下约为 `78.466 ns / 280 B`、`124.174 ns / 304 B`、`116.780 ns / 304 B`、`220.040 ns / 672 B`
|
||||
- 本轮已验证旧 benchmark 劣化的两个主热点:`0 pipeline` 场景下仍解析空行为列表,以及容器查询热路径在 debug 禁用时仍构造日志字符串;两者收口后,`GFramework.Cqrs` request 路径不再出现额外数百字节分配
|
||||
- `HasRegistration(Type)` 现在只把“同一服务键已注册”或“开放泛型服务键可闭合到目标类型”视为命中,不再把“仅以具体实现类型自注册”的行为误判为接口服务已注册;该语义与 `Get(Type)` / `GetAll(Type)` 已重新对齐
|
||||
- `GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherContextValidationTests.cs` 已同步适配 `HasRegistration(Type)` fast-path,避免 strict mock 因缺少新调用配置而在上下文失败语义断言前提前抛出 `Moq.MockException`
|
||||
- `GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherContextValidationTests.cs` 现连“handler 缺失但仍返回 faulted `ValueTask`”这条 request 失败语义回归也显式为 `HasRegistration(Type)` / `GetAll(Type)` 预留了防御性 mock,不再依赖 dispatcher 先判空 handler、后探测 pipeline 的内部顺序
|
||||
- `docs/zh-CN/core/ioc.md` 已新增 `HasRegistration(Type)` 的使用语义、热路径用途与“按服务键而非可赋值关系判断”的示例说明
|
||||
- 当前 request steady-state 仍落后于 source-generated `Mediator` 与 `MediatR`,但差距已从“额外数百字节分配 + 近 300ns”收敛到“零 pipeline fast-path 仍慢约 `31ns` / `3.6x` 于 `Mediator`”;下一批若继续压 request dispatch,应优先评估默认路径吸收 generated invoker/provider 的空间
|
||||
- 本轮 `SendAsync(...)` 的 direct-return `ValueTask` 改动已证明确实是有效热点:同样的短跑配置下,`GFramework.Cqrs` steady-state request 从约 `83.823 ns` 下探到 `69-70 ns` 区间
|
||||
- 冻结后 `HasRegistration(Type)` 服务键索引化在当前短跑下没有带来同等量级的可见收益,但也没有引入功能回退或额外分配;后续若继续压零 pipeline request,应优先重新评估“默认 request 路径进一步吸收 generated invoker/provider”而不是继续堆叠同层级微优化
|
||||
- 默认 `RequestBenchmarks`、`RequestPipelineBenchmarks` 与 `StreamingBenchmarks` 现在都已通过 handwritten generated registry + 真实 `RegisterCqrsHandlersFromAssembly(...)` 宿主接线命中 generated invoker provider,不再只代表纯反射 binding 路径
|
||||
- `gframework-boot` 与 `gframework-batch-boot` 现明确把“上下文预算接近约 80%”视为默认优先停止信号,branch diff files / lines 仅保留为次级仓库范围指标
|
||||
- 当前性能回归门槛已收紧为:只要改动触达 `GFramework.Cqrs` request dispatch、DI 热路径、invoker/provider、pipeline 或 benchmark 宿主,就必须至少复跑 `RequestBenchmarks.SendRequest_*` 与 `RequestLifetimeBenchmarks.SendRequest_*`
|
||||
- 当前阶段的性能验收目标已明确为:默认 request steady-state 路径不要求超过 source-generated `Mediator`,但必须持续逼近它,并至少稳定快于基于反射 / 扫描的 `MediatR`
|
||||
- `GFramework.Core` 当前已通过内部 bridge request / handler 把 legacy `ICommand`、`IAsyncCommand`、`IQuery`、`IAsyncQuery` 接到统一 `ICqrsRuntime`
|
||||
- 标准 `Architecture` 初始化路径会自动扫描 `GFramework.Core` 程序集中的 legacy bridge handler,因此旧 `SendCommand(...)` / `SendQuery(...)` 无需改变用法即可进入统一 pipeline
|
||||
- `CommandExecutor`、`QueryExecutor`、`AsyncQueryExecutor` 仍保留“无 runtime 时直接执行”的回退路径,用于不依赖容器的隔离单元测试
|
||||
- `LegacyCqrsDispatchHelper` 现统一负责 runtime dispatch context 解析,以及 legacy 同步 bridge 对 `ICqrsRuntime.SendAsync(...)` 的线程池隔离等待
|
||||
- `ArchitectureContext`、`CommandExecutor`、`QueryExecutor` 的同步 CQRS/legacy bridge 入口不再直接在调用线程上阻塞 `SendAsync(...).GetAwaiter().GetResult()`
|
||||
- `GFramework.Core.Tests` 现通过 `InternalsVisibleTo("GFramework.Core.Tests")` 直接实例化内部 bridge handler,不再依赖字符串反射装配测试桥接注册
|
||||
- 使用 `LegacyBridgePipelineTracker` 的 `ArchitectureContextTests` 与 `ArchitectureModulesBehaviorTests` 现都显式标记为 `NonParallelizable`
|
||||
- `ArchitectureContextTests.CreateFrozenBridgeContext(...)` 现把冻结容器所有权显式交回调用方,并在每个 bridge 用例的 `finally` 中释放
|
||||
- `CommandExecutorModule`、`QueryExecutorModule`、`AsyncQueryExecutorModule` 现改为 `GetRequired<ICqrsRuntime>()` 并在 XML 文档里显式声明注册顺序契约,避免 runtime 缺失时静默回退
|
||||
- `LegacyAsyncQueryDispatchRequestHandler`、`LegacyAsyncCommandResultDispatchRequestHandler`、`LegacyAsyncCommandDispatchRequestHandler` 现都通过 `ThrowIfCancellationRequested()` + `WaitAsync(cancellationToken)` 显式保留调用方取消可见性
|
||||
- 相对 `ai-libs/Mediator`,当前仍未完全吸收的能力集中在五类:facade 公开入口、telemetry、notification publisher 策略、生成器配置与诊断、生命周期/缓存公开配置面
|
||||
- 发布工作流已有 packed modules 校验,但 PR 工作流此前没有等价的 solution pack 产物名单校验
|
||||
- 本地 `dotnet pack GFramework.sln -c Release --no-restore -o <temp-dir>` 当前只产出 14 个预期包,未复现 benchmark `.nupkg`
|
||||
- `PR #334` 在 `2026-05-07` 的 latest-head review 当前显示 `CodeRabbit 10` / `Greptile 5` 个 open thread;本轮再次复核后确认其中大部分仍是已实质修复但未 resolve 的 stale thread,仅 `LegacyCqrsDispatchHelper.TryResolveDispatchContext(...)` 的异常边界仍需要继续收口
|
||||
- benchmark 场景现统一通过 `BenchmarkHostFactory` 构建最小宿主:GFramework 侧在 runtime 分发前显式 `Freeze()` 容器,MediatR 侧只扫描当前场景需要的 handler / behavior 类型
|
||||
- `RequestStartupBenchmarks` 已恢复 `ColdStart_GFrameworkCqrs` 结果产出,不再命中 `No CQRS request handler registered`
|
||||
- `BenchmarkDotNet` 在当前 agent 沙箱里会因自动生成的 bootstrap 脚本异常失败;同一 `dotnet run --no-build` 命令在沙箱外执行通过,因此本轮以沙箱外结果作为 benchmark 权威验证
|
||||
- 已新增手动触发的 benchmark workflow;默认只验证 benchmark 项目 Release build,只有显式提供过滤器时才执行 BenchmarkDotNet 运行;过滤器输入现通过环境变量传入 shell,避免 workflow_dispatch 输入直接插值到命令行
|
||||
- 远端 `CTRF` 最新汇总为 `2311/2311` passed(run `#1079`, 2026-05-07)
|
||||
- `MegaLinter` 当前只暴露 `dotnet-format` 的 `Restore operation failed` 环境噪音,尚未提供本地仍成立的文件级格式诊断
|
||||
- `LegacyCqrsDispatchHelper.TryResolveDispatchContext(...)` 现在只会把“Context 尚未设置”或“当前没有活动上下文”识别为可安全 fallback 的缺上下文信号;其他 `InvalidOperationException` 将继续向上传播,避免把真实运行时故障误判成 legacy 直执行场景
|
||||
- `CommandExecutorTests` 已新增“缺上下文继续 fallback”和“意外 `InvalidOperationException` 必须冒泡”的回归,防止后续再次放宽该异常过滤面
|
||||
- `PR #334` 当前 latest-head open AI feedback 经过本轮本地复核与修复后,应主要剩余待 GitHub 重新索引的状态差异或已实质关闭但未 resolve 的 thread
|
||||
- `GFramework.Core.Tests` 中 legacy bridge 的“保留上下文”回归现在同时断言 bridge request 类型与目标对象执行期观察到的 `IArchitectureContext`
|
||||
- `RecordingCqrsRuntime` 对非 `Unit` 响应已显式校验返回值类型;若测试工厂返回了 `null` 或错误装箱类型,异常会直接指出 request 类型与期望/实际响应类型
|
||||
- `PR #339` 当前 latest-head review 仍显示 `2` 个 CodeRabbit open thread 与 `2` 个 nitpick;本轮本地复核后确认:
|
||||
- `GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherCacheTests.cs` 的 `Per_Behavior_Count` 拼写已在当前 head 修正,属于 stale thread
|
||||
- `GFramework.Core.Abstractions/Ioc/IIocContainer.cs` 的流式行为注册入口此前确实缺少 `<exception>` / `<remarks>` 契约说明,现已补齐并同步到 `IArchitecture` / `Architecture` / `ArchitectureModules`
|
||||
- `GFramework.Cqrs/Internal/CqrsDispatcher.cs` 的 `StreamPipelineInvocation.GetContinuation(...)` 线程模型说明此前少于 request 对称路径,现已补齐并发 `next()` 时 continuation 缓存的语义边界
|
||||
- `GFramework.Core/Ioc/MicrosoftDiContainer.cs` 的 request / stream 行为注册逻辑此前存在重复实现,现已抽取共享私有 helper 以避免后续生命周期或校验逻辑漂移
|
||||
- 本地 `dotnet format GFramework.Cqrs/GFramework.Cqrs.csproj --verify-no-changes` 显示当前 diff 内仍有 `GFramework.Cqrs/ICqrsRequestInvokerProvider.cs` 的空白格式问题,本轮已修复;同一次命令报出的多条 `CHARSET` 提示集中在未触达的历史文件,不视为 `PR #339` 本轮新增 triage 结论
|
||||
|
||||
## 当前风险
|
||||
|
||||
- 当前 `_requestBehaviorPresenceCache` 依赖“同一 dispatcher 生命周期内,request pipeline 行为注册在容器冻结后保持稳定”这一约束;若未来引入运行时动态增删 request behavior 的模型,需要重新评估这类实例级 presence cache 的失效策略
|
||||
- 当前 `_streamBehaviorPresenceCache` 也依赖“同一 dispatcher 生命周期内,stream pipeline 行为注册在容器冻结后保持稳定”这一约束;若后续引入运行期动态增删 stream behavior 或按 scope 改写可见性的模型,需要同步设计失效策略,而不能继续假设实例级缓存永久有效
|
||||
- 标准架构启动路径现在已经有“自定义 notification publisher 不被默认顺序策略短路”的集成回归;但若后续再引入第三种仓库内置策略或新的启动快捷入口,仍需要同步补这条生产路径验证,不能只看 `CqrsTestRuntime` 测试宿主
|
||||
- 顶层 `GFramework.sln` / `GFramework.csproj` 在 WSL 下仍可能受 Windows NuGet fallback 配置影响,完整 solution 级验证成本高于模块级验证
|
||||
- 若后续新增 benchmark / example / tooling 项目但未同步校验发布面,solution 级 `dotnet pack` 仍可能在 tag 发布前才暴露异常包
|
||||
- `RequestStartupBenchmarks` 为了量化真正的单次 cold-start,引入了 `InvocationCount=1` / `UnrollFactor=1` 的专用 job;该配置会触发 BenchmarkDotNet 的 `MinIterationTime` 提示,后续若要做稳定基线比较,还需要决定是否引入批量外层循环或自定义 cold-start harness
|
||||
- 当前 benchmark 宿主仍刻意保持“单根容器最小宿主”模型;若要公平比较 `Scoped` handler 生命周期,需要先引入显式 scope 创建与 scope 内首次解析的对照基线
|
||||
- 当前 `Mediator` concrete runtime 对照已覆盖 steady-state request、单处理器 notification publish 与固定 `4 handler` notification fan-out;若要把 `Transient` / `Scoped` 生命周期矩阵、stream 生命周期矩阵或更大 fan-out 矩阵也纳入同一组对照,需要按 `Mediator` 官方 benchmark 的做法拆分 compile-time lifetime / 场景配置,而不是在同一编译产物里混用多个 runtime 变量
|
||||
- 当前 stream 生命周期矩阵尚未接入 `Mediator` concrete runtime;若要继续对齐 `Mediator` 官方 benchmark 的 compile-time lifetime 设计,需要为 stream 场景补专门的 build-time 配置,而不是在当前统一宿主里临时拼接
|
||||
- `BenchmarkDotNet.Artifacts/` 现已加入仓库忽略规则;若后续确实需要提交新的基准报告,应显式挑选结果文件或改走文档归档,而不是直接纳入整个生成目录
|
||||
- 当前 `GFramework.Cqrs` request steady-state 仍慢于 `MediatR`;在“至少超过反射版 `MediatR`”这个阶段目标达成前,任何相关改动都不能只看功能 build/test 结果,必须附带 benchmark 回归数据
|
||||
- 仓库内部仍保留旧 `Command` / `Query` API、`LegacyICqrsRuntime` alias 与部分历史命名语义,后续若不继续分批收口,容易混淆“对外替代已完成”与“内部收口未完成”
|
||||
- 若继续扩大 generated invoker 覆盖面,需要持续区分“可静态表达的合同”与 `PreciseReflectedRegistrationSpec` 等仍需保守回退的场景
|
||||
- legacy bridge 当前只为已有 `Command` / `Query` 兼容入口接到统一 request pipeline;若后续要继续对齐 `Mediator`,仍需要单独设计 stream pipeline、telemetry 与 facade 公开面,而不是把这次 bridge 当成“全部收口完成”
|
||||
- `LegacyBridgePipelineTracker` 仍是进程级静态测试辅助;虽然现在已在相关 fixture 清理阶段重置并补充线程安全说明,但若将来扩大并行 bridge fixture 数量,仍要继续控制共享状态扩散
|
||||
- stream pipeline 当前只在“单次建流”层面包裹 handler 调用;若后续需要 per-item 拦截、元素级重试或流内 metrics 聚合,仍需额外设计更细粒度 contract,而不是把本轮 seam 直接等同于元素级 middleware
|
||||
- `PR #339` 在 GitHub 上仍有 1 个已本地失效但未 resolve 的 stale test-thread;若后续 head 再次变化,需要重新抓取 latest-head review 确认未解决线程是否收敛
|
||||
- 若后续继续依赖 `HasRegistration(Type)` 做热路径短路,新增测试替身或 strict mock 时必须同步配置该调用,否则容易在真正业务断言之前被 mock 框架短路成环境性失败
|
||||
- `PR #345` 当前 latest-head review 仍以 CodeRabbit review body 的 `4` 条 actionable comments 为主;最新本地复核后,仍成立的问题集中在 `AGENTS.md` 术语说明、`StreamLifetimeBenchmarks` 的取消/文档细节,以及 benchmark README / `ai-plan` 恢复入口漂移
|
||||
|
||||
## 最近权威验证
|
||||
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- 备注:覆盖 benchmark 入口 `--artifacts-suffix` 隔离实现、`README` 命令示例与 `ai-plan` 更新后的最小 Release build
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --artifacts-suffix req-lifetime-a --filter "*RequestLifetimeBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:本次 auto-generated benchmark 项目已在 `BenchmarkDotNet.Artifacts/req-lifetime-a/host/...` 下执行;`Singleton` 约为 baseline `5.189 ns`、`MediatR` `52.765 ns`、`GFramework.Cqrs` `60.938 ns`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --artifacts-suffix stream-invoker-b --filter "*StreamInvokerBenchmarks.Stream_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:本次 auto-generated benchmark 项目已在 `BenchmarkDotNet.Artifacts/stream-invoker-b/host/...` 下执行;`DrainAll` 约为 baseline `77.82 ns`、generated `130.37 ns`、reflection `139.08 ns`、`MediatR` `245.23 ns`
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Benchmarks/Program.cs GFramework.Cqrs.Benchmarks/README.md ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md`
|
||||
- 结果:通过
|
||||
- `git --git-dir=<repo>/.git/worktrees/GFramework-cqrs --work-tree=. diff --check`
|
||||
- 结果:通过
|
||||
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- 备注:覆盖 `BenchmarkHostFactory` scoped stream helper、`StreamLifetimeBenchmarks` scoped 生命周期矩阵与 `README` 同步后的最小 Release build
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*StreamLifetimeBenchmarks*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:`StreamLifetimeBenchmarks` 已稳定跑出 `24` 个 case;`Scoped + DrainAll` 当前约为 baseline `81.20 ns / 280 B`、`MediatR` `428.66 ns / 1224 B`、generated `692.05 ns / 3888 B`、reflection `716.61 ns / 3888 B`
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Benchmarks/Messaging/BenchmarkHostFactory.cs GFramework.Cqrs.Benchmarks/Messaging/StreamLifetimeBenchmarks.cs GFramework.Cqrs.Benchmarks/README.md ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md`
|
||||
- 结果:通过
|
||||
- `git --git-dir=<repo>/.git/worktrees/GFramework-cqrs --work-tree=. diff --check`
|
||||
- 结果:通过
|
||||
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- 备注:覆盖 `StreamLifetimeBenchmarks` 的代码收口与 benchmark `README` 同步后的最小 Release build
|
||||
- `python3 scripts/license-header.py --check --paths AGENTS.md GFramework.Cqrs.Benchmarks/Messaging/StreamLifetimeBenchmarks.cs GFramework.Cqrs.Benchmarks/README.md ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md`
|
||||
- 结果:通过
|
||||
- `git --git-dir=<repo>/.git/worktrees/GFramework-cqrs --work-tree=. diff --shortstat d85828c5...HEAD`
|
||||
- 结果:通过
|
||||
- 备注:当前分支相对 `origin/main` 的累计 diff 为 `21 files changed, 1344 insertions(+), 194 deletions(-)`
|
||||
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*RequestLifetimeBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:宿主已对齐 generated-provider 路径;`Singleton` 下 baseline / `GFramework.Cqrs` / `MediatR` 约为 `5.012 ns / 32 B`、`49.612 ns / 32 B`、`51.796 ns / 232 B`;`Transient` 下约为 `3.962 ns / 32 B`、`50.480 ns / 56 B`、`50.284 ns / 232 B`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*StreamLifetimeBenchmarks*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:已产出 `baseline / GFramework reflection / GFramework generated / MediatR` 四方矩阵;`Singleton` 下约为 `79.602 / 120.553 / 111.547 / 208.381 ns`,`Transient` 下约为 `76.351 / 119.632 / 129.166 / 213.420 ns`
|
||||
- `python3 scripts/license-header.py --check --paths ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md`
|
||||
- 结果:通过
|
||||
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs/Internal/CqrsDispatcher.cs GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherContextValidationTests.cs GFramework.Cqrs.Tests/Cqrs/CqrsNotificationPublisherTests.cs GFramework.Cqrs.Tests/Cqrs/NotificationPublisherRegistrationExtensionsTests.cs GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherCacheTests.cs ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md`
|
||||
- 结果:通过
|
||||
- `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- 备注:首轮与 `GFramework.Cqrs.Tests` 并行构建时曾出现 `MSB3026` 单次复制重试;串行重跑同一命令后稳定通过
|
||||
- `dotnet build GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~CqrsDispatcherContextValidationTests|FullyQualifiedName~CqrsNotificationPublisherTests|FullyQualifiedName~NotificationPublisherRegistrationExtensionsTests|FullyQualifiedName~CqrsDispatcherCacheTests"`
|
||||
- 结果:通过,`30/30` passed
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
|
||||
- `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~CqrsDispatcherCacheTests"`
|
||||
- 结果:通过,`11/11` passed
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*RequestBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:默认 request steady-state 当前约为 baseline `5.876 ns / 32 B`、`Mediator` `5.275 ns / 32 B`、`GFramework.Cqrs` `51.717 ns / 32 B`、`MediatR` `56.108 ns / 232 B`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*RequestLifetimeBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:`Singleton` 下 baseline / `GFramework.Cqrs` / `MediatR` 约 `5.720 ns / 52.490 ns / 56.890 ns`,`Transient` 下约 `5.814 ns / 57.746 ns / 55.545 ns`
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs/Internal/CqrsDispatcher.cs GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherCacheTests.cs`
|
||||
- 结果:通过
|
||||
- `dotnet build GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~ArchitectureModulesBehaviorTests"`
|
||||
- 结果:通过,`5/5` passed
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Core.Tests/Architectures/ArchitectureModulesBehaviorTests.cs`
|
||||
- 结果:通过
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*NotificationFanOutBenchmarks*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:本轮对称化 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`
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Benchmarks/Messaging/NotificationFanOutBenchmarks.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`
|
||||
- 结果:通过
|
||||
- 备注:仅剩 `GFramework.sln` 的历史 CRLF 提示,无本轮新增 diff 格式问题
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*NotificationFanOutBenchmarks*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:历史基线(`RP-112`)固定 `4 handler` notification fan-out 对照约为 baseline `8.302 ns / 0 B`、`Mediator` `4.314 ns / 0 B`、`MediatR` `230.304 ns / 1256 B`、`GFramework.Cqrs` `434.413 ns / 408 B`
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Benchmarks/Messaging/NotificationFanOutBenchmarks.cs GFramework.Cqrs.Benchmarks/README.md`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- 备注:仅剩 `GFramework.sln` 的历史 CRLF 提示,无本轮新增 diff 格式问题
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*NotificationBenchmarks*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:notification publish 三方对照当前约为 `Mediator` `1.108 ns / 0 B`、`MediatR` `97.173 ns / 416 B`、`GFramework.Cqrs` `291.582 ns / 392 B`
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Benchmarks/Messaging/NotificationBenchmarks.cs GFramework.Cqrs.Benchmarks/README.md`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- 备注:仅剩 `GFramework.sln` 的历史 CRLF 提示,无本轮新增 diff 格式问题
|
||||
- `dotnet build GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- 备注:并行验证首轮曾因 `build` 与 `test` 同时写入同一输出 DLL 触发 `MSB3026` 单次复制重试;改为串行重跑同一命令后稳定通过
|
||||
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --filter "FullyQualifiedName~CqrsDispatcherContextValidationTests"`
|
||||
- 结果:通过,`6/6` passed
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherContextValidationTests.cs`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- 备注:仅剩 `GFramework.sln` 的历史 CRLF 提示,无本轮新增 diff 格式问题
|
||||
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --format json --json-output /tmp/current-pr-review.json`
|
||||
- 结果:通过
|
||||
- 备注:确认当前分支对应 `PR #342`;latest-head 当前显示 `CodeRabbit 4` / `Greptile 3` open thread,其中真正仍成立的是 benchmark handler 对称性、README / 中文文档示例与恢复文档锚点漂移,其余历史 thread 需要按当前 head 继续甄别
|
||||
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --json-output /tmp/current-pr-review.json`
|
||||
- 结果:通过
|
||||
- 备注:确认当前分支对应 `PR #340`;latest-head 当前显示 `CodeRabbit 2` / `Greptile 2` open thread,且 `CTRF` 报告中唯一失败测试为 `CreateStream_Should_Throw_When_Stream_Pipeline_Behavior_Context_Does_Not_Implement_IArchitectureContext`
|
||||
- `dotnet build GFramework.Core.Abstractions/GFramework.Core.Abstractions.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet build GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~MicrosoftDiContainerTests"`
|
||||
- 结果:通过,`52/52` passed
|
||||
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~CqrsDispatcherContextValidationTests"`
|
||||
- 结果:通过,`4/4` passed
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:按新性能回归门槛复跑后,steady-state request 对照约为 baseline `5.300 ns / 32 B`、`Mediator` `4.964 ns / 32 B`、`MediatR` `57.993 ns / 232 B`、`GFramework.Cqrs` `83.823 ns / 32 B`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestLifetimeBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:按新性能回归门槛复跑后,`Singleton` 下 `GFramework.Cqrs` / `MediatR` 约 `83.183 ns / 32 B` vs `60.915 ns / 232 B`;`Transient` 下约 `86.243 ns / 56 B` vs `59.644 ns / 232 B`
|
||||
- `env GIT_DIR=... GIT_WORK_TREE=... python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Benchmarks/Messaging/GeneratedStreamLifetimeBenchmarkRegistry.cs GFramework.Cqrs.Benchmarks/Messaging/StreamLifetimeBenchmarks.cs GFramework.Cqrs.Benchmarks/README.md`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:steady-state request 对照约为 baseline `5.336 ns / 32 B`、`Mediator` `5.564 ns / 32 B`、`MediatR` `53.307 ns / 232 B`、`GFramework.Cqrs` `64.745 ns / 32 B`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestLifetimeBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:`Singleton` 下 baseline / `MediatR` / `GFramework.Cqrs` 约 `4.309 ns / 51.923 ns / 67.981 ns`;`Transient` 下约 `5.029 ns / 54.435 ns / 76.437 ns`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*StreamLifetimeBenchmarks*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:`Singleton` 下 baseline / `GFramework.Cqrs` / `MediatR` 约 `80.144 ns / 137.515 ns / 229.242 ns`,`Transient` 下约 `77.198 ns / 144.998 ns / 228.185 ns`
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- 备注:当前仅保留 `GFramework.sln` 的历史 CRLF 警告,无本轮新增 diff 格式错误
|
||||
- `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~MicrosoftDiContainerTests"`
|
||||
- 结果:通过,`52/52` passed
|
||||
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~CqrsDispatcherCacheTests|FullyQualifiedName~CqrsDispatcherContextValidationTests"`
|
||||
- 结果:通过,`14/14` passed
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*RequestBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:本轮两批热路径收口后的最新 steady-state request 对照约为 baseline `6.141 ns / 32 B`、`Mediator` `6.674 ns / 32 B`、`MediatR` `61.803 ns / 232 B`、`GFramework.Cqrs` `70.298 ns / 32 B`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*RequestLifetimeBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:最新 lifetime request 对照约为 `Singleton` 下 baseline / `MediatR` / `GFramework.Cqrs` = `4.706 ns / 52.197 ns / 73.005 ns`,`Transient` 下 = `4.571 ns / 50.175 ns / 74.757 ns`
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Benchmarks/Messaging/GeneratedDefaultRequestBenchmarkRegistry.cs GFramework.Cqrs.Benchmarks/Messaging/BenchmarkHostFactory.cs GFramework.Cqrs.Benchmarks/Messaging/RequestBenchmarks.cs GFramework.Cqrs.Benchmarks/README.md`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:默认 steady-state request 对照现约为 baseline `5.013 ns / 32 B`、`Mediator` `5.747 ns / 32 B`、`MediatR` `51.588 ns / 232 B`、`GFramework.Cqrs` `65.296 ns / 32 B`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestLifetimeBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:最新 lifetime request 对照约为 `Singleton` 下 baseline / `MediatR` / `GFramework.Cqrs` = `4.817 ns / 48.177 ns / 68.772 ns`,`Transient` 下 = `4.841 ns / 51.753 ns / 73.157 ns`
|
||||
- `env GIT_DIR=... GIT_WORK_TREE=... python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- 备注:仍仅保留 `GFramework.sln` 的历史 CRLF 警告,无本轮新增 diff 格式问题
|
||||
- `dotnet pack GFramework.sln -c Release --no-restore -o /tmp/gframework-pack-validation -p:IncludeSymbols=false`
|
||||
- 结果:通过
|
||||
- 备注:当前本地产物仅包含 14 个预期发布包,未生成 `GFramework.Cqrs.Benchmarks.*.nupkg`
|
||||
- `bash scripts/validate-packed-modules.sh /tmp/gframework-pack-validation`
|
||||
- 结果:通过
|
||||
- 备注:共享脚本确认 actual package set 与预期 14 个发布包完全一致
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~MicrosoftDiContainerTests"`
|
||||
- 结果:通过,`51/51` passed
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:最新 steady-state request 对照约为 baseline `5.969 ns / 32 B`、`Mediator` `6.242 ns / 32 B`、`MediatR` `53.818 ns / 232 B`、`GFramework.Cqrs` `85.504 ns / 32 B`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestLifetimeBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:`Singleton` 下 `GFramework.Cqrs` / `MediatR` 约 `84.066 ns / 32 B` vs `56.096 ns / 232 B`;`Transient` 下约 `90.652 ns / 56 B` vs `57.207 ns / 232 B`
|
||||
- `python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestStartupBenchmarks*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:`ColdStart_GFrameworkCqrs` 已恢复出数,最新本地输出约 `220-292 us`,MediatR 对照约 `575-616 us`;当前仅剩 BenchmarkDotNet 对单次 cold-start 场景的 `MinIterationTime` 提示
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- 备注:用于验证本轮 request invoker / pipeline / stream invoker 调整与 benchmark workflow 改动后的 Release 编译结果
|
||||
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --format json --json-output /tmp/current-pr-review.json`
|
||||
- 结果:通过
|
||||
- 备注:确认当前分支对应 `PR #334`;`CodeRabbit` latest review 已 `APPROVED`,但 latest-head 仍显示 `10` 个 open thread、`Greptile` 仍显示 `3` 个 open thread;本地逐项复核后未发现新的仍成立缺陷,最新 CI 测试汇总为 `2311/2311` passed,`MegaLinter` 仅剩 `dotnet-format` restore 环境噪音
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --json-output /tmp/current-pr-review.json`
|
||||
- 结果:通过
|
||||
- 备注:确认当前分支对应 `PR #339`;latest-head 显示 `2` 个 CodeRabbit open thread 与 `2` 个 nitpick;本轮本地接受并修复的问题集中在流式行为注册入口 XML 契约、stream continuation 线程说明与 `MicrosoftDiContainer` 的重复注册逻辑,测试方法拼写线程已在当前 head 失效
|
||||
- `dotnet format GFramework.Cqrs/GFramework.Cqrs.csproj --verify-no-changes`
|
||||
- 结果:发现当前 diff 内 `GFramework.Cqrs/ICqrsRequestInvokerProvider.cs` 的空白格式问题;其余 `CHARSET` 提示集中在未触达的历史文件
|
||||
- `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet build GFramework.Core.Abstractions/GFramework.Core.Abstractions.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 --no-build --filter "FullyQualifiedName~CqrsDispatcherCacheTests"`
|
||||
- 结果:通过,`10/10` passed
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~ArchitectureModulesBehaviorTests"`
|
||||
- 结果:通过,`4/4` passed
|
||||
- `env GIT_DIR=... GIT_WORK_TREE=... python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `dotnet build GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~CommandExecutorTests|FullyQualifiedName~AsyncQueryExecutorTests"`
|
||||
- 结果:通过,`19/19` passed
|
||||
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --json-output /tmp/current-pr-review.json`
|
||||
- 结果:通过
|
||||
- 备注:确认当前分支对应 `PR #334`;最新 review 仍为 `CodeRabbit APPROVED (2026-05-07T12:20:24Z)`,latest-head 显示 `CodeRabbit 10` / `Greptile 5` open thread;本轮接受并修复的仍成立问题收敛到 `LegacyCqrsDispatchHelper.TryResolveDispatchContext(...)` 的过宽异常吞掉逻辑
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet build GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~CommandExecutorTests|FullyQualifiedName~QueryExecutorTests"`
|
||||
- 结果:通过,`25/25` passed
|
||||
- `python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --format json --json-output <temporary-json-output>`
|
||||
- 结果:通过
|
||||
- 备注:确认当前分支对应 `PR #331`,本轮 latest-head open AI feedback 已收敛到 `dotnet pack --no-build`、共享包校验脚本跨平台兼容性与 active 文档 PR 锚点同步
|
||||
- `python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- 备注:当前 WSL worktree 需要显式绑定 `GIT_DIR` / `GIT_WORK_TREE` 后运行
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestLifetimeBenchmarks*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过(以沙箱外 `--no-build` 权威结果为准)
|
||||
- 备注:`Singleton` 下 baseline / MediatR / GFramework 均值约 `5.633 ns / 58.687 ns / 301.731 ns`;`Transient` 下约 `5.044 ns / 52.274 ns / 287.863 ns`
|
||||
- `python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- 备注:当前 WSL worktree 需要显式绑定 `GIT_DIR` / `GIT_WORK_TREE`
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~ArchitectureContextTests|FullyQualifiedName~CommandExecutorTests|FullyQualifiedName~QueryExecutorTests|FullyQualifiedName~AsyncQueryExecutorTests"`
|
||||
- 结果:通过,`45/45` passed
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release`
|
||||
- 结果:通过,`1644/1644` passed
|
||||
- `env GIT_DIR=... GIT_WORK_TREE=... python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --json-output /tmp/current-pr-review.json`
|
||||
- 结果:通过
|
||||
- 备注:确认当前分支对应 `PR #334`;仍有效的 latest-head review 已收敛到 legacy bridge 测试装配、运行时依赖契约、异步取消、XML 文档与兼容文档边界
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- 备注:修复新增 XML 文档 warning 后复跑,当前 `GFramework.Core` 三个 target framework 均已干净通过
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~ArchitectureContextTests|FullyQualifiedName~ArchitectureModulesBehaviorTests|FullyQualifiedName~CommandExecutorTests|FullyQualifiedName~QueryExecutorTests|FullyQualifiedName~AsyncQueryExecutorTests"`
|
||||
- 结果:通过,`48/48` passed
|
||||
- 备注:覆盖 legacy bridge 兼容入口、测试装配、执行器 runtime fallback 与相关模块行为
|
||||
- `env GIT_DIR=... GIT_WORK_TREE=... python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~ArchitectureContextTests|FullyQualifiedName~ArchitectureModulesBehaviorTests|FullyQualifiedName~CommandExecutorTests|FullyQualifiedName~QueryExecutorTests|FullyQualifiedName~AsyncQueryExecutorTests|FullyQualifiedName~LegacyAsyncCommandDispatchRequestHandlerTests"`
|
||||
- 结果:通过,`54/54` passed
|
||||
- 备注:覆盖 legacy 同步 bridge 的同步上下文隔离、bridge fixture 容器释放,以及 async void command cancellation 可见性
|
||||
- `env GIT_DIR=... GIT_WORK_TREE=... python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
|
||||
## 下一推荐步骤
|
||||
|
||||
1. 若下一轮继续压 request steady-state,优先挑选仍能减少常量热路径查询/分支的切片;继续避开“类型级 `IContextAware` 判定缓存”这条已验证无收益的热点假设
|
||||
2. 若下一轮转向 benchmark 对齐,优先评估 `request scoped host + compile-time lifetime` 对照,而不是继续并行跑多个 BenchmarkDotNet 任务去争用同一自动生成目录
|
||||
3. 若下一轮回到 notification 线,应把问题重新收敛到“是否值得公开第三种仓库内置 publisher strategy”或“是否需要 `IServiceCollection` 版本的公开入口”,而不是继续重复扩同层级回归
|
||||
|
||||
## 活跃文档
|
||||
|
||||
- 历史跟踪归档:[cqrs-rewrite-history-through-rp043.md](../archive/todos/cqrs-rewrite-history-through-rp043.md)
|
||||
- 验证历史归档:[cqrs-rewrite-validation-history-through-rp062.md](../archive/todos/cqrs-rewrite-validation-history-through-rp062.md)
|
||||
- `RP-063` 至 `RP-074` 验证归档:[cqrs-rewrite-validation-history-rp063-through-rp074.md](../archive/todos/cqrs-rewrite-validation-history-rp063-through-rp074.md)
|
||||
- `RP-062` 至 `RP-076` trace 归档:[cqrs-rewrite-history-rp062-through-rp076.md](../archive/traces/cqrs-rewrite-history-rp062-through-rp076.md)
|
||||
- CQRS 与 Mediator 评估归档:[cqrs-vs-mediator-assessment-rp063.md](../archive/todos/cqrs-vs-mediator-assessment-rp063.md)
|
||||
- 历史 trace 归档:[cqrs-rewrite-history-through-rp043.md](../archive/traces/cqrs-rewrite-history-through-rp043.md)
|
||||
- `RP-046` 至 `RP-061` trace 归档:[cqrs-rewrite-history-rp046-through-rp061.md](../archive/traces/cqrs-rewrite-history-rp046-through-rp061.md)
|
||||
|
||||
## 说明
|
||||
|
||||
- `PR #261`、`PR #302`、`PR #305`、`PR #307` 及更早阶段的详细过程已不再作为 active 恢复入口;如需追溯,以对应归档文件或历史 trace 段落为准
|
||||
- active tracking 仅保留当前恢复点、当前风险、最近权威验证与下一推荐步骤,避免 `boot` 落到历史阶段细节
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,8 @@
|
||||
<!--
|
||||
Copyright (c) 2025-2026 GeWuYou
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
|
||||
# CQRS 重写迁移跟踪
|
||||
|
||||
## 目标
|
||||
@ -7,406 +12,85 @@ CQRS 迁移与收敛。
|
||||
|
||||
## 当前恢复点
|
||||
|
||||
- 恢复点编号:`CQRS-REWRITE-RP-118`
|
||||
- 恢复点编号:`CQRS-REWRITE-RP-140`
|
||||
- 当前阶段:`Phase 8`
|
||||
- 当前 PR 锚点:`PR #342`
|
||||
- 当前 PR 锚点:`PR #350(OPEN,2026-05-12)`
|
||||
- 当前结论:
|
||||
- 当前 `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`
|
||||
- `RP-117` 留在“最近权威验证”中的固定 `4 handler` fan-out 数值属于早期 benchmark 记录,因此本轮选择显式补上 `历史基线(RP-112)` 标注,而不是把历史验证段落改写成新的 benchmark 结果,避免混淆恢复轨迹
|
||||
- 当前 `RP-117` 继续沿用 `$gframework-batch-boot 50`,但没有继续把 batch 推回 request dispatch 热路径:本轮先试了一刀“按运行时类型缓存 `IContextAware` 判定”的 dispatcher 微优化,随后按 `RequestBenchmarks` / `RequestLifetimeBenchmarks` 复跑确认 steady-state request 反而回落到约 `71.824 ns`,因此这组运行时代码已在同轮完全撤回,不保留负收益热点实验
|
||||
- 这一批改为只收口 notification publisher 的采用文档:`GFramework.Cqrs/README.md` 与 `docs/zh-CN/core/cqrs.md` 现在把 `Sequential` / `TaskWhenAll` / 自定义 publisher 三条策略放进同一张选择矩阵,明确 `TaskWhenAll` 的价值是“并行完成 + 聚合失败”,而不是 fixed fan-out publish 的性能升级开关
|
||||
- 当前分支相对 `origin/main`(`7ca21af9`, `2026-05-08 16:12:20 +0800`)的累计 branch diff 仍约为 `12 files`,远低于 `$gframework-batch-boot 50` 的停止阈值;因此这批继续保持 notification 采用边界内的低风险、可评审文档切片
|
||||
- 当前 `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 结论保持一致,减少使用者把它误解成默认的性能升级开关
|
||||
- 当前 `RP-114` 已继续沿用 `$gframework-batch-boot 50`,并沿着 `RP-113` 刚落地的 notification publisher 能力切片继续补 benchmark:`NotificationFanOutBenchmarks` 现同时纳入 `GFramework.Cqrs` 默认顺序发布器与新内置 `TaskWhenAllNotificationPublisher`,用于量化“能力差距收口后,固定 4 handler fan-out 的成本变化”
|
||||
- `RP-114` 的 short-job 结果显示:fixed `4 handler` fan-out 下,默认顺序发布器约 `427.453 ns / 408 B`,内置 `TaskWhenAllNotificationPublisher` 约 `472.574 ns / 496 B`,`MediatR` 约 `225.940 ns / 1256 B`,NuGet `Mediator` concrete runtime 约 `3.854 ns / 0 B`;这说明当前内置并行 publisher 的主要价值是语义补齐,而不是 steady-state fan-out 性能收益
|
||||
- 这一批保持 runtime 公开 API 与 notification 语义不变,只扩 benchmark 对照口径与恢复文档;原因是 `RP-113` 已经把并行 publisher 能力落到 production path,当前更高价值的是先证明它相对默认顺序发布器、`Mediator` 与 `MediatR` 的成本位置,而不是立即继续扩第二个 publisher strategy
|
||||
- 当前分支相对 `origin/main` 的累计 branch diff 启动时为 `9 files`,仍明显低于 `$gframework-batch-boot 50` 的停止阈值;这一批继续保持单模块、低风险、可直接评审的 benchmark 边界
|
||||
- 当前 `RP-113` 已继续沿用 `$gframework-batch-boot 50`,并把 notification 线从 benchmark 对照推进到实际 runtime 能力:新增公开内置 `TaskWhenAllNotificationPublisher`,让 `GFramework.Cqrs` 在保留默认顺序发布器的同时,提供与 `Mediator` `TaskWhenAllPublisher` 对齐的并行 notification publish 策略
|
||||
- `TaskWhenAllNotificationPublisher` 当前语义明确为:零处理器静默完成,单处理器直接透传,多处理器并行启动并等待全部结束;它不保留默认顺序发布器的“首个异常立即停止”语义,而是把全部处理器的失败/取消结果收敛到同一个返回任务
|
||||
- 本轮同时补齐 `CqrsNotificationPublisherTests` 对新内置策略的回归,并更新 `GFramework.Cqrs/README.md` 与 `docs/zh-CN/core/cqrs.md`,把切换方式和语义边界写回用户可见文档;当前已提交 branch diff 仍明显低于 `$gframework-batch-boot 50` 的停止阈值
|
||||
- 这一批选择真正落一个内置 publisher strategy,而不是继续加 notification benchmark 维度;原因是 `RP-111` / `RP-112` 已经把 notification gap 量化清楚,下一步更高价值的是开始收口“能力差距”而不是继续重复建立对照数据
|
||||
- 当前 `RP-112` 已继续沿用 `$gframework-batch-boot 50`,并在 `RP-111` 的单处理器 notification 对照基础上补齐固定 `4 handler` 的 fan-out publish benchmark:新增 `NotificationFanOutBenchmarks`,对比 baseline、`GFramework.Cqrs`、NuGet `Mediator` concrete runtime 与 `MediatR`
|
||||
- `NotificationFanOutBenchmarks` 当前 short-job 基线约为 baseline `8.302 ns / 0 B`、`Mediator` `4.314 ns / 0 B`、`MediatR` `230.304 ns / 1256 B`、`GFramework.Cqrs` `434.413 ns / 408 B`;这说明 notification fan-out 的差距已经不只体现在单处理器 publish,而是在固定 4 处理器场景下依然保持相近量级
|
||||
- 本轮仍然只扩 benchmark 对照口径,没有直接修改 notification runtime 或 publisher 策略语义;原因是当前更高价值的事实是先量化“单处理器”和“固定 fan-out”两条 notification 路径的外部差距,再决定下一批是否值得切进 publisher strategy 或 runtime 热点
|
||||
- 当前 `RP-111` 已继续沿用 `$gframework-batch-boot 50`,并按 skill 规则重新以 `origin/main` 作为基线复核:`origin/main` = `7ca21af9`(`2026-05-08 16:12:20 +0800`),本地 `main` = `c2d22285` 已落后,当前分支 `feat/cqrs-optimization` 与 `origin/main` 的累计 branch diff 为 `0 files / 0 lines`;基于“上下文预算优先、单批可评审边界次之”的停止规则,本轮选择 `NotificationBenchmarks` 这一条仍缺 `Mediator` concrete runtime 对照的单模块 benchmark 切片,而不是为了对称性继续扩展 notification runtime seam
|
||||
- `NotificationBenchmarks` 现已从双方对照扩成三方对照:新增 NuGet `Mediator` source-generated concrete runtime 宿主与 `PublishNotification_Mediator()`,`BenchmarkNotification` / `BenchmarkNotificationHandler` 也同步接上 `Mediator` 的 notification 合同;当前 short-job 基线约为 `Mediator` `1.108 ns / 0 B`、`MediatR` `97.173 ns / 416 B`、`GFramework.Cqrs` `291.582 ns / 392 B`
|
||||
- 本轮只把“notification publish 的高性能外部对照”补齐到 benchmark 层,而没有直接新增 generated notification invoker/provider 或 runtime 语义调整;原因是 notification dispatch 现有反射委托本就只在首次命中时缓存,继续加一层 provider 对 steady-state publish 的收益信号不如先把 `Mediator` concrete runtime 对照补齐来得清晰
|
||||
- 当前 `RP-110` 已再次使用 `$gframework-pr-review` 复核 `PR #341` latest-head review:`BenchmarkHostFactory` 的 legacy runtime alias 防守式类型检查、benchmark 宿主定向 generated registry 激活、以及 `CqrsDispatcher.SendAsync(...)` 的 faulted `ValueTask` 失败语义在当前 head 均已实质收口;本轮仅继续接受仍然成立的 CodeRabbit nitpick,为 `SendAsync_Should_Return_Faulted_ValueTask_When_Handler_Is_Missing()` 补齐 `HasRegistration(...)` / `GetAll(...)` 防御性 mock,并删除 trace 中重复 `本轮权威验证` 的 `本轮下一步` 段落
|
||||
- 当前 `RP-109` 已使用 `$gframework-pr-review` 复核 `PR #341` latest-head review:benchmark 宿主改为定向激活当前场景的 generated registry,避免同一 benchmark 程序集里的其他 registry 扩大冻结服务索引与 `HasRegistration` 基线;`BenchmarkHostFactory` 为 legacy runtime alias 注册补齐防守式类型检查与 stream lifetime 运行时注释;`CqrsDispatcher.SendAsync(...)` 在保留 direct-return 热路径的同时恢复 faulted `ValueTask` 失败语义,并补齐 generated registry 定向接线与 request fault 语义回归测试;`.agents/skills/gframework-batch-boot/SKILL.md` 的 MD005 缩进也已顺手修正
|
||||
- `GFramework.Cqrs` 已完成对外部 `Mediator` 的生产级替代,当前主线已从“是否可替代”转向“仓库内部收口与能力深化顺序”
|
||||
- `dispatch/invoker` 生成前移已扩展到 request / stream 路径,`RP-077` 已补齐 request invoker provider gate 与 stream gate 对称的 descriptor / descriptor entry runtime 合同回归
|
||||
- `RP-078` 已补齐 mixed fallback metadata 在 runtime 不允许多个 fallback attribute 实例时的单字符串 attribute 回退回归
|
||||
- `RP-079` 已补齐 runtime 缺少 generated handler registry interface 时的 generator 静默跳过回归
|
||||
- `RP-080` 已将基础 generation gate 回归扩展到 notification handler interface、stream handler interface 与 registry attribute 缺失分支
|
||||
- `RP-081` 已继续补齐基础 generation gate 的 logging 与 DI runtime contract 缺失分支
|
||||
- 当前 `RP-082` 已补齐基础 generation gate 的 request handler runtime contract 缺失分支
|
||||
- `RP-083` 已补齐 mixed direct / reflected-implementation request 与 stream invoker provider 发射顺序回归
|
||||
- `RP-084` 已引入独立 `GFramework.Cqrs.Benchmarks` 项目,作为持续吸收 `Mediator` benchmark 组织方式的第一落点
|
||||
- `RP-085` 已补齐 stream request benchmark,对齐 `Mediator` messaging benchmark 的第二个核心场景
|
||||
- `RP-086` 已补齐 request pipeline `0 / 1 / 4` 数量矩阵,开始把 benchmark 关注点从单纯 messaging steady-state 扩展到行为编排开销
|
||||
- `RP-087` 已补齐 request startup benchmark,把 initialization 与 cold-start 维度正式纳入 `GFramework.Cqrs.Benchmarks`
|
||||
- 当前 `RP-088` 已补齐 request invoker reflection / generated-provider 对照,开始直接量化 dispatcher 预热 generated descriptor 的收益
|
||||
- 当前 `RP-089` 已补齐 stream invoker reflection / generated-provider 对照,使 generated descriptor 预热收益从 request 扩展到 stream 路径
|
||||
- 当前 `RP-090` 已收敛 `PR #326` benchmark review:统一 benchmark 最小宿主构建、冻结 GFramework 容器、限制 MediatR 扫描范围,并恢复 request startup cold-start 对照
|
||||
- 当前 `RP-091` 已把 benchmark 项目发布面隔离与包清单校验前移到 PR:`GFramework.Cqrs.Benchmarks` 明确保持不可打包,`publish.yml` 与 `ci.yml` 复用同一份 packed-modules 校验脚本
|
||||
- `RP-092` 已补齐 request handler `Singleton / Transient` 生命周期矩阵 benchmark,并明确把 `Scoped` 对照留到具备真实显式作用域边界的宿主模型后再评估
|
||||
- `RP-093` 已把 `GFramework.Core` 的 legacy `SendCommand` / `SendQuery` 兼容入口收敛到底层统一 `GFramework.Cqrs` runtime,同时补充 `Mediator` 未吸收能力差距复核
|
||||
- `RP-094` 已按 `PR #334` latest-head review 收口 legacy bridge 的测试注册方式、模块运行时依赖契约、异步取消语义、XML 文档缺口与兼容文档回退边界
|
||||
- `RP-095` 已继续收口 `PR #334` 剩余 review:把 legacy 同步 bridge 的阻塞等待统一切到线程池隔离 helper、补齐 `ArchitectureContext` / executor 共享 dispatch helper、修正 bridge fixture 的并行与容器释放约束,并为 runtime bridge 与 async void command cancellation 增补回归测试
|
||||
- `RP-096` 已再次使用 `$gframework-pr-review` 复核 `PR #334` latest-head review,确认仍显示为 open 的 AI threads 在本地代码中已无新增仍成立的运行时 / 测试 / 文档缺陷,剩余差异主要是 GitHub thread 未 resolve 的状态滞后
|
||||
- `RP-097` 已继续收口 `PR #334` latest-head nitpick:为 `AsyncQueryExecutorTests` / `CommandExecutorTests` 补齐可观察的上下文保留断言,并让 `RecordingCqrsRuntime` 在测试替身返回错误响应类型时抛出带请求/类型信息的诊断异常
|
||||
- 当前 `RP-098` 已再次使用 `$gframework-pr-review` 复核 `PR #334` latest-head review,并收口 `LegacyCqrsDispatchHelper.TryResolveDispatchContext(...)` 过宽吞掉 `InvalidOperationException` 的真实运行时诊断退化问题;现在仅把“上下文尚未就绪”视为允许 fallback 的信号,并为 fallback / 异常冒泡分别补齐回归测试
|
||||
- `RP-099` 已补齐 `GFramework.Cqrs` 的最小 stream pipeline seam:新增 `IStreamPipelineBehavior<,>` / `StreamMessageHandlerDelegate<,>`、`RegisterCqrsStreamPipelineBehavior<TBehavior>()`、dispatcher 侧 stream pipeline executor 缓存与 generated stream invoker 兼容回归,以及 `Architecture` 公开注册入口与对应文档说明
|
||||
- 当前 `RP-100` 已使用 `$gframework-pr-review` 复核 `PR #339` latest-head review:收口 `RegisterCqrsStreamPipelineBehavior<TBehavior>()` 的异常契约文档、为 `StreamPipelineInvocation.GetContinuation(...)` 补齐并发 continuation 缓存说明、抽取 `MicrosoftDiContainer` 的 CQRS 行为注册公共逻辑,并顺手修复当前 branch diff 内 `ICqrsRequestInvokerProvider.cs` 的 XML 缩进格式问题
|
||||
- 当前 `RP-101` 已按用户新增 benchmark 诉求收口 request 热路径:为 `IIocContainer` 新增不激活实例的 `HasRegistration(Type)`、让 dispatcher 在 `0 pipeline` 场景下跳过空行为解析,并为 `MicrosoftDiContainer` 的热路径查询补齐 debug-level 守卫,避免无效日志字符串分配
|
||||
- 当前 `RP-102` 已把 `GFramework.Cqrs.Benchmarks` 的 `Mediator` 对照组收口为官方 NuGet 引用(`Mediator.Abstractions` / `Mediator.SourceGenerator` `3.0.2`),不再使用本地 `ai-libs/Mediator` project reference;`RequestBenchmarks` 现已新增 source-generated concrete `Mediator` 对照方法,并通过 `RequestLifetimeBenchmarks` 复核 hot path 收口后的新基线
|
||||
- 当前 `RP-102` 已将 `BenchmarkDotNet.Artifacts/` 收口为默认忽略路径,并把 request steady-state / lifetime benchmark 复跑升级为 CQRS 性能相关改动的默认回归门槛;当前阶段目标明确为“持续逼近 source-generated `Mediator`,并至少稳定超过反射版 `MediatR`”
|
||||
- 当前 `RP-103` 已使用 `$gframework-pr-review` 复核 `PR #340` latest-head review:修复 `CreateStream_Should_Throw_When_Stream_Pipeline_Behavior_Context_Does_Not_Implement_IArchitectureContext` 因 strict mock 未配置 `HasRegistration(Type)` 产生的 CI 失败,收紧 `MicrosoftDiContainer.HasRegistration(Type)` 到与 `GetAll(Type)` 一致的服务键可见性语义,补齐 `IIocContainer.HasRegistration(Type)` 的异常/XML 契约与 `docs/zh-CN/core/ioc.md` 的用户接入说明,并同步 benchmark 注释与 active tracking/trace 到当前 PR 锚点
|
||||
- 当前 `RP-104` 已继续沿用 `$gframework-batch-boot 50` 压 request 热路径:先把 `CqrsDispatcher.SendAsync(...)` 改成 direct-return `ValueTask`,移除 dispatcher 自身的 `async/await` 状态机;再让 `MicrosoftDiContainer.HasRegistration(Type)` 在冻结后复用预构建的服务键索引,避免每次命中零 pipeline request 都线性扫描全部描述符;本轮 benchmark 表明第一刀显著压低 steady-state / lifetime request,第二刀在当前短跑下主要确认“无回退、收益不明显”
|
||||
- 当前 `RP-105` 已继续沿用 `$gframework-batch-boot 50` 压默认 request steady-state:为 benchmark 最小宿主补齐 CQRS runtime / registrar / registration service 基础设施,让 `RequestBenchmarks` 不再只测反射路径,而是通过 handwritten generated registry + `RegisterCqrsHandlersFromAssembly(...)` 真实接上 generated request invoker provider;本轮 benchmark 表明默认 request 路径进一步从约 `70.298 ns / 32 B` 压到约 `65.296 ns / 32 B`,`Singleton / Transient` lifetime 也同步收敛到约 `68.772 ns / 32 B` 与 `73.157 ns / 56 B`
|
||||
- 当前 `RP-106` 已把同一套 generated-provider 宿主收口扩展到 `RequestPipelineBenchmarks`:新增 handwritten `GeneratedRequestPipelineBenchmarkRegistry`,并让 `RequestPipelineBenchmarks` 改走 `RegisterCqrsHandlersFromAssembly(...)` + benchmark CQRS 基础设施预接线;本轮 benchmark 表明 `0 pipeline` steady-state 进一步收敛到约 `64.755 ns / 32 B`,`1 pipeline` 约 `353.141 ns / 536 B`,`4 pipeline` 在短跑噪音下维持约 `555.083 ns / 896 B`
|
||||
- 当前 `RP-107` 已把默认 stream steady-state 宿主也切到 generated-provider 路径:新增 handwritten `GeneratedDefaultStreamingBenchmarkRegistry`,让 `StreamingBenchmarks` 改走 `RegisterCqrsHandlersFromAssembly(...)` 并在 setup/cleanup 清理 dispatcher cache;同时将 `gframework-boot` / `gframework-batch-boot` 的默认停止规则改为“AI 上下文预算优先,建议在预计接近约 80% 安全上下文占用前收口”,不再把 changed files 误当作唯一阈值
|
||||
- 当前 `RP-108` 已补齐 stream handler `Singleton / Transient` 生命周期矩阵 benchmark:新增 `StreamLifetimeBenchmarks` 与 `GeneratedStreamLifetimeBenchmarkRegistry`,让 stream 生命周期对照沿用 generated-provider 宿主接线而不是退回纯反射路径;本轮 benchmark 表明 `Singleton` 下 baseline / `GFramework.Cqrs` / `MediatR` 约 `80.144 ns / 137.515 ns / 229.242 ns`,`Transient` 下约 `77.198 ns / 144.998 ns / 228.185 ns`
|
||||
- `ai-plan` active 入口现以 `RP-108` 为最新恢复锚点;`PR #340`、`PR #339`、`PR #334`、`PR #331`、`PR #326`、`PR #323`、`PR #307` 与其他更早阶段细节均以下方归档或说明为准
|
||||
- 本轮按 `$gframework-pr-review` 重新抓取 GitHub 真值后,确认当前公开 PR 不是已合并的 `PR #349`,而是仍处于 `OPEN` 状态的 `PR #350`。
|
||||
- 最新 AI review 只有 1 条 Greptile open thread,关注点是:
|
||||
- `StreamStartupBenchmarks.ColdStart_Mediator()` 与 `RequestLifetimeBenchmarks.SendRequest_Mediator()` 先前只做了编译验证,未实际 smoke-run
|
||||
- 主线程按 review 提示执行最小 benchmark smoke run 后,确认 Greptile 线程不是误报,而是命中了真实运行时缺陷:
|
||||
- `StreamStartupBenchmarks.ColdStart_Mediator()` 在 BenchmarkDotNet 自动生成宿主里抛出
|
||||
`Invalid configuration detected for Mediator. Generated code for 'Transient' lifetime, but got 'Singleton' lifetime from options.`
|
||||
- `RequestLifetimeBenchmarks.SendRequest_Mediator()` 的 `Singleton / Scoped` 也抛出同类异常;只有 `Transient` 变体能跑通
|
||||
- 根因确认:
|
||||
- NuGet `Mediator` 的 DI lifetime 由 source generator 在 benchmark 项目编译期固定
|
||||
- 当前工程同时存在默认 `AddMediator()` 与 request lifetime 场景下的 `AddMediator(options => options.ServiceLifetime = ...)`
|
||||
- 这会让同一份生成产物在 BenchmarkDotNet 自动生成宿主中出现 compile-time 形状与 runtime options 不一致
|
||||
- 本轮收口策略:
|
||||
- `BenchmarkHostFactory.CreateMediatorServiceProvider()` 统一显式固定为 `Singleton` compile-time lifetime
|
||||
- `RequestLifetimeBenchmarks` 撤回当前无法真实运行的 `Mediator` 生命周期矩阵,只保留 `GFramework.Cqrs` 与 `MediatR`
|
||||
- `GFramework.Cqrs.Benchmarks/README.md` 同步收窄 request lifetime coverage,并把 `Mediator` 生命周期矩阵改记为当前缺口
|
||||
- 本轮未修改 `GFramework.Cqrs` 运行时代码;修复面限定在 benchmark 宿主装配与 reader-facing docs。
|
||||
|
||||
## 当前活跃事实
|
||||
|
||||
- 当前分支为 `feat/cqrs-optimization`
|
||||
- 本轮 `$gframework-batch-boot 50` 以 `origin/main` (`4d6dbba6`, 2026-05-08 11:13:33 +0800) 为基线;本地 `main` 仍落后,不作为 branch diff 基线
|
||||
- 当前已提交分支相对 `origin/main` 的累计 branch diff 为 `14 files / 507 lines`
|
||||
- 本批待提交工作树集中在 `GFramework.Cqrs.Benchmarks/Messaging/StreamLifetimeBenchmarks.cs`、`GFramework.Cqrs.Benchmarks/Messaging/GeneratedStreamLifetimeBenchmarkRegistry.cs` 与 `GFramework.Cqrs.Benchmarks/README.md`
|
||||
- 当前批次后的默认停止依据已改为 AI 上下文预算:若下一轮预计会让活动对话、已加载 recovery 文档、验证输出与当前 diff 接近约 `80%` 安全上下文占用,应在当前自然批次边界停止,即使 branch diff 仍有余量
|
||||
- `GFramework.Cqrs.Benchmarks` 作为 benchmark 基础设施项目,必须持续排除在 NuGet / GitHub Packages 发布集合之外
|
||||
- `GFramework.Cqrs.Benchmarks` 现已覆盖 request steady-state、pipeline 数量矩阵、startup、request/stream generated invoker,以及 request handler `Singleton / Transient` 生命周期矩阵
|
||||
- `GFramework.Cqrs.Benchmarks` 当前以 NuGet 方式引用 `Mediator.Abstractions` / `Mediator.SourceGenerator` `3.0.2`;`ai-libs/Mediator` 只保留为本地源码/README 对照资料,不再参与 benchmark 项目编译
|
||||
- 当前 request steady-state benchmark 已形成 baseline / `Mediator` / `MediatR` / `GFramework.Cqrs` 四方对照:最新约 `5.608 ns / 32 B`、`5.445 ns / 32 B`、`57.071 ns / 232 B`、`64.825 ns / 32 B`
|
||||
- 当前 request lifetime benchmark 已继续收敛:`Singleton` 下 `GFramework.Cqrs` 最新约 `69.275 ns / 32 B`,`Transient` 下约 `74.301 ns / 56 B`;相较 `RP-104` 前的 `73.005 ns / 32 B` 与 `74.757 ns / 56 B` 仍维持同一收敛区间
|
||||
- 当前 request pipeline benchmark 已改为与默认 request steady-state 相同的 generated-provider 宿主接线路径:`0 pipeline` 约 `64.755 ns / 32 B`,`1 pipeline` 约 `353.141 ns / 536 B`,`4 pipeline` 约 `555.083 ns / 896 B`
|
||||
- 当前 stream steady-state benchmark 也已切到 generated-provider 宿主接线路径:baseline 约 `5.535 ns / 32 B`、`MediatR` 约 `59.499 ns / 232 B`、`GFramework.Cqrs` 约 `66.778 ns / 32 B`
|
||||
- 当前 stream lifetime benchmark 已补齐 `Singleton / Transient` 两档矩阵,并沿用 generated-provider 宿主接线:`Singleton` 下 baseline / `GFramework.Cqrs` / `MediatR` 约 `80.144 ns / 137.515 ns / 229.242 ns`,`Transient` 下约 `77.198 ns / 144.998 ns / 228.185 ns`
|
||||
- 本轮已验证旧 benchmark 劣化的两个主热点:`0 pipeline` 场景下仍解析空行为列表,以及容器查询热路径在 debug 禁用时仍构造日志字符串;两者收口后,`GFramework.Cqrs` request 路径不再出现额外数百字节分配
|
||||
- `HasRegistration(Type)` 现在只把“同一服务键已注册”或“开放泛型服务键可闭合到目标类型”视为命中,不再把“仅以具体实现类型自注册”的行为误判为接口服务已注册;该语义与 `Get(Type)` / `GetAll(Type)` 已重新对齐
|
||||
- `GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherContextValidationTests.cs` 已同步适配 `HasRegistration(Type)` fast-path,避免 strict mock 因缺少新调用配置而在上下文失败语义断言前提前抛出 `Moq.MockException`
|
||||
- `GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherContextValidationTests.cs` 现连“handler 缺失但仍返回 faulted `ValueTask`”这条 request 失败语义回归也显式为 `HasRegistration(Type)` / `GetAll(Type)` 预留了防御性 mock,不再依赖 dispatcher 先判空 handler、后探测 pipeline 的内部顺序
|
||||
- `docs/zh-CN/core/ioc.md` 已新增 `HasRegistration(Type)` 的使用语义、热路径用途与“按服务键而非可赋值关系判断”的示例说明
|
||||
- 当前 request steady-state 仍落后于 source-generated `Mediator` 与 `MediatR`,但差距已从“额外数百字节分配 + 近 300ns”收敛到“零 pipeline fast-path 仍慢约 `31ns` / `3.6x` 于 `Mediator`”;下一批若继续压 request dispatch,应优先评估默认路径吸收 generated invoker/provider 的空间
|
||||
- 本轮 `SendAsync(...)` 的 direct-return `ValueTask` 改动已证明确实是有效热点:同样的短跑配置下,`GFramework.Cqrs` steady-state request 从约 `83.823 ns` 下探到 `69-70 ns` 区间
|
||||
- 冻结后 `HasRegistration(Type)` 服务键索引化在当前短跑下没有带来同等量级的可见收益,但也没有引入功能回退或额外分配;后续若继续压零 pipeline request,应优先重新评估“默认 request 路径进一步吸收 generated invoker/provider”而不是继续堆叠同层级微优化
|
||||
- 默认 `RequestBenchmarks`、`RequestPipelineBenchmarks` 与 `StreamingBenchmarks` 现在都已通过 handwritten generated registry + 真实 `RegisterCqrsHandlersFromAssembly(...)` 宿主接线命中 generated invoker provider,不再只代表纯反射 binding 路径
|
||||
- `gframework-boot` 与 `gframework-batch-boot` 现明确把“上下文预算接近约 80%”视为默认优先停止信号,branch diff files / lines 仅保留为次级仓库范围指标
|
||||
- 当前性能回归门槛已收紧为:只要改动触达 `GFramework.Cqrs` request dispatch、DI 热路径、invoker/provider、pipeline 或 benchmark 宿主,就必须至少复跑 `RequestBenchmarks.SendRequest_*` 与 `RequestLifetimeBenchmarks.SendRequest_*`
|
||||
- 当前阶段的性能验收目标已明确为:默认 request steady-state 路径不要求超过 source-generated `Mediator`,但必须持续逼近它,并至少稳定快于基于反射 / 扫描的 `MediatR`
|
||||
- `GFramework.Core` 当前已通过内部 bridge request / handler 把 legacy `ICommand`、`IAsyncCommand`、`IQuery`、`IAsyncQuery` 接到统一 `ICqrsRuntime`
|
||||
- 标准 `Architecture` 初始化路径会自动扫描 `GFramework.Core` 程序集中的 legacy bridge handler,因此旧 `SendCommand(...)` / `SendQuery(...)` 无需改变用法即可进入统一 pipeline
|
||||
- `CommandExecutor`、`QueryExecutor`、`AsyncQueryExecutor` 仍保留“无 runtime 时直接执行”的回退路径,用于不依赖容器的隔离单元测试
|
||||
- `LegacyCqrsDispatchHelper` 现统一负责 runtime dispatch context 解析,以及 legacy 同步 bridge 对 `ICqrsRuntime.SendAsync(...)` 的线程池隔离等待
|
||||
- `ArchitectureContext`、`CommandExecutor`、`QueryExecutor` 的同步 CQRS/legacy bridge 入口不再直接在调用线程上阻塞 `SendAsync(...).GetAwaiter().GetResult()`
|
||||
- `GFramework.Core.Tests` 现通过 `InternalsVisibleTo("GFramework.Core.Tests")` 直接实例化内部 bridge handler,不再依赖字符串反射装配测试桥接注册
|
||||
- 使用 `LegacyBridgePipelineTracker` 的 `ArchitectureContextTests` 与 `ArchitectureModulesBehaviorTests` 现都显式标记为 `NonParallelizable`
|
||||
- `ArchitectureContextTests.CreateFrozenBridgeContext(...)` 现把冻结容器所有权显式交回调用方,并在每个 bridge 用例的 `finally` 中释放
|
||||
- `CommandExecutorModule`、`QueryExecutorModule`、`AsyncQueryExecutorModule` 现改为 `GetRequired<ICqrsRuntime>()` 并在 XML 文档里显式声明注册顺序契约,避免 runtime 缺失时静默回退
|
||||
- `LegacyAsyncQueryDispatchRequestHandler`、`LegacyAsyncCommandResultDispatchRequestHandler`、`LegacyAsyncCommandDispatchRequestHandler` 现都通过 `ThrowIfCancellationRequested()` + `WaitAsync(cancellationToken)` 显式保留调用方取消可见性
|
||||
- 相对 `ai-libs/Mediator`,当前仍未完全吸收的能力集中在五类:facade 公开入口、telemetry、notification publisher 策略、生成器配置与诊断、生命周期/缓存公开配置面
|
||||
- 发布工作流已有 packed modules 校验,但 PR 工作流此前没有等价的 solution pack 产物名单校验
|
||||
- 本地 `dotnet pack GFramework.sln -c Release --no-restore -o <temp-dir>` 当前只产出 14 个预期包,未复现 benchmark `.nupkg`
|
||||
- `PR #334` 在 `2026-05-07` 的 latest-head review 当前显示 `CodeRabbit 10` / `Greptile 5` 个 open thread;本轮再次复核后确认其中大部分仍是已实质修复但未 resolve 的 stale thread,仅 `LegacyCqrsDispatchHelper.TryResolveDispatchContext(...)` 的异常边界仍需要继续收口
|
||||
- benchmark 场景现统一通过 `BenchmarkHostFactory` 构建最小宿主:GFramework 侧在 runtime 分发前显式 `Freeze()` 容器,MediatR 侧只扫描当前场景需要的 handler / behavior 类型
|
||||
- `RequestStartupBenchmarks` 已恢复 `ColdStart_GFrameworkCqrs` 结果产出,不再命中 `No CQRS request handler registered`
|
||||
- `BenchmarkDotNet` 在当前 agent 沙箱里会因自动生成的 bootstrap 脚本异常失败;同一 `dotnet run --no-build` 命令在沙箱外执行通过,因此本轮以沙箱外结果作为 benchmark 权威验证
|
||||
- 已新增手动触发的 benchmark workflow;默认只验证 benchmark 项目 Release build,只有显式提供过滤器时才执行 BenchmarkDotNet 运行;过滤器输入现通过环境变量传入 shell,避免 workflow_dispatch 输入直接插值到命令行
|
||||
- 远端 `CTRF` 最新汇总为 `2311/2311` passed(run `#1079`, 2026-05-07)
|
||||
- `MegaLinter` 当前只暴露 `dotnet-format` 的 `Restore operation failed` 环境噪音,尚未提供本地仍成立的文件级格式诊断
|
||||
- `LegacyCqrsDispatchHelper.TryResolveDispatchContext(...)` 现在只会把“Context 尚未设置”或“当前没有活动上下文”识别为可安全 fallback 的缺上下文信号;其他 `InvalidOperationException` 将继续向上传播,避免把真实运行时故障误判成 legacy 直执行场景
|
||||
- `CommandExecutorTests` 已新增“缺上下文继续 fallback”和“意外 `InvalidOperationException` 必须冒泡”的回归,防止后续再次放宽该异常过滤面
|
||||
- `PR #334` 当前 latest-head open AI feedback 经过本轮本地复核与修复后,应主要剩余待 GitHub 重新索引的状态差异或已实质关闭但未 resolve 的 thread
|
||||
- `GFramework.Core.Tests` 中 legacy bridge 的“保留上下文”回归现在同时断言 bridge request 类型与目标对象执行期观察到的 `IArchitectureContext`
|
||||
- `RecordingCqrsRuntime` 对非 `Unit` 响应已显式校验返回值类型;若测试工厂返回了 `null` 或错误装箱类型,异常会直接指出 request 类型与期望/实际响应类型
|
||||
- `PR #339` 当前 latest-head review 仍显示 `2` 个 CodeRabbit open thread 与 `2` 个 nitpick;本轮本地复核后确认:
|
||||
- `GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherCacheTests.cs` 的 `Per_Behavior_Count` 拼写已在当前 head 修正,属于 stale thread
|
||||
- `GFramework.Core.Abstractions/Ioc/IIocContainer.cs` 的流式行为注册入口此前确实缺少 `<exception>` / `<remarks>` 契约说明,现已补齐并同步到 `IArchitecture` / `Architecture` / `ArchitectureModules`
|
||||
- `GFramework.Cqrs/Internal/CqrsDispatcher.cs` 的 `StreamPipelineInvocation.GetContinuation(...)` 线程模型说明此前少于 request 对称路径,现已补齐并发 `next()` 时 continuation 缓存的语义边界
|
||||
- `GFramework.Core/Ioc/MicrosoftDiContainer.cs` 的 request / stream 行为注册逻辑此前存在重复实现,现已抽取共享私有 helper 以避免后续生命周期或校验逻辑漂移
|
||||
- 本地 `dotnet format GFramework.Cqrs/GFramework.Cqrs.csproj --verify-no-changes` 显示当前 diff 内仍有 `GFramework.Cqrs/ICqrsRequestInvokerProvider.cs` 的空白格式问题,本轮已修复;同一次命令报出的多条 `CHARSET` 提示集中在未触达的历史文件,不视为 `PR #339` 本轮新增 triage 结论
|
||||
- 当前分支:`feat/cqrs-optimization`
|
||||
- 当前 PR:`PR #349(已合并;当前分支暂无新的公开 PR)`
|
||||
- 当前 PR:`PR #350(OPEN)`
|
||||
- 当前写面:
|
||||
- `GFramework.Cqrs.Benchmarks/Messaging/BenchmarkHostFactory.cs`
|
||||
- `GFramework.Cqrs.Benchmarks/Messaging/RequestLifetimeBenchmarks.cs`
|
||||
- `GFramework.Cqrs.Benchmarks/README.md`
|
||||
- `ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md`
|
||||
- `ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md`
|
||||
- 当前基线:
|
||||
- `origin/main @ 2b2bec65 (2026-05-12 11:49:39 +0800)`
|
||||
- 当前已提交 branch diff:`14 files`
|
||||
- 当前分支比 `origin/main` 多 `5` 个提交:`f346110a`、`a016e3d4`、`ab422b05`、`555c7c07`、`c32a1ec4`
|
||||
- 当前工作面已收口为 `Mediator` benchmark runtime 配置修正、request lifetime coverage 收窄与对应 `README` / `ai-plan` 同步
|
||||
- 本轮提交:
|
||||
- `f346110a` `feat(cqrs-benchmarks): 补齐 stream startup 的 Mediator 对照路径`
|
||||
- `ab422b05` `docs(cqrs-benchmarks): 补齐 request benchmark 返回值注释`
|
||||
- `555c7c07` `docs(cqrs-benchmarks): 补齐 request benchmark 返回值文档`
|
||||
- `c32a1ec4` `docs(cqrs-benchmarks): 补齐stream与notification基准返回值文档`
|
||||
|
||||
## 当前风险
|
||||
|
||||
- 顶层 `GFramework.sln` / `GFramework.csproj` 在 WSL 下仍可能受 Windows NuGet fallback 配置影响,完整 solution 级验证成本高于模块级验证
|
||||
- 若后续新增 benchmark / example / tooling 项目但未同步校验发布面,solution 级 `dotnet pack` 仍可能在 tag 发布前才暴露异常包
|
||||
- `RequestStartupBenchmarks` 为了量化真正的单次 cold-start,引入了 `InvocationCount=1` / `UnrollFactor=1` 的专用 job;该配置会触发 BenchmarkDotNet 的 `MinIterationTime` 提示,后续若要做稳定基线比较,还需要决定是否引入批量外层循环或自定义 cold-start harness
|
||||
- 当前 benchmark 宿主仍刻意保持“单根容器最小宿主”模型;若要公平比较 `Scoped` handler 生命周期,需要先引入显式 scope 创建与 scope 内首次解析的对照基线
|
||||
- 当前 `Mediator` concrete runtime 对照已覆盖 steady-state request、单处理器 notification publish 与固定 `4 handler` notification fan-out;若要把 `Transient` / `Scoped` 生命周期矩阵、stream 生命周期矩阵或更大 fan-out 矩阵也纳入同一组对照,需要按 `Mediator` 官方 benchmark 的做法拆分 compile-time lifetime / 场景配置,而不是在同一编译产物里混用多个 runtime 变量
|
||||
- 当前 stream 生命周期矩阵尚未接入 `Mediator` concrete runtime;若要继续对齐 `Mediator` 官方 benchmark 的 compile-time lifetime 设计,需要为 stream 场景补专门的 build-time 配置,而不是在当前统一宿主里临时拼接
|
||||
- `BenchmarkDotNet.Artifacts/` 现已加入仓库忽略规则;若后续确实需要提交新的基准报告,应显式挑选结果文件或改走文档归档,而不是直接纳入整个生成目录
|
||||
- 当前 `GFramework.Cqrs` request steady-state 仍慢于 `MediatR`;在“至少超过反射版 `MediatR`”这个阶段目标达成前,任何相关改动都不能只看功能 build/test 结果,必须附带 benchmark 回归数据
|
||||
- 仓库内部仍保留旧 `Command` / `Query` API、`LegacyICqrsRuntime` alias 与部分历史命名语义,后续若不继续分批收口,容易混淆“对外替代已完成”与“内部收口未完成”
|
||||
- 若继续扩大 generated invoker 覆盖面,需要持续区分“可静态表达的合同”与 `PreciseReflectedRegistrationSpec` 等仍需保守回退的场景
|
||||
- legacy bridge 当前只为已有 `Command` / `Query` 兼容入口接到统一 request pipeline;若后续要继续对齐 `Mediator`,仍需要单独设计 stream pipeline、telemetry 与 facade 公开面,而不是把这次 bridge 当成“全部收口完成”
|
||||
- `LegacyBridgePipelineTracker` 仍是进程级静态测试辅助;虽然现在已在相关 fixture 清理阶段重置并补充线程安全说明,但若将来扩大并行 bridge fixture 数量,仍要继续控制共享状态扩散
|
||||
- stream pipeline 当前只在“单次建流”层面包裹 handler 调用;若后续需要 per-item 拦截、元素级重试或流内 metrics 聚合,仍需额外设计更细粒度 contract,而不是把本轮 seam 直接等同于元素级 middleware
|
||||
- `PR #339` 在 GitHub 上仍有 1 个已本地失效但未 resolve 的 stale test-thread;若后续 head 再次变化,需要重新抓取 latest-head review 确认未解决线程是否收敛
|
||||
- 若后续继续依赖 `HasRegistration(Type)` 做热路径短路,新增测试替身或 strict mock 时必须同步配置该调用,否则容易在真正业务断言之前被 mock 框架短路成环境性失败
|
||||
- `StreamLifetimeBenchmarks` 仍缺 `Mediator` parity;如果后续要补,必须采用独立 compile-time config 或独立 benchmark 工程,而不是在当前项目里切换 runtime `ServiceLifetime`。
|
||||
- `RequestLifetimeBenchmarks` 目前不再覆盖 `Mediator`;若后续要恢复该矩阵,也必须先解决 source-generated lifetime 与 BenchmarkDotNet 自动宿主的编译期塑形边界。
|
||||
- benchmark XML 盘点若再次依赖粗糙脚本或只读 inventory,仍有把已存在文档误记为缺口的风险;后续若再开 XML 波次,必须先用主线程抽样核对代表文件。
|
||||
- 当前 PR 的 Greptile open thread 在代码修正后虽已有本地验证证据,但线程本身还未在 GitHub 上回复 / resolve。
|
||||
|
||||
## 最近权威验证
|
||||
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*NotificationFanOutBenchmarks*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 备注:确认统一 `Mediator` compile-time lifetime 后 benchmark 工程仍可编译
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --artifacts-suffix pr350-stream-startup-mediator-fixed --filter "*StreamStartupBenchmarks.ColdStart_Mediator*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:本轮对称化 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`
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Benchmarks/Messaging/NotificationFanOutBenchmarks.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`
|
||||
- 结果:通过
|
||||
- 备注:仅剩 `GFramework.sln` 的历史 CRLF 提示,无本轮新增 diff 格式问题
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*NotificationFanOutBenchmarks*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:历史基线(`RP-112`)固定 `4 handler` notification fan-out 对照约为 baseline `8.302 ns / 0 B`、`Mediator` `4.314 ns / 0 B`、`MediatR` `230.304 ns / 1256 B`、`GFramework.Cqrs` `434.413 ns / 408 B`
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Benchmarks/Messaging/NotificationFanOutBenchmarks.cs GFramework.Cqrs.Benchmarks/README.md`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- 备注:仅剩 `GFramework.sln` 的历史 CRLF 提示,无本轮新增 diff 格式问题
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*NotificationBenchmarks*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:notification publish 三方对照当前约为 `Mediator` `1.108 ns / 0 B`、`MediatR` `97.173 ns / 416 B`、`GFramework.Cqrs` `291.582 ns / 392 B`
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Benchmarks/Messaging/NotificationBenchmarks.cs GFramework.Cqrs.Benchmarks/README.md`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- 备注:仅剩 `GFramework.sln` 的历史 CRLF 提示,无本轮新增 diff 格式问题
|
||||
- `dotnet build GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- 备注:并行验证首轮曾因 `build` 与 `test` 同时写入同一输出 DLL 触发 `MSB3026` 单次复制重试;改为串行重跑同一命令后稳定通过
|
||||
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --filter "FullyQualifiedName~CqrsDispatcherContextValidationTests"`
|
||||
- 结果:通过,`6/6` passed
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherContextValidationTests.cs`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- 备注:仅剩 `GFramework.sln` 的历史 CRLF 提示,无本轮新增 diff 格式问题
|
||||
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --format json --json-output /tmp/current-pr-review.json`
|
||||
- 结果:通过
|
||||
- 备注:确认当前分支对应 `PR #342`;latest-head 当前显示 `CodeRabbit 4` / `Greptile 3` open thread,其中真正仍成立的是 benchmark handler 对称性、README / 中文文档示例与恢复文档锚点漂移,其余历史 thread 需要按当前 head 继续甄别
|
||||
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --json-output /tmp/current-pr-review.json`
|
||||
- 结果:通过
|
||||
- 备注:确认当前分支对应 `PR #340`;latest-head 当前显示 `CodeRabbit 2` / `Greptile 2` open thread,且 `CTRF` 报告中唯一失败测试为 `CreateStream_Should_Throw_When_Stream_Pipeline_Behavior_Context_Does_Not_Implement_IArchitectureContext`
|
||||
- `dotnet build GFramework.Core.Abstractions/GFramework.Core.Abstractions.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet build GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~MicrosoftDiContainerTests"`
|
||||
- 结果:通过,`52/52` passed
|
||||
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~CqrsDispatcherContextValidationTests"`
|
||||
- 结果:通过,`4/4` passed
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:按新性能回归门槛复跑后,steady-state request 对照约为 baseline `5.300 ns / 32 B`、`Mediator` `4.964 ns / 32 B`、`MediatR` `57.993 ns / 232 B`、`GFramework.Cqrs` `83.823 ns / 32 B`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestLifetimeBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:按新性能回归门槛复跑后,`Singleton` 下 `GFramework.Cqrs` / `MediatR` 约 `83.183 ns / 32 B` vs `60.915 ns / 232 B`;`Transient` 下约 `86.243 ns / 56 B` vs `59.644 ns / 232 B`
|
||||
- `env GIT_DIR=... GIT_WORK_TREE=... python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Benchmarks/Messaging/GeneratedStreamLifetimeBenchmarkRegistry.cs GFramework.Cqrs.Benchmarks/Messaging/StreamLifetimeBenchmarks.cs GFramework.Cqrs.Benchmarks/README.md`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:steady-state request 对照约为 baseline `5.336 ns / 32 B`、`Mediator` `5.564 ns / 32 B`、`MediatR` `53.307 ns / 232 B`、`GFramework.Cqrs` `64.745 ns / 32 B`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestLifetimeBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:`Singleton` 下 baseline / `MediatR` / `GFramework.Cqrs` 约 `4.309 ns / 51.923 ns / 67.981 ns`;`Transient` 下约 `5.029 ns / 54.435 ns / 76.437 ns`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*StreamLifetimeBenchmarks*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:`Singleton` 下 baseline / `GFramework.Cqrs` / `MediatR` 约 `80.144 ns / 137.515 ns / 229.242 ns`,`Transient` 下约 `77.198 ns / 144.998 ns / 228.185 ns`
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- 备注:当前仅保留 `GFramework.sln` 的历史 CRLF 警告,无本轮新增 diff 格式错误
|
||||
- `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~MicrosoftDiContainerTests"`
|
||||
- 结果:通过,`52/52` passed
|
||||
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~CqrsDispatcherCacheTests|FullyQualifiedName~CqrsDispatcherContextValidationTests"`
|
||||
- 结果:通过,`14/14` passed
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*RequestBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:本轮两批热路径收口后的最新 steady-state request 对照约为 baseline `6.141 ns / 32 B`、`Mediator` `6.674 ns / 32 B`、`MediatR` `61.803 ns / 232 B`、`GFramework.Cqrs` `70.298 ns / 32 B`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*RequestLifetimeBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:最新 lifetime request 对照约为 `Singleton` 下 baseline / `MediatR` / `GFramework.Cqrs` = `4.706 ns / 52.197 ns / 73.005 ns`,`Transient` 下 = `4.571 ns / 50.175 ns / 74.757 ns`
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Benchmarks/Messaging/GeneratedDefaultRequestBenchmarkRegistry.cs GFramework.Cqrs.Benchmarks/Messaging/BenchmarkHostFactory.cs GFramework.Cqrs.Benchmarks/Messaging/RequestBenchmarks.cs GFramework.Cqrs.Benchmarks/README.md`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:默认 steady-state request 对照现约为 baseline `5.013 ns / 32 B`、`Mediator` `5.747 ns / 32 B`、`MediatR` `51.588 ns / 232 B`、`GFramework.Cqrs` `65.296 ns / 32 B`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestLifetimeBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:最新 lifetime request 对照约为 `Singleton` 下 baseline / `MediatR` / `GFramework.Cqrs` = `4.817 ns / 48.177 ns / 68.772 ns`,`Transient` 下 = `4.841 ns / 51.753 ns / 73.157 ns`
|
||||
- `env GIT_DIR=... GIT_WORK_TREE=... python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- 备注:仍仅保留 `GFramework.sln` 的历史 CRLF 警告,无本轮新增 diff 格式问题
|
||||
- `dotnet pack GFramework.sln -c Release --no-restore -o /tmp/gframework-pack-validation -p:IncludeSymbols=false`
|
||||
- 结果:通过
|
||||
- 备注:当前本地产物仅包含 14 个预期发布包,未生成 `GFramework.Cqrs.Benchmarks.*.nupkg`
|
||||
- `bash scripts/validate-packed-modules.sh /tmp/gframework-pack-validation`
|
||||
- 结果:通过
|
||||
- 备注:共享脚本确认 actual package set 与预期 14 个发布包完全一致
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~MicrosoftDiContainerTests"`
|
||||
- 结果:通过,`51/51` passed
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:最新 steady-state request 对照约为 baseline `5.969 ns / 32 B`、`Mediator` `6.242 ns / 32 B`、`MediatR` `53.818 ns / 232 B`、`GFramework.Cqrs` `85.504 ns / 32 B`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestLifetimeBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:`Singleton` 下 `GFramework.Cqrs` / `MediatR` 约 `84.066 ns / 32 B` vs `56.096 ns / 232 B`;`Transient` 下约 `90.652 ns / 56 B` vs `57.207 ns / 232 B`
|
||||
- `python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestStartupBenchmarks*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:`ColdStart_GFrameworkCqrs` 已恢复出数,最新本地输出约 `220-292 us`,MediatR 对照约 `575-616 us`;当前仅剩 BenchmarkDotNet 对单次 cold-start 场景的 `MinIterationTime` 提示
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- 备注:用于验证本轮 request invoker / pipeline / stream invoker 调整与 benchmark workflow 改动后的 Release 编译结果
|
||||
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --format json --json-output /tmp/current-pr-review.json`
|
||||
- 结果:通过
|
||||
- 备注:确认当前分支对应 `PR #334`;`CodeRabbit` latest review 已 `APPROVED`,但 latest-head 仍显示 `10` 个 open thread、`Greptile` 仍显示 `3` 个 open thread;本地逐项复核后未发现新的仍成立缺陷,最新 CI 测试汇总为 `2311/2311` passed,`MegaLinter` 仅剩 `dotnet-format` restore 环境噪音
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --json-output /tmp/current-pr-review.json`
|
||||
- 结果:通过
|
||||
- 备注:确认当前分支对应 `PR #339`;latest-head 显示 `2` 个 CodeRabbit open thread 与 `2` 个 nitpick;本轮本地接受并修复的问题集中在流式行为注册入口 XML 契约、stream continuation 线程说明与 `MicrosoftDiContainer` 的重复注册逻辑,测试方法拼写线程已在当前 head 失效
|
||||
- `dotnet format GFramework.Cqrs/GFramework.Cqrs.csproj --verify-no-changes`
|
||||
- 结果:发现当前 diff 内 `GFramework.Cqrs/ICqrsRequestInvokerProvider.cs` 的空白格式问题;其余 `CHARSET` 提示集中在未触达的历史文件
|
||||
- `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet build GFramework.Core.Abstractions/GFramework.Core.Abstractions.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 --no-build --filter "FullyQualifiedName~CqrsDispatcherCacheTests"`
|
||||
- 结果:通过,`10/10` passed
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~ArchitectureModulesBehaviorTests"`
|
||||
- 结果:通过,`4/4` passed
|
||||
- `env GIT_DIR=... GIT_WORK_TREE=... python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `dotnet build GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~CommandExecutorTests|FullyQualifiedName~AsyncQueryExecutorTests"`
|
||||
- 结果:通过,`19/19` passed
|
||||
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --json-output /tmp/current-pr-review.json`
|
||||
- 结果:通过
|
||||
- 备注:确认当前分支对应 `PR #334`;最新 review 仍为 `CodeRabbit APPROVED (2026-05-07T12:20:24Z)`,latest-head 显示 `CodeRabbit 10` / `Greptile 5` open thread;本轮接受并修复的仍成立问题收敛到 `LegacyCqrsDispatchHelper.TryResolveDispatchContext(...)` 的过宽异常吞掉逻辑
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet build GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~CommandExecutorTests|FullyQualifiedName~QueryExecutorTests"`
|
||||
- 结果:通过,`25/25` passed
|
||||
- `python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --format json --json-output <temporary-json-output>`
|
||||
- 结果:通过
|
||||
- 备注:确认当前分支对应 `PR #331`,本轮 latest-head open AI feedback 已收敛到 `dotnet pack --no-build`、共享包校验脚本跨平台兼容性与 active 文档 PR 锚点同步
|
||||
- `python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- 备注:当前 WSL worktree 需要显式绑定 `GIT_DIR` / `GIT_WORK_TREE` 后运行
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release -- --filter "*RequestLifetimeBenchmarks*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过(以沙箱外 `--no-build` 权威结果为准)
|
||||
- 备注:`Singleton` 下 baseline / MediatR / GFramework 均值约 `5.633 ns / 58.687 ns / 301.731 ns`;`Transient` 下约 `5.044 ns / 52.274 ns / 287.863 ns`
|
||||
- `python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- 备注:当前 WSL worktree 需要显式绑定 `GIT_DIR` / `GIT_WORK_TREE`
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~ArchitectureContextTests|FullyQualifiedName~CommandExecutorTests|FullyQualifiedName~QueryExecutorTests|FullyQualifiedName~AsyncQueryExecutorTests"`
|
||||
- 结果:通过,`45/45` passed
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release`
|
||||
- 结果:通过,`1644/1644` passed
|
||||
- `env GIT_DIR=... GIT_WORK_TREE=... python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --json-output /tmp/current-pr-review.json`
|
||||
- 结果:通过
|
||||
- 备注:确认当前分支对应 `PR #334`;仍有效的 latest-head review 已收敛到 legacy bridge 测试装配、运行时依赖契约、异步取消、XML 文档与兼容文档边界
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- 备注:修复新增 XML 文档 warning 后复跑,当前 `GFramework.Core` 三个 target framework 均已干净通过
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~ArchitectureContextTests|FullyQualifiedName~ArchitectureModulesBehaviorTests|FullyQualifiedName~CommandExecutorTests|FullyQualifiedName~QueryExecutorTests|FullyQualifiedName~AsyncQueryExecutorTests"`
|
||||
- 结果:通过,`48/48` passed
|
||||
- 备注:覆盖 legacy bridge 兼容入口、测试装配、执行器 runtime fallback 与相关模块行为
|
||||
- `env GIT_DIR=... GIT_WORK_TREE=... python3 scripts/license-header.py --check`
|
||||
- 结果:通过
|
||||
- `git diff --check`
|
||||
- 结果:通过
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
|
||||
- 结果:通过,`0 warning / 0 error`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~ArchitectureContextTests|FullyQualifiedName~ArchitectureModulesBehaviorTests|FullyQualifiedName~CommandExecutorTests|FullyQualifiedName~QueryExecutorTests|FullyQualifiedName~AsyncQueryExecutorTests|FullyQualifiedName~LegacyAsyncCommandDispatchRequestHandlerTests"`
|
||||
- 结果:通过,`54/54` passed
|
||||
- 备注:覆盖 legacy 同步 bridge 的同步上下文隔离、bridge fixture 容器释放,以及 async void command cancellation 可见性
|
||||
- `env GIT_DIR=... GIT_WORK_TREE=... python3 scripts/license-header.py --check`
|
||||
- 备注:`ColdStart_Mediator` 已在 BenchmarkDotNet 自动生成宿主中实际执行,约 `144.036 us / 69.3 KB`
|
||||
- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --artifacts-suffix pr350-request-lifetime-fixed-rerun --filter "*RequestLifetimeBenchmarks*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
|
||||
- 结果:通过
|
||||
- 备注:当前矩阵为 `9` 项(baseline / `GFramework.Cqrs` / `MediatR` * `Singleton|Scoped|Transient`),不再包含伪 `Mediator` lifetime 条目
|
||||
- `$gframework-pr-review`
|
||||
- 结果:确认 `PR #350` open,CodeRabbit 已 `APPROVED`,Greptile 仍有 `1` 条 open thread 指向 `StreamStartupBenchmarks.cs`
|
||||
|
||||
## 下一推荐步骤
|
||||
|
||||
1. 既然 `RP-117` 已把 notification publisher 的采用路径收口成显式策略矩阵,下一轮若继续留在 notification 线,优先评估是否需要补第三种仓库内置策略或更贴近示例代码的采用文档,而不是再重复翻写同一套边界说明
|
||||
2. 当前 benchmark 仍证明 `TaskWhenAllNotificationPublisher` 的价值主要在并行完成与异常聚合语义,而不是吞吐收益;若 notification 文档已经足够,下一轮再回到 request dispatch 常量开销时,应先避开“类型级 `IContextAware` 判定缓存”这条已验证无收益的热点假设
|
||||
3. 若 benchmark 对照需要继续贴近 `Mediator` 官方设计,再评估 `Mediator` 的 compile-time lifetime / stream 对照矩阵,或给 stream 引入 scoped host 基线,而不是回头重试已被 benchmark 否决的 `GetAll(Type)` 零行为探测方案
|
||||
1. 在 GitHub `PR #350` 回应并 resolve 当前 Greptile 线程,说明 `ColdStart_Mediator` 已补 smoke-run,且 request lifetime 的 `Mediator` 矩阵已按 source-generator 真实约束撤回。
|
||||
2. 若后续评估 `StreamLifetimeBenchmarks` 或 request lifetime 的 `Mediator` parity,优先设计独立 compile-time config / 独立 benchmark 工程,而不是继续在同一项目里切换 runtime `ServiceLifetime`。
|
||||
3. 若后续再开 XML / docs 批次,先由主线程逐文件核对代表样本,不要直接沿用误报 inventory 扩批。
|
||||
|
||||
## 活跃文档
|
||||
|
||||
- 历史跟踪归档:[cqrs-rewrite-history-through-rp043.md](../archive/todos/cqrs-rewrite-history-through-rp043.md)
|
||||
- 验证历史归档:[cqrs-rewrite-validation-history-through-rp062.md](../archive/todos/cqrs-rewrite-validation-history-through-rp062.md)
|
||||
- `RP-063` 至 `RP-074` 验证归档:[cqrs-rewrite-validation-history-rp063-through-rp074.md](../archive/todos/cqrs-rewrite-validation-history-rp063-through-rp074.md)
|
||||
- `RP-062` 至 `RP-076` trace 归档:[cqrs-rewrite-history-rp062-through-rp076.md](../archive/traces/cqrs-rewrite-history-rp062-through-rp076.md)
|
||||
- CQRS 与 Mediator 评估归档:[cqrs-vs-mediator-assessment-rp063.md](../archive/todos/cqrs-vs-mediator-assessment-rp063.md)
|
||||
- 历史 trace 归档:[cqrs-rewrite-history-through-rp043.md](../archive/traces/cqrs-rewrite-history-through-rp043.md)
|
||||
- `RP-046` 至 `RP-061` trace 归档:[cqrs-rewrite-history-rp046-through-rp061.md](../archive/traces/cqrs-rewrite-history-rp046-through-rp061.md)
|
||||
- 当前 active tracking:`ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md`
|
||||
- 当前 active trace:`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`
|
||||
|
||||
## 说明
|
||||
|
||||
- `PR #261`、`PR #302`、`PR #305`、`PR #307` 及更早阶段的详细过程已不再作为 active 恢复入口;如需追溯,以对应归档文件或历史 trace 段落为准
|
||||
- active tracking 仅保留当前恢复点、当前风险、最近权威验证与下一推荐步骤,避免 `boot` 落到历史阶段细节
|
||||
- `RP-131` 及之前的长历史验证、阶段流水与旧恢复点说明已迁移到新的 `archive/` 文件,不再继续堆叠在 active 入口。
|
||||
- active tracking 现在只保留当前恢复点所需的最小事实、风险、权威验证与下一步,供 `boot` 与后续 PR review 快速恢复。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,58 @@
|
||||
# Input System Godot Integration 跟踪
|
||||
|
||||
## 目标
|
||||
|
||||
在 `GFramework.Game.Abstractions`、`GFramework.Game` 与 `GFramework.Godot` 之间建立统一输入抽象、默认动作绑定运行时与
|
||||
Godot `InputMap` 适配,优先服务 UI 语义动作桥接和绑定重映射能力。
|
||||
|
||||
## 当前恢复点
|
||||
|
||||
- 恢复点编号:`INPUT-GODOT-RP-001`
|
||||
- 当前阶段:`Phase 1`
|
||||
- 当前焦点:
|
||||
- 已新增 `GFramework.Game.Abstractions.Input` 契约,覆盖动作绑定描述、快照、设备上下文与 UI 输入桥接接口
|
||||
- 已新增 `GFramework.Game.Input` 默认运行时,覆盖纯托管绑定存储、设备上下文持有者和逻辑动作到 `UiInputAction` 的桥接
|
||||
- 已新增 `GFramework.Godot.Input` 适配层,覆盖 `InputMap` 绑定读写与 descriptor-based backend 桥接
|
||||
- 已补 `Game.Tests` 与 `Godot.Tests` 的新增回归,并补 `docs/zh-CN/game/input.md` 与 `docs/zh-CN/godot/input.md`
|
||||
- 已处理 PR `#346` 的首轮 review follow-up,修复只读查询污染快照与 Godot 导入快照残留绑定问题,并补齐 README / XML / `ai-plan` 收尾
|
||||
|
||||
## 当前状态摘要
|
||||
|
||||
- 统一输入抽象已建立,但当前仍聚焦动作绑定和 UI 输入桥接,不尝试覆盖完整 gameplay input runtime
|
||||
- `GodotInputBindingStore` 当前把 `InputMap` 默认绑定和主绑定替换接到框架抽象,允许导出 / 导入 `InputBindingSnapshot`
|
||||
- `InputBindingStore.GetBindings(...)` 已改为纯读取语义,不再因查询缺失动作而把空条目带进导出快照
|
||||
- `GodotInputBindingStore.ImportSnapshot(...)` 已改为快照级覆盖语义,会清空快照中未出现动作的后端绑定
|
||||
- `GodotInputMapBackend.ResetAction(...)` / `ResetAll()` 已对齐默认快照替换语义,运行时新增动作在全量重置后不会残留在 `InputMap`
|
||||
- `project.godot -> InputActions` 生成器链路保持不变,新的输入系统直接复用动作名常量,而不是替代它
|
||||
|
||||
## 当前风险
|
||||
|
||||
- Godot 原生 `InputEvent` 对象在普通 `dotnet test` 宿主中的可测性仍有限
|
||||
- 缓解措施:当前 `Godot.Tests` 只覆盖纯托管 backend 桥接语义,原生 `InputMap` 行为由 `GFramework.Godot` Release build 兜底验证
|
||||
- 当前 `UiInputActionMap` 只内置 `ui_accept` / `ui_cancel` 等最小别名集
|
||||
- 缓解措施:后续如需更大动作表,由项目层自定义 `IUiInputActionMap`
|
||||
|
||||
## 验证说明
|
||||
|
||||
- `dotnet build GFramework.Game.Abstractions/GFramework.Game.Abstractions.csproj -c Release --no-restore -m:1 -nodeReuse:false`
|
||||
- 结果:通过
|
||||
- `dotnet build GFramework.Game/GFramework.Game.csproj -c Release --no-restore -m:1 -nodeReuse:false`
|
||||
- 结果:通过
|
||||
- `dotnet build GFramework.Godot/GFramework.Godot.csproj -c Release --no-restore -m:1 -nodeReuse:false`
|
||||
- 结果:通过
|
||||
- `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~InputBindingStoreTests|FullyQualifiedName~UiInputDispatcherTests" -m:1 -p:RestoreFallbackFolders= -nodeReuse:false`
|
||||
- 结果:通过
|
||||
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter "FullyQualifiedName~GodotInputBindingStoreTests" -m:1 -p:RestoreFallbackFolders= -nodeReuse:false`
|
||||
- 结果:通过
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Game.Abstractions/README.md GFramework.Game.Tests/Input/InputBindingStoreTests.cs GFramework.Game/Input/InputBindingStore.cs GFramework.Game/Input/InputDeviceTracker.cs GFramework.Game/Input/UiInputDispatcher.cs GFramework.Game/README.md GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs GFramework.Godot/Input/GodotInputBindingStore.cs GFramework.Godot/Input/GodotInputMapBackend.cs GFramework.Godot/Input/IGodotInputMapBackend.cs ai-plan/public/input-system-godot-integration/traces/input-system-godot-integration-trace.md`
|
||||
- 结果:通过(All supported files include an Apache-2.0 license header.)
|
||||
- `dotnet build GFramework.Game/GFramework.Game.csproj -c Release -m:1 -nodeReuse:false`
|
||||
- 结果:通过(0 warning, 0 error)
|
||||
- `dotnet build GFramework.Godot/GFramework.Godot.csproj -c Release -m:1 -nodeReuse:false`
|
||||
- 结果:通过(0 warning, 0 error)
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 若继续处理 PR review,可再单独评估值对象切换到 `record` 是否值得进入同一个 PR
|
||||
2. 若继续扩展输入系统,优先补更多逻辑动作与 gameplay 输入场景,而不是先扩面到品牌图标、震动预设或平台文案
|
||||
3. 若要增强 Godot 宿主覆盖,优先补真实 `InputMap` / `InputEvent` 集成测试宿主,而不是把更多原生对象直接放进普通 `dotnet test`
|
||||
@ -0,0 +1,95 @@
|
||||
# Input System Godot Integration 追踪
|
||||
|
||||
## 2026-05-10
|
||||
|
||||
### 阶段:统一输入抽象与 Godot 适配首轮落地(RP-001)
|
||||
|
||||
- 创建长分支 `feat/input-system-godot-integration`,并在 `feat/input-system-godot-integration#346`
|
||||
上推进独立实现与验证
|
||||
- 在 `GFramework.Game.Abstractions/Input/` 新增:
|
||||
- `InputBindingDescriptor`
|
||||
- `InputActionBinding`
|
||||
- `InputBindingSnapshot`
|
||||
- `InputDeviceContext`
|
||||
- `IInputBindingStore`
|
||||
- `IInputDeviceTracker`
|
||||
- `IUiInputActionMap`
|
||||
- `IUiInputDispatcher`
|
||||
- 在 `GFramework.Game/Input/` 新增:
|
||||
- `InputBindingStore`
|
||||
- `InputDeviceTracker`
|
||||
- `UiInputActionMap`
|
||||
- `UiInputDispatcher`
|
||||
- 在 `GFramework.Godot/Input/` 新增:
|
||||
- `GodotInputBindingCodec`
|
||||
- `IGodotInputMapBackend`
|
||||
- `GodotInputMapBackend`
|
||||
- `GodotInputBindingStore`
|
||||
- 关键设计决策:
|
||||
- 保留字符串动作名,直接复用 `InputActions.*` 常量
|
||||
- 抽象层只暴露 descriptor / snapshot,不暴露 Godot `InputEvent`
|
||||
- Godot backend 改成 descriptor-based contract,避免测试直接依赖原生 `InputEvent` 实例
|
||||
- `SetPrimaryBinding(...)` 改为按完整快照回写后端,以保留冲突交换语义
|
||||
- 新增测试:
|
||||
- `GFramework.Game.Tests/Input/InputBindingStoreTests.cs`
|
||||
- `GFramework.Game.Tests/Input/UiInputDispatcherTests.cs`
|
||||
- `GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs`
|
||||
- 文档更新:
|
||||
- 新增 `docs/zh-CN/game/input.md`
|
||||
- 新增 `docs/zh-CN/godot/input.md`
|
||||
- 更新 `docs/zh-CN/game/index.md`
|
||||
- 更新 `docs/zh-CN/godot/index.md`
|
||||
- 更新 `docs/zh-CN/tutorials/godot-integration.md`
|
||||
- 更新 `GFramework.Game.Abstractions/README.md`
|
||||
- 更新 `GFramework.Game/README.md`
|
||||
- 更新 `GFramework.Godot/README.md`
|
||||
|
||||
### 下一步
|
||||
|
||||
1. 若继续推进输入系统,优先定义更多逻辑动作与 gameplay 输入桥接,而不是先扩到宿主品牌文案
|
||||
2. 若要增强 Godot 验证,单独准备真实 `InputMap` / `InputEvent` 集成宿主,而不是依赖普通 VSTest process
|
||||
|
||||
## 2026-05-11
|
||||
|
||||
### 阶段:PR #346 review follow-up(RP-001)
|
||||
|
||||
- 核对当前分支 PR `#346` 的 CodeRabbit review,确认以下问题仍然适用于本地代码:
|
||||
- `InputBindingStore.GetBindings(...)` 读取缺失动作时会隐式创建空条目,并污染 `ExportSnapshot()`
|
||||
- `GodotInputBindingStore.ImportSnapshot(...)` 只覆盖快照内动作,未清空后端残留绑定
|
||||
- `InputDeviceTracker` / `UiInputDispatcher` / `IGodotInputMapBackend` 的 XML 文档缺少线程或异常契约
|
||||
- `GFramework.Game.Abstractions/README.md` 与 `GFramework.Game/README.md` 缺少输入系统文档入口
|
||||
- 公开 trace 中仍包含 worktree 目录名,已改为 `feat/input-system-godot-integration#346`
|
||||
- 本轮未跟进 `InputBindingDescriptor`、`InputActionBinding`、`InputBindingSnapshot`、`InputDeviceContext` 改成 `record` 的 nitpick
|
||||
- 原因:这些建议偏向值语义风格统一,不是当前 PR 中已验证的行为缺陷;本轮优先收敛真实回归风险与契约缺口
|
||||
- 新增回归测试:
|
||||
- `InputBindingStoreTests.GetBindings_WhenActionMissing_Should_NotMutateSnapshot`
|
||||
- `GodotInputBindingStoreTests.ImportSnapshot_WhenActionMissingFromSnapshot_Should_ClearBackendBindings`
|
||||
- 验证结果:
|
||||
- `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~InputBindingStoreTests|FullyQualifiedName~UiInputDispatcherTests"` 通过
|
||||
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter "FullyQualifiedName~GodotInputBindingStoreTests"` 通过
|
||||
|
||||
### 阶段:PR #346 review 二次 follow-up(RP-001)
|
||||
|
||||
- 再次抓取当前分支 PR `#346` 的 latest-head review threads,区分已在本地修复但 GitHub 线程仍未折叠的问题与仍然有效的问题
|
||||
- 确认以下 review 点已在本地代码中成立并继续处理:
|
||||
- `InputBindingStore` 缺少共享可变状态的线程安全使用约束说明
|
||||
- `GodotInputBindingCodec.TryCreateBinding(...)` 在键盘事件分支重复计算 `GetKeyCode(...)`
|
||||
- `GodotInputMapBackend.ResetAll()` 对运行时新增动作只清空事件、不移除动作本身,和默认快照替换语义不一致
|
||||
- 新增回归测试:
|
||||
- `GodotInputBindingStoreTests.ResetAll_WhenRuntimeActionIsNotInDefaults_Should_RemoveAction`
|
||||
- 验证结果:
|
||||
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter "FullyQualifiedName~GodotInputBindingStoreTests"` 通过(5/5)
|
||||
- `python3 scripts/license-header.py --check --paths GFramework.Game.Abstractions/README.md GFramework.Game.Tests/Input/InputBindingStoreTests.cs GFramework.Game/Input/InputBindingStore.cs GFramework.Game/Input/InputDeviceTracker.cs GFramework.Game/Input/UiInputDispatcher.cs GFramework.Game/README.md GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs GFramework.Godot/Input/GodotInputBindingStore.cs GFramework.Godot/Input/GodotInputMapBackend.cs GFramework.Godot/Input/IGodotInputMapBackend.cs ai-plan/public/input-system-godot-integration/traces/input-system-godot-integration-trace.md` 通过
|
||||
- `dotnet build GFramework.Game/GFramework.Game.csproj -c Release -m:1 -nodeReuse:false` 通过(0 warning, 0 error)
|
||||
- `dotnet build GFramework.Godot/GFramework.Godot.csproj -c Release -m:1 -nodeReuse:false` 通过(0 warning, 0 error)
|
||||
- `git ... diff --check` 通过
|
||||
|
||||
### 下一步
|
||||
|
||||
1. 如需继续消化 open review threads,可再评估值对象切换到 `record` 的收益与兼容性
|
||||
2. 若需要更高置信度的宿主验证,再补真实 Godot `InputMap` 集成测试宿主
|
||||
|
||||
### 下一步
|
||||
|
||||
1. 运行针对本次改动文件的 license-header 检查并补录结果
|
||||
2. 如需继续消化 PR review,再单独评估值对象切换到 `record` 是否值得放进同一个 PR
|
||||
@ -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)
|
||||
|
||||
@ -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(...)` | 需要接入自定义或第三方 publisher 策略 | 取决于实现 | 取决于实现 | 仅在内置顺序 / 并行策略都不满足时使用 |
|
||||
| `UseNotificationPublisher(...)` / `UseNotificationPublisher<TPublisher>()` | 需要接入自定义或第三方 publisher 策略 | 取决于实现 | 取决于实现 | 前者复用现成实例,后者让容器负责单例生命周期;两者都要求容器此前尚未注册 `INotificationPublisher` |
|
||||
|
||||
如果你想在组合根里显式保留默认顺序语义,也可以直接写成:
|
||||
|
||||
@ -160,6 +161,14 @@ using GFramework.Cqrs.Notification;
|
||||
container.UseNotificationPublisher(new TaskWhenAllNotificationPublisher());
|
||||
```
|
||||
|
||||
如果你的自定义 publisher 需要继续由容器构造和托管,也可以改用泛型注册入口:
|
||||
|
||||
```csharp
|
||||
using GFramework.Cqrs.Extensions;
|
||||
|
||||
container.UseNotificationPublisher<MyCustomNotificationPublisher>();
|
||||
```
|
||||
|
||||
## Request 与流式变体
|
||||
|
||||
除了最常见的 `Command` / `Query` / `Notification`,当前公开面还覆盖两类容易被忽略的入口:
|
||||
@ -208,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。
|
||||
|
||||
@ -223,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 元数据才会退回整程序集扫描。
|
||||
|
||||
@ -85,6 +85,17 @@ var count = this.SendQuery(
|
||||
|
||||
在标准架构启动路径中,这些兼容入口底层同样会转到统一 `ICqrsRuntime`。
|
||||
因此历史查询对象仍保持原始 `SendQuery(...)` / `SendQueryAsync(...)` 用法,但会共享新版 request pipeline 与上下文注入链路。
|
||||
只有在你直接 `new QueryExecutor()` 或 `new AsyncQueryExecutor()` 做隔离测试,且没有提供 `ICqrsRuntime` 时,才会回退到 legacy 直接执行;这时异步查询也不会进入统一 CQRS pipeline。
|
||||
|
||||
## 兼容入口和 CQRS bridge 的关系
|
||||
|
||||
旧查询页面的重点不是再引入一套新执行模型,而是说明兼容入口现在如何接到 CQRS runtime:
|
||||
|
||||
- `SendQuery(...)` / `SendQueryAsync(...)` 仍然是面向存量代码的旧 API
|
||||
- 标准 `Architecture` 路径会把旧查询包装成内部 bridge request,再交给 `ICqrsRuntime`
|
||||
- 这让旧查询对象在不改调用方式的前提下,也能共享当前 CQRS 的 pipeline、handler 调度和上下文注入语义
|
||||
|
||||
如果你依赖的是 direct executor 测试或隔离运行,那么仍要把它看成 legacy 路径,而不是完整的新 CQRS 使用方式。
|
||||
|
||||
在 `IContextAware` 对象内部,通常直接使用 `GFramework.Core.Extensions` 里的扩展:
|
||||
|
||||
@ -97,10 +108,11 @@ using GFramework.Core.Extensions;
|
||||
- 你在维护现有 `Core.Query` 代码
|
||||
- 当前代码已经建立在旧查询执行器之上
|
||||
- 你只想修正局部行为,不想顺手迁移整条调用链
|
||||
- 你需要保留现有 `AbstractQuery*` 类型与测试入口,只要求标准架构下继续复用统一 runtime
|
||||
|
||||
## 什么时候改用 CQRS 查询
|
||||
|
||||
如果你正在写新的读取路径,优先考虑:
|
||||
如果你正在写新的读取路径,或者已经需要统一读写模型,优先考虑:
|
||||
|
||||
- `GFramework.Cqrs.Abstractions.Cqrs.Query.IQuery<TResponse>`
|
||||
- `AbstractQueryHandler<TQuery, TResponse>`
|
||||
@ -108,4 +120,9 @@ using GFramework.Core.Extensions;
|
||||
|
||||
原因很简单:新查询路径和命令、通知、流式请求共享同一 dispatcher 与行为管道。
|
||||
|
||||
可以按下面的判断来选:
|
||||
|
||||
- 继续保留旧路径:为了兼容已有 `Query` 类型、旧执行器或局部修复场景
|
||||
- 迁移到 CQRS:为了把新的读取能力纳入统一 request model,而不是继续扩大 legacy 查询面
|
||||
|
||||
继续阅读:[cqrs](./cqrs.md)
|
||||
|
||||
@ -17,7 +17,7 @@ description: 说明 GFramework.Game 配置系统的定位、目录约定、生
|
||||
- JSON Schema 作为结构描述
|
||||
- 一对象一文件的目录组织
|
||||
- 运行时只读查询
|
||||
- Runtime / Generator / Tooling 共享支持 `enum`、`const`、`not`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties`、`dependentRequired`、`dependentSchemas`、`allOf`、object-focused `if` / `then` / `else`,以及闭合对象边界 `additionalProperties: false`
|
||||
- Runtime / Generator / Tooling 共享支持 `enum`、`const`、`not`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties`、`dependentRequired`、`dependentSchemas`、`allOf`、object-focused `if` / `then` / `else`,以及闭合对象边界 `additionalProperties: false`;数组形状当前只接受单个 object-valued `items` schema
|
||||
- Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录
|
||||
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
|
||||
|
||||
@ -813,13 +813,14 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
|
||||
- `allOf`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只接受 object 节点上的 object-typed inline schema 数组,并按 focused constraint block 语义把每个条目叠加到当前对象上,不做属性合并,也不改变生成类型形状
|
||||
- `if` / `then` / `else`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只接受 object 节点上的 object-typed inline schema,`if` 必填且必须至少配合 `then` 或 `else` 之一使用,分支只能约束父对象已声明的字段,不做属性合并,也不改变生成类型形状;条件匹配本身沿用 `dependentSchemas` / `allOf` 的 focused matcher 语义,允许对象保留未在条件块中声明的额外同级字段
|
||||
- `additionalProperties`:当前共享支持边界只接受 `additionalProperties: false`;它用于声明对象是闭合的,运行时、生成器和工具都会据此拒绝未声明字段。其他 `additionalProperties` 形态,以及 `patternProperties`、`propertyNames`、`unevaluatedProperties` 这类会重新打开对象形状的关键字,当前都不属于共享支持子集,会在解析或生成阶段直接被拒绝
|
||||
- 数组形状关键字:当前共享支持边界只接受单个 object-valued `items` schema;`prefixItems`、`additionalItems`、`unevaluatedItems` 这类 tuple / open-array 关键字当前都不属于共享支持子集,会在解析或生成阶段直接被拒绝,避免数组元素形状在三端静默漂移
|
||||
- `oneOf` / `anyOf`:当前不属于共享支持子集;Runtime / Generator / Tooling 会在解析或生成阶段直接拒绝,避免静默接受会改变生成类型形状的组合关键字
|
||||
|
||||
如果你的 schema 需要超出这些边界的复杂 shape,推荐采用下面的回退顺序:
|
||||
|
||||
1. 先在 raw YAML 与 schema 文件中直接编辑,而不是强行依赖表单入口
|
||||
2. 再核对该 shape 是否仍符合这里列出的共享支持子集
|
||||
3. 如果它依赖 `oneOf` / `anyOf`、非 `false` 的 `additionalProperties`、`patternProperties` / `propertyNames` / `unevaluatedProperties` 这类开放对象关键字、会向父对象注入新字段的 `allOf` / `dependentSchemas` / `if` 分支,或者更异构的深层数组结构,就应当把它视为当前版本之外的设计,而不是工具层遗漏的“隐藏能力”
|
||||
3. 如果它依赖 `oneOf` / `anyOf`、非 `false` 的 `additionalProperties`、`patternProperties` / `propertyNames` / `unevaluatedProperties` 这类开放对象关键字、`prefixItems` / `additionalItems` / `unevaluatedItems` 这类 tuple / open-array 关键字、会向父对象注入新字段的 `allOf` / `dependentSchemas` / `if` 分支,或者更异构的深层数组结构,就应当把它视为当前版本之外的设计,而不是工具层遗漏的“隐藏能力”
|
||||
|
||||
`allOf` 的最小可工作示例如下。关键点是:字段形状先在父对象 `properties` 中声明,再用 `allOf` 叠加 `required` 或更细的字段约束;`allOf` 条目不会把新字段并回父对象。
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ description: 说明 GFramework AI-First 配置工作流对应的 VS Code 工具
|
||||
|
||||
- 项目不使用 `GFramework.Game` 的配置工作流
|
||||
- 需要完整 JSON Schema 编辑器,而不是当前仓库落地的稳定子集
|
||||
- 需要在编辑器里处理更深层对象数组嵌套,且不接受回退到 raw YAML
|
||||
- 需要完整放宽到当前共享支持子集之外的更异构数组结构,且不接受回退到 raw YAML
|
||||
|
||||
## 工作区约定
|
||||
|
||||
@ -83,7 +83,7 @@ Explorer + 表单预览。
|
||||
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口
|
||||
- 对同一配置域内的多份 YAML 文件执行批量字段更新
|
||||
- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-table(UI 中显示为 ref-table) / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties / dependentRequired / dependentSchemas / allOf / if / then / else` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
|
||||
- 对 `additionalProperties: false` 提供闭合对象边界校验,并在遇到 `oneOf` / `anyOf` 或其他当前未收口的组合形状时明确提示该 schema 不属于当前工具支持子集
|
||||
- 对 `additionalProperties: false` 提供闭合对象边界校验,并在遇到 `oneOf` / `anyOf`、`prefixItems` / `additionalItems` / `unevaluatedItems` 或其他当前未收口的组合 / 数组形状时明确提示该 schema 不属于当前工具支持子集
|
||||
|
||||
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
|
||||
|
||||
@ -195,6 +195,7 @@ rewardTableId: starter-reward
|
||||
|
||||
- `additionalProperties` 是否显式设置为 `false`;省略或 `true` 不属于当前共享支持子集
|
||||
- schema 是否依赖 `oneOf` / `anyOf`;这些组合关键字会被 Runtime / Generator / Tooling 直接拒绝
|
||||
- schema 是否依赖 `prefixItems` / `additionalItems` / `unevaluatedItems`;当前数组子集只接受单个 object-valued `items` schema
|
||||
- 对象数组里是否混入标量项,或是否存在更深、更异构的数组结构
|
||||
- Runtime / Source Generator 是否已经接受这份 schema,而不是只有编辑器里“暂时看起来能写”
|
||||
|
||||
@ -230,15 +231,16 @@ rewardTableId: starter-reward
|
||||
- `contains` / `minContains` / `maxContains`
|
||||
- `additionalProperties: false`
|
||||
|
||||
如果你进入更深层对象数组嵌套,当前更稳妥的做法通常是:
|
||||
如果你进入对象数组里的继续嵌套对象数组,当前表单通常仍可继续处理;更稳妥的顺序是:
|
||||
|
||||
1. 用 Explorer 找到目标文件
|
||||
2. 先看表单预览确认字段结构
|
||||
3. 再回到 raw YAML 完成最终编辑
|
||||
2. 先看表单预览确认当前层级仍保持对象 / 标量字段 / 标量数组 / 嵌套对象的共享子集形状
|
||||
3. 只有在结构开始变得更异构,或已经超出当前共享支持子集时,再回到 raw YAML 完成最终编辑
|
||||
|
||||
以下 shape 目前也建议直接回退到 raw YAML,并同时检查 schema 是否仍在当前共享支持子集内:
|
||||
|
||||
- 需要表达 `oneOf` / `anyOf` 这类会改变生成类型形状的组合关键字
|
||||
- 需要表达 `prefixItems` / `additionalItems` / `unevaluatedItems` 这类 tuple / open-array 关键字
|
||||
- 需要 `additionalProperties` 的其他形态,而不是当前明确支持的 `additionalProperties: false`
|
||||
- 需要在 `allOf`、`dependentSchemas`、`if` / `then` / `else` 中引入父对象未声明的新字段
|
||||
- 需要比当前对象数组编辑器更深、更异构的数组结构
|
||||
@ -265,9 +267,9 @@ rewardTableId: starter-reward
|
||||
|
||||
- 工作区默认只取第一个 workspace folder
|
||||
- 校验聚焦仓库当前支持的 schema 子集
|
||||
- 表单预览支持对象数组,但更深的嵌套对象数组仍可能需要回退到 raw YAML
|
||||
- 表单预览支持对象数组,以及对象数组项内部继续嵌套的对象数组;只有当内层结构超出共享子集时才需要回退到 raw YAML
|
||||
- 批量编辑当前聚焦顶层标量和顶层标量数组字段
|
||||
- 共享约束里只支持闭合对象边界 `additionalProperties: false`;`oneOf` / `anyOf` 等改变生成形状的组合关键字会被明确拒绝
|
||||
- 共享约束里只支持闭合对象边界 `additionalProperties: false`,数组形状里只支持单个 object-valued `items` schema;`oneOf` / `anyOf` 与 `prefixItems` / `additionalItems` / `unevaluatedItems` 这类会改变生成形状的关键字会被明确拒绝
|
||||
|
||||
因此,最稳妥的理解方式是:
|
||||
|
||||
|
||||
@ -40,6 +40,8 @@ description: GFramework.Game 运行时模块的入口、采用顺序与源码阅
|
||||
- 导航与界面
|
||||
- [场景系统](./scene.md)
|
||||
- [UI 系统](./ui.md)
|
||||
- 输入与动作绑定
|
||||
- [输入系统](./input.md)
|
||||
|
||||
## 最小接入路径
|
||||
|
||||
@ -110,6 +112,7 @@ shape,优先回到 raw YAML 和 schema 设计本体处理,再决定是否拆
|
||||
4. [数据系统](./data.md)
|
||||
5. [设置系统](./setting.md)
|
||||
6. [场景系统](./scene.md)或[UI 系统](./ui.md)
|
||||
7. [输入系统](./input.md)
|
||||
|
||||
## 源码与 API 阅读入口
|
||||
|
||||
|
||||
97
docs/zh-CN/game/input.md
Normal file
97
docs/zh-CN/game/input.md
Normal file
@ -0,0 +1,97 @@
|
||||
---
|
||||
title: 输入系统
|
||||
description: 说明 GFramework.Game 与 GFramework.Game.Abstractions 当前提供的统一输入契约、默认运行时与 UI 语义桥接边界。
|
||||
---
|
||||
|
||||
# 输入系统
|
||||
|
||||
`GFramework.Game.Abstractions.Input` 与 `GFramework.Game.Input` 提供的是“动作绑定管理”和“UI 语义桥接”这一层输入系统,
|
||||
而不是直接替代任何具体引擎的输入 API。
|
||||
|
||||
当前 v1 聚焦三件事:
|
||||
|
||||
- 用稳定 DTO 描述动作绑定,而不是把引擎原生输入事件暴露给业务层
|
||||
- 允许导出 / 导入绑定快照,并支持主绑定替换、冲突交换和默认恢复
|
||||
- 把逻辑动作名桥接到现有 `UiInputAction`,继续复用 `UiRouterBase` 的输入仲裁
|
||||
|
||||
## 契约层入口
|
||||
|
||||
`GFramework.Game.Abstractions.Input` 当前公开这些核心类型:
|
||||
|
||||
- `InputBindingDescriptor`
|
||||
- 描述一个动作绑定使用的设备族、绑定类型、稳定码值和展示名称
|
||||
- `InputActionBinding`
|
||||
- 描述单个逻辑动作当前持有的绑定集合
|
||||
- `InputBindingSnapshot`
|
||||
- 描述一组动作绑定的可持久化快照
|
||||
- `IInputBindingStore`
|
||||
- 定义查询、主绑定替换、快照导入导出与默认恢复契约
|
||||
- `IInputDeviceTracker`
|
||||
- 定义当前活跃输入设备上下文查询入口
|
||||
- `IUiInputActionMap` / `IUiInputDispatcher`
|
||||
- 定义逻辑动作名到 `UiInputAction` 的桥接边界
|
||||
|
||||
这里仍然保留字符串动作名,而不是额外发明新的动作 ID 类型。对 Godot 项目来说,这意味着可以直接继续使用
|
||||
`project.godot` 生成出来的 `InputActions.*` 常量。
|
||||
|
||||
## 默认运行时
|
||||
|
||||
`GFramework.Game.Input` 当前提供的默认实现是:
|
||||
|
||||
- `InputBindingStore`
|
||||
- 纯托管输入绑定存储
|
||||
- 管理默认快照、当前快照、主绑定替换与冲突交换
|
||||
- `InputDeviceTracker`
|
||||
- 可由宿主侧更新的活跃设备上下文持有者
|
||||
- `UiInputActionMap`
|
||||
- 默认把 `ui_cancel` / `cancel` 映射到 `UiInputAction.Cancel`
|
||||
- 默认把 `ui_accept` / `confirm` / `submit` 映射到 `UiInputAction.Confirm`
|
||||
- `UiInputDispatcher`
|
||||
- 把逻辑动作名继续分发给 `IUiRouter.TryDispatchUiAction(...)`
|
||||
|
||||
也就是说,`Game` 层现在只负责统一输入语义与默认运行时行为;实际的物理输入事件采集仍由宿主层负责。
|
||||
|
||||
## 最小接入方式
|
||||
|
||||
如果你的项目已经有动作名常量,只想先接入统一输入绑定和 UI 桥接,可以从这组最小组合开始:
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Abstractions.Input;
|
||||
using GFramework.Game.Abstractions.UI;
|
||||
using GFramework.Game.Input;
|
||||
|
||||
var defaultSnapshot = new InputBindingSnapshot(
|
||||
[
|
||||
new InputActionBinding(
|
||||
"ui_accept",
|
||||
[
|
||||
new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.Key,
|
||||
"key:13",
|
||||
"Enter")
|
||||
])
|
||||
]);
|
||||
|
||||
var bindingStore = new InputBindingStore(defaultSnapshot);
|
||||
var dispatcher = new UiInputDispatcher(new UiInputActionMap(), uiRouter);
|
||||
```
|
||||
|
||||
随后由项目自己的宿主层决定:
|
||||
|
||||
- 什么时候读取物理输入
|
||||
- 什么时候调用 `SetPrimaryBinding(...)`
|
||||
- 什么时候触发 `dispatcher.TryDispatch(...)`
|
||||
|
||||
## 当前边界
|
||||
|
||||
- 这套输入抽象当前不尝试复刻完整 `PlayerInput` / `ActionMap` 系统
|
||||
- 当前只统一动作绑定管理、快照导入导出与 UI 语义桥接
|
||||
- 设备品牌识别、平台差异文案、震动等宿主专属能力不在 `Game.Abstractions` 契约层
|
||||
- 触摸 / 手柄轴向等更复杂输入源当前只保证 DTO 能表达,不保证 `Game` 层自带完整采集策略
|
||||
|
||||
## 相关主题
|
||||
|
||||
- [UI 系统](./ui.md)
|
||||
- [Godot 输入集成](../godot/input.md)
|
||||
- [Godot 集成教程](../tutorials/godot-integration.md)
|
||||
@ -15,6 +15,7 @@ description: 以当前 GFramework.Godot 源码、测试与 CoreGrid 接线为准
|
||||
- 架构生命周期与场景树绑定:`AbstractArchitecture`、`ArchitectureAnchor`
|
||||
- 节点运行时辅助:`WaitUntilReadyAsync()`、`AddChildXAsync()`、`QueueFreeX()`、`UnRegisterWhenNodeExitTree(...)`
|
||||
- Godot 风格的 Scene / UI 工厂与 registry:`GodotSceneFactory`、`GodotUiFactory`
|
||||
- 基于 `InputMap` 的动作绑定适配:`GodotInputBindingStore`
|
||||
- Godot 特化的存储、设置与配置加载:`GodotFileStorage`、`GodotAudioSettings`、`GodotYamlConfigLoader`
|
||||
- 少量面向运行时交互的扩展:`Signal(...)` fluent API、`GodotLogAppender`、暂停处理、富文本效果、协程时间源
|
||||
|
||||
@ -136,6 +137,7 @@ public partial class SettingsPanel : Control
|
||||
- 架构锚点与模块挂接:[Godot 架构集成](./architecture.md)
|
||||
- Scene / `PackedScene` 工厂与行为封装:[Godot 场景系统](./scene.md)
|
||||
- UI page 行为、layer 语义与工厂:[Godot UI 系统](./ui.md)
|
||||
- 动作绑定与 `InputMap` 适配:[Godot 输入集成](./input.md)
|
||||
- Godot 文件路径与持久化适配:[Godot 存储系统](./storage.md)
|
||||
- 音频、图形与本地化设置接线:[Godot 设置系统](./setting.md)
|
||||
- `Signal(...)` fluent API 与动态连接边界:[Godot 信号系统](./signal.md)
|
||||
@ -162,4 +164,5 @@ public partial class SettingsPanel : Control
|
||||
2. [Godot 架构集成](./architecture.md)
|
||||
3. [Godot 场景系统](./scene.md)
|
||||
4. [Godot UI 系统](./ui.md)
|
||||
5. [Godot 项目元数据生成器](../source-generators/godot-project-generator.md)
|
||||
5. [Godot 输入集成](./input.md)
|
||||
6. [Godot 项目元数据生成器](../source-generators/godot-project-generator.md)
|
||||
|
||||
67
docs/zh-CN/godot/input.md
Normal file
67
docs/zh-CN/godot/input.md
Normal file
@ -0,0 +1,67 @@
|
||||
---
|
||||
title: Godot 输入集成
|
||||
description: 说明 GFramework.Godot 如何把 InputMap 与 project.godot InputActions 接到新的框架输入抽象。
|
||||
---
|
||||
|
||||
# Godot 输入集成
|
||||
|
||||
`GFramework.Godot.Input` 负责把 Godot 的 `InputMap` 绑定表接到 `GFramework.Game.Abstractions.Input` 契约。
|
||||
|
||||
当前入口是:
|
||||
|
||||
- `GodotInputBindingStore`
|
||||
- 读取 / 写回 `InputMap`
|
||||
- 导出 / 导入 `InputBindingSnapshot`
|
||||
- 把逻辑动作名继续桥接给 `Game` 层的绑定存储与 UI 输入分发语义
|
||||
|
||||
## 与 `project.godot` 的关系
|
||||
|
||||
当前推荐组合仍然是:
|
||||
|
||||
- `project.godot`
|
||||
- 继续定义动作名与默认绑定
|
||||
- `GFramework.Godot.SourceGenerators`
|
||||
- 继续生成 `InputActions.*` 字符串常量
|
||||
- `GFramework.Godot.Input.GodotInputBindingStore`
|
||||
- 负责运行时读取默认绑定、替换主绑定、恢复默认和导出快照
|
||||
|
||||
这意味着新的运行时输入系统不会替代 `InputActions`,而是把它当作稳定动作名入口继续使用。
|
||||
|
||||
## 最小接入方式
|
||||
|
||||
```csharp
|
||||
using GFramework.Game.Abstractions.Input;
|
||||
using GFramework.Game.Input;
|
||||
using GFramework.Godot.Generated;
|
||||
using GFramework.Godot.Input;
|
||||
|
||||
var bindingStore = new GodotInputBindingStore();
|
||||
|
||||
var acceptBinding = bindingStore.GetBindings(InputActions.UiAccept);
|
||||
bindingStore.SetPrimaryBinding(
|
||||
InputActions.UiAccept,
|
||||
new InputBindingDescriptor(
|
||||
InputDeviceKind.KeyboardMouse,
|
||||
InputBindingKind.Key,
|
||||
"key:32",
|
||||
"Space"));
|
||||
```
|
||||
|
||||
如果你已经有 `UiRouterBase`,还可以继续把动作名桥接到 UI 语义:
|
||||
|
||||
```csharp
|
||||
var dispatcher = new UiInputDispatcher(new UiInputActionMap(), uiRouter);
|
||||
dispatcher.TryDispatch(InputActions.UiCancel);
|
||||
```
|
||||
|
||||
## 当前边界
|
||||
|
||||
- `GodotInputBindingStore` 当前聚焦 `InputMap` 绑定管理,而不是完整 gameplay input runtime
|
||||
- 当前测试覆盖的是纯托管后端语义,不是 Godot 原生 `InputEvent` 对象在所有宿主中的行为差异
|
||||
- 设备品牌、手柄图标、震动预设等宿主特化体验仍应视为 Godot 专属扩展,不上升到 `Game.Abstractions`
|
||||
|
||||
## 相关主题
|
||||
|
||||
- [Game 输入系统](../game/input.md)
|
||||
- [Godot 运行时集成](./index.md)
|
||||
- [Godot 集成教程](../tutorials/godot-integration.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 本身继续工作。
|
||||
|
||||
## 哪些场景通常不会直接退回整程序集扫描
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ description: 以当前源码和真实消费者接线为准,说明 GFramework
|
||||
- 项目级配置:`project.godot` -> `AutoLoads` / `InputActions`
|
||||
- 场景级样板:`[GetNode]` / `[BindNodeSignal]`
|
||||
- 运行时辅助:节点生命周期、事件解绑、异步等待
|
||||
- 输入绑定管理:`GodotInputBindingStore`
|
||||
|
||||
它不再把旧版长篇 API 列表当事实来源,也不把 `AbstractGodotModule` / `InstallGodotModule(...)` 当成默认接入起点。
|
||||
|
||||
|
||||
@ -80,6 +80,8 @@ The extension currently validates the repository's current schema subset:
|
||||
object-focused `if` / `then` / `else`
|
||||
- closed-object validation through `additionalProperties: false`
|
||||
- explicit rejection for unsupported combinators such as `oneOf` and `anyOf`, instead of silently ignoring them
|
||||
- explicit rejection for unsupported array-shape keywords such as `prefixItems`, `additionalItems`, and
|
||||
`unevaluatedItems`, so tuple or open-array item shapes do not drift away from Runtime / Generator
|
||||
|
||||
## Contract Boundary
|
||||
|
||||
@ -106,8 +108,8 @@ This extension is an editor-side helper. It does not define the runtime contract
|
||||
project-specific paths relative to the first workspace folder.
|
||||
3. Open the `GFramework Config` explorer view and select a config file or domain.
|
||||
4. Run validation first to confirm the current YAML files still match the supported schema subset.
|
||||
5. Open the lightweight form preview or domain batch editing actions, then fall back to raw YAML for deeper nested edits
|
||||
when needed.
|
||||
5. Open the lightweight form preview or domain batch editing actions, then fall back to raw YAML only when the current
|
||||
path exceeds the supported object-array editor boundary or leaves the shared schema subset.
|
||||
|
||||
Minimal adoption checklist:
|
||||
|
||||
@ -117,6 +119,8 @@ Minimal adoption checklist:
|
||||
- Use `x-gframework-ref-table` only on fields that should link to another config domain or reference file
|
||||
- Keep `additionalProperties` explicitly set to `false` when you need closed-object validation; omitting it, setting
|
||||
it to `true`, or mixing in `patternProperties`, `propertyNames`, or `unevaluatedProperties` is outside the supported subset
|
||||
- Keep arrays on one object-valued `items` schema; tuple or open-array keywords such as `prefixItems`,
|
||||
`additionalItems`, and `unevaluatedItems` are outside the supported subset
|
||||
|
||||
Use raw YAML directly when you need:
|
||||
|
||||
@ -126,6 +130,7 @@ Use raw YAML directly when you need:
|
||||
- `contains` / `minContains` / `maxContains` when the structure is easier to reason about directly in YAML
|
||||
- schema designs outside the current shared subset, including `oneOf`, `anyOf`, non-`false` `additionalProperties`, or
|
||||
other open-object keywords such as `patternProperties`, `propertyNames`, and `unevaluatedProperties`
|
||||
- tuple or open-array designs that depend on `prefixItems`, `additionalItems`, or `unevaluatedItems`
|
||||
|
||||
## Documentation
|
||||
|
||||
@ -136,12 +141,14 @@ Use raw YAML directly when you need:
|
||||
|
||||
- Multi-root workspaces use the first workspace folder
|
||||
- Validation only covers the repository's current schema subset
|
||||
- Form preview supports nested objects and object-array editing, but deeper nested object arrays inside array items still
|
||||
fall back to raw YAML
|
||||
- Form preview supports nested objects, object arrays, and nested object arrays inside object-array items as long as
|
||||
those nested items still stay within the shared subset's object/scalar/array shape
|
||||
- Batch editing remains limited to top-level scalar fields and top-level scalar arrays
|
||||
- Closed-object support is limited to `additionalProperties: false`; open-object keywords such as
|
||||
`patternProperties`, `propertyNames`, and `unevaluatedProperties` are rejected on purpose, as are unsupported
|
||||
combinators such as `oneOf` / `anyOf`
|
||||
- Array-shape support is limited to one object-valued `items` schema; tuple or open-array keywords such as
|
||||
`prefixItems`, `additionalItems`, and `unevaluatedItems` are rejected on purpose
|
||||
|
||||
## Local Testing
|
||||
|
||||
|
||||
@ -1107,6 +1107,7 @@ function parseSchemaNode(rawNode, displayPath) {
|
||||
}
|
||||
|
||||
validateUnsupportedOpenObjectKeyword(value, displayPath);
|
||||
validateUnsupportedArrayShapeKeyword(value, displayPath);
|
||||
|
||||
const type = resolveSupportedSchemaType(value.type, displayPath);
|
||||
const patternMetadata = normalizeSchemaPattern(value.pattern, displayPath);
|
||||
@ -1290,6 +1291,24 @@ function validateUnsupportedOpenObjectKeyword(schemaNode, displayPath) {
|
||||
"The current config schema subset only accepts 'additionalProperties: false' and rejects keywords that reopen object shapes so fields remain closed and strongly typed.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject tuple-array and open-array keywords that would let tooling accept item
|
||||
* shapes outside the Runtime / Generator shared subset.
|
||||
*
|
||||
* @param {Record<string, unknown>} schemaNode Raw schema object.
|
||||
* @param {string} displayPath Logical property path.
|
||||
*/
|
||||
function validateUnsupportedArrayShapeKeyword(schemaNode, displayPath) {
|
||||
const unsupportedKeyword = getUnsupportedArrayShapeKeywordName(schemaNode);
|
||||
if (!unsupportedKeyword) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Schema property '${displayPath}' uses unsupported '${unsupportedKeyword}' metadata. ` +
|
||||
"The current config schema subset only accepts one object-valued 'items' schema and rejects tuple or open-array keywords that can change item shape across Runtime, Generator, and Tooling.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one required array child schema while keeping tooling errors aligned
|
||||
* with the Runtime and Source Generator contracts.
|
||||
@ -1406,6 +1425,29 @@ function getUnsupportedOpenObjectKeywordName(schemaNode) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first array-shape keyword that the current shared schema subset
|
||||
* intentionally rejects to keep array item contracts aligned.
|
||||
*
|
||||
* @param {Record<string, unknown>} schemaNode Raw schema object.
|
||||
* @returns {string | undefined} Unsupported keyword name when present.
|
||||
*/
|
||||
function getUnsupportedArrayShapeKeywordName(schemaNode) {
|
||||
if (Object.prototype.hasOwnProperty.call(schemaNode, "prefixItems")) {
|
||||
return "prefixItems";
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(schemaNode, "additionalItems")) {
|
||||
return "additionalItems";
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(schemaNode, "unevaluatedItems")) {
|
||||
return "unevaluatedItems";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one optional `not` sub-schema and keep path formatting aligned with
|
||||
* the runtime/generator diagnostics.
|
||||
|
||||
@ -302,6 +302,61 @@ test("parseSchemaContent should reject unsupported open-object keywords", () =>
|
||||
/unsupported 'unevaluatedProperties' metadata/u);
|
||||
});
|
||||
|
||||
test("parseSchemaContent should reject unsupported array-shape keywords", () => {
|
||||
assert.throws(
|
||||
() => parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{ "type": "integer" }
|
||||
],
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
/unsupported 'prefixItems' metadata/u);
|
||||
|
||||
assert.throws(
|
||||
() => parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"additionalItems": false,
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
/unsupported 'additionalItems' metadata/u);
|
||||
|
||||
assert.throws(
|
||||
() => parseSchemaContent(`
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dropRates": {
|
||||
"type": "array",
|
||||
"unevaluatedItems": false,
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
/unsupported 'unevaluatedItems' metadata/u);
|
||||
});
|
||||
|
||||
test("parseSchemaContent should reject unsupported explicit schema types", () => {
|
||||
assert.throws(
|
||||
() => parseSchemaContent(`
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user