Compare commits

...

28 Commits

Author SHA1 Message Date
gewuyou
36db7d0929
Merge pull request #306 from GeWuYou/feat/ai-first-config
Feat/ai first config
2026-04-30 16:09:43 +08:00
gewuyou
e671646a74 fix(ai-first-config): 收口 PR 306 审查遗留项
- 新增 Generator 与 Tooling 的 anyOf 和坏形状回归覆盖,补齐组合关键字与未知 type 拒绝

- 修复 VS Code 配置工具的 object-array 直属项收集与 contains 文案一致性问题

- 更新 README、Game 文档与工具说明,明确 additionalProperties 显式 false 边界与最小接入路径

- 补充 ai-plan 跟踪与 trace,记录 PR 306 open threads 收口结果和验证摘要
2026-04-30 15:22:04 +08:00
gewuyou
040bcb99e4 fix(ai-first-config): 收口当前 PR 审查遗留项
- 新增 anyOf 对称运行时回归测试,覆盖组合关键字拒绝分支

- 更新 Game Abstractions README 的配置系统链接显示名,避免暴露原始路径

- 精简 active tracking 的批次级验证细节并补充恢复指针

- 清理 trace 中重复日期标题,消除 MD024 风险
2026-04-30 15:03:47 +08:00
gewuyou
85f7c1707e docs(game): 同步场景与宿主入口配置边界
- 补充 Scene 与 UI 入口对配置系统正式边界页的指引

- 明确 oneOf、anyOf 与非 false additionalProperties 不属于默认采用路径

- 更新 Godot storage 入口对 VS Code 工具辅助层与 raw YAML 回退路径的说明
2026-04-30 13:25:29 +08:00
gewuyou
01f1e5fd72 docs(game): 同步数据与设置入口配置边界
- 补充 data 与 setting 入口对 AI-First 配置系统共享 schema 子集的 reader-facing 提示

- 说明 DataRepository、UnifiedSettingsDataRepository 与 SettingsModel 负责持久化和应用而不放宽配置契约

- 更新复杂 schema shape 回到 config-system 与 raw YAML 处理的采用指引
2026-04-30 13:25:29 +08:00
gewuyou
e8203bc76e docs(game): 同步生成器与持久化入口配置边界
- 补充 Game.SourceGenerators 对共享 schema 子集的 reader-facing 采用边界说明

- 更新 serialization 与 storage 页面中的复杂 schema 回退路径提示

- 明确 oneOf、anyOf 与非 false additionalProperties 不属于默认采用路径
2026-04-30 13:25:29 +08:00
gewuyou
7e62313b24 docs(game): 同步总览入口配置采用边界
- 补充首页、入门页与 API 导航对 AI-First 配置工作流正式契约的高层说明

- 更新入口提示以说明 additionalProperties: false 与 oneOf/anyOf 的默认采用边界

- 强调超出共享 schema 子集的复杂 shape 应回到 raw YAML 与 schema 设计处理
2026-04-30 13:25:26 +08:00
gewuyou
74f853bffe docs(game): 同步生成器与抽象层配置边界
- 更新 source-generators 入口,说明 Game.SourceGenerators 面向与 Runtime 对齐的共享 schema 子集

- 补充 abstractions 文档与 README,明确配置契约实现边界仍需回到 GFramework.Game 与 config-system 文档

- 强调 oneOf、anyOf 与非 false 的 additionalProperties 不属于当前 reader-facing 采用路径
2026-04-30 13:23:19 +08:00
gewuyou
56a96b50fd docs(game): 同步配置入口采用边界
- 更新 Game 入口页的静态 YAML 配置接入提示,明确 Runtime 与 Source Generator 的共享契约优先级

- 补充安装入口对 additionalProperties: false 与 oneOf / anyOf 拒绝边界的 reader-facing 提示

- 优化入口页采用建议,说明复杂 shape 应回退到 raw YAML 与 schema 设计本体
2026-04-30 13:23:19 +08:00
gewuyou
0721cafd03 docs(game): 同步配置工作流入口边界
- 更新仓库根 README 的 AI-First 配置接入提示与共享子集边界说明

- 补充 GFramework.Game README 中的配置系统采用约束与 raw YAML 回退路径
2026-04-30 13:23:19 +08:00
gewuyou
fdcb11c92c fix(config-tool): 收紧坏形状 schema 解析边界
- 修复 Tooling 侧 additionalProperties 仅接受 false 的共享边界校验

- 补充数组 items 与 contains 子 schema 必须显式声明 type 的拒绝逻辑

- 更新 ai-plan 恢复摘要与 JS 回归测试验证记录
2026-04-30 13:23:19 +08:00
gewuyou
e8cceac7ae docs(game): 补齐配置工具能力边界说明
- 更新 config system 与 config tool 的 reader-facing 边界说明

- 补充 additionalProperties:false、oneOf/anyOf rejection 与 raw YAML 回退路径

- 记录本批次 Tooling/Docs 收口验证与下一步
2026-04-30 13:23:19 +08:00
gewuyou
7f98cafbfa fix(config-tool): 统一 contains 与本地化提示文案
- 修复 dependentRequired 校验消息键缺失导致的隐式 undefined 文案映射

- 统一 contains 与 dependent schema 相关中文提示措辞并补齐 maxContains hint 输出

- 补充本地化与 contains 摘要测试覆盖新增文案与回归场景
2026-04-30 13:23:19 +08:00
gewuyou
3f335f19d6 docs(game): 收口配置工具说明入口
- 更新 config-tool 文档,承接 VS Code 工具能力、边界与适用场景说明

- 优化 config-system 文档,移除重复工具细节并保留系统级入口说明
2026-04-30 13:23:19 +08:00
gewuyou
13b77eb3fe fix(game-config): 显式声明闭合对象字段边界
- 修复 Runtime 与 Source Generator 对 additionalProperties 的隐式闭合对象语义,统一接受 additionalProperties:false 并拒绝其它开放对象形状

- 补充 Release 回归测试,覆盖生成器诊断与运行时 additionalProperties 边界

- 更新配置工具元数据与 README 说明,使命令、设置和当前能力描述保持一致
2026-04-30 13:23:19 +08:00
gewuyou
eddce21383 docs(ai-first-config-system): 补充 tooling lane 收口验证记录
- 更新 tracking 中的 Tooling lane 收口验证结果,补齐实际的 Release build 结论
2026-04-30 13:23:19 +08:00
gewuyou
fad391e8cf feat(config-tool): 支持对象数组内嵌对象数组编辑
- 新增对象数组编辑器对数组项内嵌对象数组的递归渲染与保存能力

- 补充嵌套对象数组表单模型与 YAML 写回回归测试

- 更新配置系统文档中的 raw YAML 回退边界说明
2026-04-30 13:23:19 +08:00
gewuyou
d6a154726c fix(game-config): 显式拒绝 oneOf 与 anyOf 组合关键字
- 修复 Runtime、Source Generator 与 Tooling 对 oneOf/anyOf 的静默接受,统一改为显式报错

- 补充 JS 与 Release 测试回归,覆盖生成器诊断和运行时拒绝路径

- 更新 ai-plan 跟踪与中文文档,明确后续默认跳过会改变生成类型形状的组合关键字
2026-04-30 13:23:19 +08:00
gewuyou
8d6fc74b3d
Merge pull request #305 from GeWuYou/feat/cqrs-optimization
Feat/Add notification publisher seam and request invoker provider for CQRS runtime
2026-04-30 13:05:41 +08:00
gewuyou
0f1e91a499 fix(cqrs): 收口PR审查遗留问题
- 修复并发 CQRS 解析测试的失败路径释放逻辑,并收敛重复 orchestration 以消除新增 analyzer warning

- 更新 generated request invoker provider 相关测试、XML 文档与 generator 注释,明确默认 runtime 的描述符预热契约

- 调整 legacy runtime alias 注册与 generated provider 注册顺序,并同步 cqrs-rewrite 跟踪文档中的 PR #305 triage 结果
2026-04-30 12:58:05 +08:00
gewuyou
0c65cd8e38 feat(cqrs): 前移请求调用器生成注册
- 新增 generated request invoker provider seam,并让 registrar 与 dispatcher 复用编译期请求调用元数据

- 扩展 CQRS source generator 发射 request invoker provider 成员与最小 request invoker 方法

- 补充 runtime 与 source-generator 回归测试,并更新 cqrs-rewrite 追踪到 RP-067
2026-04-30 12:10:25 +08:00
gewuyou
7209fdc32d docs(cqrs): 收口旧版运行时别名说明
- 更新 LegacyICqrsRuntime 兼容层说明,明确旧命名空间别名与正式 CQRS runtime seam 的边界

- 补充容器基础设施回填 legacy alias 的回归测试,并收敛相关 helper 注释

- 更新 cqrs-rewrite 跟踪与 trace,记录 RP-066 的批处理结果和验证
2026-04-30 11:38:52 +08:00
gewuyou
c1dfee3c71 test(core): 补充架构上下文CQRS懒解析回归
- 新增 PublishAsync 与 CreateStream 并发首次访问只解析一次 ICqrsRuntime 的回归测试

- 更新 cqrs-rewrite 跟踪与 trace,记录三份 Mediator 测试命名收口已完成
2026-04-30 11:28:37 +08:00
gewuyou
b015a91e57 test(cqrs): 收口 ArchitectureContext 综合测试命名
- 重命名综合测试类、命名空间与文件路径,使其与 CQRS 和 ArchitectureContext 语义一致

- 更新中文注释与局部变量命名,移除残留的 Mediator 表述且不改变测试行为

- 收窄文件内测试辅助类型可见性,避免额外暴露旧语义类型并保持项目编译通过
2026-04-30 11:26:18 +08:00
gewuyou
f44629deb3 test(cqrs): 统一架构上下文集成测试命名
- 重命名 CQRS 架构上下文集成测试文件、命名空间与测试类以移除 Mediator 语义残留

- 更新嵌套测试类型、局部变量与中文注释为 CQRS 和 ArchitectureContext 一致命名

- 补充公开测试类型与成员的 XML 文档说明而不改变测试断言行为
2026-04-30 11:25:16 +08:00
gewuyou
e1af8ac833 test(cqrs): 收口高级特性测试的CQRS命名
- 重命名高级特性测试类、命名空间与文件路径,统一到 CQRS 与 ArchitectureContext 语义

- 更新测试方法名、中文注释与日志器名称,移除残留的 Mediator 命名

- 补充当前测试文件内辅助类型的 XML 文档,保持测试行为不变
2026-04-30 11:24:22 +08:00
gewuyou
22f608eb4d feat(cqrs): 新增通知发布策略接缝
- 新增 notification publisher seam 与默认顺序发布器,保持零处理器静默完成与首错即停语义

- 调整 dispatcher、runtime factory 与测试基础设施,支持复用容器中预注册的通知发布策略

- 补充 publisher 回归测试并更新 CQRS 文档与 ai-plan 恢复点
2026-04-30 11:07:24 +08:00
gewuyou
a3fe2974f7 docs(cqrs): 归档CQRS与Mediator评估结论
- 新增 CQRS 与 Mediator 的结构化评估归档,明确生产替代完成度与设计吸收差距

- 更新 cqrs-rewrite active tracking 与 trace,提升恢复点到 RP-063 并重排后续优先级

- 补充本轮最小 Release 构建验证结果,保持 ai-plan 恢复入口与实际状态一致
2026-04-30 10:16:35 +08:00
75 changed files with 4785 additions and 517 deletions

View File

@ -26,7 +26,7 @@
| --- | --- |
| `Architectures/` `Lifecycle/` `Registries/` | `IArchitecture`、上下文、模块、服务模块、阶段监听、注册表基类与生命周期契约 |
| `Bases/` `Controller/` `Model/` `Systems/` `Utility/` `Rule/` | 组件角色接口、优先级 / key 值对象、上下文感知约束与扩展边界 |
| `Command/` `Query/` `Cqrs/` | 旧版命令 / 查询执行器接口,以及 `ICqrsRuntime` 这类新请求模型接线契约 |
| `Command/` `Query/` `Cqrs/` | 旧版命令 / 查询执行器接口,以及面向 CQRS runtime 的兼容别名入口 |
| `Events/` `Property/` `State/` `StateManagement/` | 事件总线、解绑对象、可绑定属性、状态机、Store / reducer / middleware 契约 |
| `Coroutine/` `Time/` `Pause/` `Concurrency/` | 协程状态、时间源、暂停栈、键控异步锁和统计对象 |
| `Resource/` `Pool/` `Logging/` `Localization/` | 资源句柄、对象池、日志、日志工厂、本地化表与格式化契约 |
@ -41,7 +41,7 @@
| 类型族 | 代表类型 | 阅读重点 |
| --- | --- | --- |
| `Architectures/` `Lifecycle/` `Registries/` | `IArchitecture``IArchitectureContext``IServiceModule``KeyValueRegistryBase<TKey, TValue>` | 看架构、上下文、模块装配与注册表基类边界 |
| `Command/` `Query/` `Cqrs/` | `ICommandExecutor``IAsyncQueryExecutor``ICqrsRuntime` | 看命令、查询与新请求模型的调用入口 |
| `Command/` `Query/` `Cqrs/` | `ICommandExecutor``IAsyncQueryExecutor``ICqrsRuntime` | 看旧命令 / 查询契约,以及 CQRS runtime 的兼容别名入口 |
| `Events/` `Property/` `State/` `StateManagement/` | `IEventBus``IBindableProperty<T>``IStateMachine``IStore<TState>` | 看事件分发、可绑定状态与 store 契约 |
| `Coroutine/` `Time/` `Pause/` `Concurrency/` | `IYieldInstruction``ITimeProvider``IPauseStackManager``IAsyncKeyLockManager` | 看协程、时间源、暂停栈与并发协调能力 |
| `Resource/` `Pool/` `Logging/` `Localization/` | `IResourceManager``IObjectPoolSystem``ILogger``ILocalizationManager` | 看资源、对象池、日志与本地化服务角色 |
@ -63,7 +63,11 @@
- 架构与模块入口:`IArchitecture``IArchitectureContext``IServiceModule`
- 运行时基础设施:`IIocContainer``ILogger``IResourceManager``IConfigurationManager`
- 状态与并发能力:`IStateMachine``IStore``IAsyncKeyLockManager``ITimeProvider`
- 迁移与组合边界:`ICommandExecutor``IQueryExecutor``ICqrsRuntime`
- 迁移与组合边界:`ICommandExecutor``IQueryExecutor`,以及旧命名空间下作为 compatibility alias 暴露的 `ICqrsRuntime`
`GFramework.Core.Abstractions.Cqrs.ICqrsRuntime` 当前主要用于兼容旧命名空间引用。新代码应直接依赖
`GFramework.Cqrs.Abstractions.Cqrs.ICqrsRuntime`这样可以把请求模型、handler 和 runtime seam 保持在同一套
CQRS 契约下。
## 对应文档

View File

@ -306,8 +306,6 @@ public class ArchitectureContextTests
public async Task SendRequestAsync_Should_ResolveCqrsRuntime_OnlyOnce_When_AccessedConcurrently()
{
const int workerCount = 8;
var workerStartupTimeout = TimeSpan.FromSeconds(5);
var firstResolutionTimeout = TimeSpan.FromSeconds(5);
using var startGate = new ManualResetEventSlim(false);
using var allowResolutionToComplete = new ManualResetEventSlim(false);
using var workersReady = new CountdownEvent(workerCount);
@ -339,18 +337,11 @@ public class ArchitectureContextTests
}))
.ToArray();
Assert.That(
workersReady.Wait(workerStartupTimeout),
Is.True,
"Expected all workers to be ready before releasing start gate.");
startGate.Set();
Assert.That(
SpinWait.SpinUntil(() => Volatile.Read(ref resolutionCallCount) > 0, firstResolutionTimeout),
Is.True,
"Expected at least one CQRS runtime resolution attempt.");
allowResolutionToComplete.Set();
ReleaseWorkersAfterFirstResolutionAttempt(
workersReady,
startGate,
allowResolutionToComplete,
() => Volatile.Read(ref resolutionCallCount) > 0);
var responses = await Task.WhenAll(requests);
@ -365,7 +356,187 @@ public class ArchitectureContextTests
Times.Exactly(requests.Length));
}
/// <summary>
/// 测试 CQRS runtime 在并发首次发布通知时只会从容器解析一次。
/// </summary>
[Test]
public async Task PublishAsync_Should_ResolveCqrsRuntime_OnlyOnce_When_AccessedConcurrently()
{
const int workerCount = 8;
using var startGate = new ManualResetEventSlim(false);
using var allowResolutionToComplete = new ManualResetEventSlim(false);
using var workersReady = new CountdownEvent(workerCount);
var resolutionCallCount = 0;
var runtime = new Mock<ICqrsRuntime>(MockBehavior.Strict);
var container = new Mock<IIocContainer>(MockBehavior.Strict);
runtime.Setup(mockRuntime => mockRuntime.PublishAsync(
It.IsAny<IArchitectureContext>(),
It.IsAny<TestCqrsNotification>(),
It.IsAny<CancellationToken>()))
.Returns(ValueTask.CompletedTask);
container.Setup(mockContainer => mockContainer.Get<ICqrsRuntime>())
.Returns(() =>
{
Interlocked.Increment(ref resolutionCallCount);
allowResolutionToComplete.Wait();
return runtime.Object;
});
var context = new ArchitectureContext(container.Object);
var notifications = Enumerable.Range(0, workerCount)
.Select(_ => Task.Run(async () =>
{
workersReady.Signal();
startGate.Wait();
await context.PublishAsync(new TestCqrsNotification()).ConfigureAwait(false);
}))
.ToArray();
ReleaseWorkersAfterFirstResolutionAttempt(
workersReady,
startGate,
allowResolutionToComplete,
() => Volatile.Read(ref resolutionCallCount) > 0);
await Task.WhenAll(notifications).ConfigureAwait(false);
Assert.That(resolutionCallCount, Is.EqualTo(1));
container.Verify(mockContainer => mockContainer.Get<ICqrsRuntime>(), Times.Once);
runtime.Verify(
mockRuntime => mockRuntime.PublishAsync(
It.IsAny<IArchitectureContext>(),
It.IsAny<TestCqrsNotification>(),
It.IsAny<CancellationToken>()),
Times.Exactly(notifications.Length));
}
/// <summary>
/// 测试 CQRS runtime 在并发首次创建流时只会从容器解析一次。
/// </summary>
[Test]
public async Task CreateStream_Should_ResolveCqrsRuntime_OnlyOnce_When_AccessedConcurrently()
{
const int workerCount = 8;
using var startGate = new ManualResetEventSlim(false);
using var allowResolutionToComplete = new ManualResetEventSlim(false);
using var workersReady = new CountdownEvent(workerCount);
var resolutionCallCount = 0;
var runtime = new Mock<ICqrsRuntime>(MockBehavior.Strict);
var container = new Mock<IIocContainer>(MockBehavior.Strict);
runtime.Setup(mockRuntime => mockRuntime.CreateStream(
It.IsAny<IArchitectureContext>(),
It.IsAny<TestCqrsStreamRequest>(),
It.IsAny<CancellationToken>()))
.Returns(static () => CreateTestCqrsStream());
container.Setup(mockContainer => mockContainer.Get<ICqrsRuntime>())
.Returns(() =>
{
Interlocked.Increment(ref resolutionCallCount);
allowResolutionToComplete.Wait();
return runtime.Object;
});
var context = new ArchitectureContext(container.Object);
var streamTasks = Enumerable.Range(0, workerCount)
.Select(_ => Task.Run(async () =>
{
workersReady.Signal();
startGate.Wait();
await DrainAsync(context.CreateStream(new TestCqrsStreamRequest())).ConfigureAwait(false);
}))
.ToArray();
ReleaseWorkersAfterFirstResolutionAttempt(
workersReady,
startGate,
allowResolutionToComplete,
() => Volatile.Read(ref resolutionCallCount) > 0);
await Task.WhenAll(streamTasks).ConfigureAwait(false);
Assert.That(resolutionCallCount, Is.EqualTo(1));
container.Verify(mockContainer => mockContainer.Get<ICqrsRuntime>(), Times.Once);
runtime.Verify(
mockRuntime => mockRuntime.CreateStream(
It.IsAny<IArchitectureContext>(),
It.IsAny<TestCqrsStreamRequest>(),
It.IsAny<CancellationToken>()),
Times.Exactly(streamTasks.Length));
}
/// <summary>
/// 枚举完整个测试流,确保 `CreateStream` 路径真正执行到底。
/// </summary>
/// <param name="stream">要消费的异步流。</param>
/// <returns>表示消费完成的任务。</returns>
private static async Task DrainAsync(IAsyncEnumerable<int> stream)
{
ArgumentNullException.ThrowIfNull(stream);
await foreach (var _ in stream.ConfigureAwait(false))
{
}
}
/// <summary>
/// 释放并发 worker并确保在断言失败时也能放行首次 runtime 解析。
/// </summary>
/// <param name="workersReady">用于确认 worker 已就绪的倒计时器。</param>
/// <param name="startGate">用于同时放行 worker 的门闩。</param>
/// <param name="allowResolutionToComplete">用于解除首次 runtime 解析阻塞的门闩。</param>
/// <param name="hasObservedResolutionAttempt">用于判断当前是否已观察到首次 runtime 解析尝试。</param>
private static void ReleaseWorkersAfterFirstResolutionAttempt(
CountdownEvent workersReady,
ManualResetEventSlim startGate,
ManualResetEventSlim allowResolutionToComplete,
Func<bool> hasObservedResolutionAttempt)
{
ArgumentNullException.ThrowIfNull(workersReady);
ArgumentNullException.ThrowIfNull(startGate);
ArgumentNullException.ThrowIfNull(allowResolutionToComplete);
ArgumentNullException.ThrowIfNull(hasObservedResolutionAttempt);
var workerStartupTimeout = TimeSpan.FromSeconds(5);
var firstResolutionTimeout = TimeSpan.FromSeconds(5);
Assert.That(
workersReady.Wait(workerStartupTimeout),
Is.True,
"Expected all workers to be ready before releasing start gate.");
startGate.Set();
try
{
Assert.That(
SpinWait.SpinUntil(hasObservedResolutionAttempt, firstResolutionTimeout),
Is.True,
"Expected at least one CQRS runtime resolution attempt.");
}
finally
{
allowResolutionToComplete.Set();
}
}
/// <summary>
/// 为 `CreateStream` 并发解析测试提供最小异步流。
/// </summary>
/// <returns>只包含单个元素的异步流。</returns>
private static async IAsyncEnumerable<int> CreateTestCqrsStream()
{
yield return 42;
await Task.CompletedTask.ConfigureAwait(false);
}
private sealed class TestCqrsRequest : IRequest<int>
{
}
private sealed record TestCqrsNotification : INotification;
private sealed record TestCqrsStreamRequest : IStreamRequest<int>;
}

View File

@ -4,6 +4,7 @@ using GFramework.Core.Ioc;
using GFramework.Core.Logging;
using GFramework.Core.Tests.Cqrs;
using GFramework.Core.Tests.Systems;
using GFramework.Cqrs;
using GFramework.Cqrs.Abstractions.Cqrs;
using LegacyICqrsRuntime = GFramework.Core.Abstractions.Cqrs.ICqrsRuntime;
@ -171,6 +172,30 @@ public class MicrosoftDiContainerTests
Assert.That(_container.Get<ICqrsRuntime>(), Is.SameAs(_container.Get<LegacyICqrsRuntime>()));
}
/// <summary>
/// 测试当容器里仅预注册正式 CQRS runtime seam 时,基础设施接线会补齐 legacy alias
/// 并保持新旧服务类型解析到同一实例。
/// </summary>
[Test]
public void RegisterInfrastructure_Should_Backfill_Legacy_Cqrs_Runtime_Alias_With_The_Same_Instance()
{
_container.Clear();
var runtime = CqrsRuntimeFactory.CreateRuntime(
_container,
LoggerFactoryResolver.Provider.CreateLogger("CqrsDispatcher"));
_container.Register<ICqrsRuntime>(runtime);
Assert.That(_container.Get<LegacyICqrsRuntime>(), Is.Null);
CqrsTestRuntime.RegisterInfrastructure(_container);
Assert.That(_container.GetAll<ICqrsRuntime>(), Has.Count.EqualTo(1));
Assert.That(_container.GetAll<LegacyICqrsRuntime>(), Has.Count.EqualTo(1));
Assert.That(_container.Get<ICqrsRuntime>(), Is.SameAs(runtime));
Assert.That(_container.Get<LegacyICqrsRuntime>(), Is.SameAs(runtime));
}
/// <summary>
/// 测试当没有实例时获取应返回 null 的功能
/// </summary>

View File

@ -3,6 +3,7 @@ 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;
@ -31,6 +32,8 @@ public sealed class CqrsRuntimeModule : IServiceModule
/// <summary>
/// 注册默认 CQRS runtime seam 实现。
/// 该入口会同时补齐旧命名空间下的 <c>ICqrsRuntime</c> 兼容别名,
/// 并保证新旧服务类型都解析到同一个 runtime 实例。
/// </summary>
/// <param name="container">目标依赖注入容器。</param>
public void Register(IIocContainer container)
@ -40,16 +43,31 @@ public sealed class CqrsRuntimeModule : IServiceModule
var dispatcherLogger = LoggerFactoryResolver.Provider.CreateLogger("CqrsDispatcher");
var registrarLogger = LoggerFactoryResolver.Provider.CreateLogger("DefaultCqrsHandlerRegistrar");
var registrationLogger = LoggerFactoryResolver.Provider.CreateLogger("DefaultCqrsRegistrationService");
var runtime = CqrsRuntimeFactory.CreateRuntime(container, dispatcherLogger);
var notificationPublisher = container.Get<INotificationPublisher>();
var runtime = CqrsRuntimeFactory.CreateRuntime(container, dispatcherLogger, notificationPublisher);
var registrar = CqrsRuntimeFactory.CreateHandlerRegistrar(container, registrarLogger);
container.Register(runtime);
container.Register<LegacyICqrsRuntime>((LegacyICqrsRuntime)runtime);
RegisterLegacyRuntimeAlias(container, runtime);
container.Register<ICqrsHandlerRegistrar>(registrar);
container.Register<ICqrsRegistrationService>(
CqrsRuntimeFactory.CreateRegistrationService(registrar, registrationLogger));
}
/// <summary>
/// 为旧命名空间下的 CQRS runtime 契约注册兼容别名。
/// </summary>
/// <param name="container">承载运行时实例的依赖注入容器。</param>
/// <param name="runtime">当前已创建的新 CQRS runtime 实例。</param>
/// <remarks>
/// 旧接口仍作为兼容入口保留,因此这里明确把别名注册收敛到单独 helper
/// 便于后续独立评估 alias 收口,而不混入 runtime 主体行为。
/// </remarks>
private static void RegisterLegacyRuntimeAlias(IIocContainer container, ICqrsRuntime runtime)
{
container.Register<LegacyICqrsRuntime>((LegacyICqrsRuntime)runtime);
}
/// <summary>
/// 初始化模块。
/// </summary>

View File

@ -5,11 +5,16 @@ namespace GFramework.Cqrs.SourceGenerators.Cqrs;
/// </summary>
public sealed partial class CqrsHandlerRegistryGenerator
{
private readonly record struct RequestInvokerRegistrationSpec(
string RequestTypeDisplayName,
string ResponseTypeDisplayName);
private readonly record struct HandlerRegistrationSpec(
string HandlerInterfaceDisplayName,
string ImplementationTypeDisplayName,
string HandlerInterfaceLogName,
string ImplementationLogName);
string ImplementationLogName,
RequestInvokerRegistrationSpec? RequestInvokerRegistration);
private readonly record struct ReflectedImplementationRegistrationSpec(
string HandlerInterfaceDisplayName,
@ -24,14 +29,24 @@ public sealed partial class CqrsHandlerRegistryGenerator
bool HasReflectedImplementationRegistrations,
bool HasPreciseReflectedRegistrations,
bool HasReflectionTypeLookups,
bool HasExternalAssemblyTypeLookups)
bool HasExternalAssemblyTypeLookups,
bool SupportsRequestInvokerProvider,
ImmutableArray<RequestInvokerEmissionSpec> RequestInvokerEmissions)
{
public bool RequiresRegistryAssemblyVariable =>
HasReflectedImplementationRegistrations ||
HasPreciseReflectedRegistrations ||
HasReflectionTypeLookups;
public bool HasRequestInvokerProvider => SupportsRequestInvokerProvider && !RequestInvokerEmissions.IsDefaultOrEmpty;
}
private readonly record struct RequestInvokerEmissionSpec(
string RequestTypeDisplayName,
string ResponseTypeDisplayName,
string HandlerInterfaceDisplayName,
int MethodIndex);
/// <summary>
/// 标记某条 handler 注册语句在生成阶段采用的表达策略。
/// </summary>
@ -312,5 +327,6 @@ public sealed partial class CqrsHandlerRegistryGenerator
bool GenerationEnabled,
bool SupportsNamedReflectionFallbackTypes,
bool SupportsDirectReflectionFallbackTypes,
bool SupportsMultipleReflectionFallbackAttributes);
bool SupportsMultipleReflectionFallbackAttributes,
bool SupportsRequestInvokerProvider);
}

View File

@ -25,10 +25,11 @@ public sealed partial class CqrsHandlerRegistryGenerator
/// 该方法本身不报告诊断“fallback 必需但 runtime 契约缺失”的错误由调用方在进入本方法前处理。
/// </remarks>
private static string GenerateSource(
GenerationEnvironment generationEnvironment,
IReadOnlyList<ImplementationRegistrationSpec> registrations,
ReflectionFallbackEmissionSpec reflectionFallbackEmission)
{
var sourceShape = CreateGeneratedRegistrySourceShape(registrations);
var sourceShape = CreateGeneratedRegistrySourceShape(generationEnvironment, registrations);
var builder = new StringBuilder();
AppendGeneratedSourcePreamble(builder, reflectionFallbackEmission);
AppendGeneratedRegistryType(builder, registrations, sourceShape);
@ -41,6 +42,7 @@ public sealed partial class CqrsHandlerRegistryGenerator
/// <param name="registrations">已整理并排序的 handler 注册描述。</param>
/// <returns>当前生成输出需要启用的结构分支。</returns>
private static GeneratedRegistrySourceShape CreateGeneratedRegistrySourceShape(
GenerationEnvironment generationEnvironment,
IReadOnlyList<ImplementationRegistrationSpec> registrations)
{
var hasReflectedImplementationRegistrations = registrations.Any(static registration =>
@ -52,12 +54,61 @@ public sealed partial class CqrsHandlerRegistryGenerator
var hasExternalAssemblyTypeLookups = registrations.Any(static registration =>
registration.PreciseReflectedRegistrations.Any(static preciseRegistration =>
preciseRegistration.ServiceTypeArguments.Any(ContainsExternalAssemblyTypeLookup)));
var requestInvokerEmissions = CreateRequestInvokerEmissions(
generationEnvironment.SupportsRequestInvokerProvider,
registrations);
return new GeneratedRegistrySourceShape(
hasReflectedImplementationRegistrations,
hasPreciseReflectedRegistrations,
hasReflectionTypeLookups,
hasExternalAssemblyTypeLookups);
hasExternalAssemblyTypeLookups,
generationEnvironment.SupportsRequestInvokerProvider,
requestInvokerEmissions);
}
/// <summary>
/// 从 direct handler 注册描述中提取 request invoker 发射计划。
/// </summary>
/// <param name="supportsRequestInvokerProvider">
/// 指示当前 runtime 是否同时暴露 <c>ICqrsRequestInvokerProvider</c> 与
/// <c>IEnumeratesCqrsRequestInvokerDescriptors</c> 契约;若不支持,则本方法必须返回空结果并让后续发射路径整体跳过。
/// </param>
/// <param name="registrations">已按稳定顺序整理完成的 handler 注册描述。</param>
/// <returns>
/// 由 <c>directRegistration.RequestInvokerRegistration</c> 派生出的 <see cref="RequestInvokerEmissionSpec" /> 集合。
/// <c>methodIndex</c> 按 <paramref name="registrations" /> 与其 direct registration 的遍历顺序单调递增,
/// 因而只要上游排序稳定,生成的 invoker 方法名与描述符顺序就跨运行保持稳定。
/// </returns>
/// <remarks>
/// 缺少 <c>RequestInvokerRegistration</c> 的 direct registration 会被显式跳过,而不会生成半成品 provider 成员;
/// 调用方应把“为什么没有 request invoker registration”对应的诊断留在更早的建模阶段而不是在源码发射阶段兜底。
/// </remarks>
private static ImmutableArray<RequestInvokerEmissionSpec> CreateRequestInvokerEmissions(
bool supportsRequestInvokerProvider,
IReadOnlyList<ImplementationRegistrationSpec> registrations)
{
if (!supportsRequestInvokerProvider)
return ImmutableArray<RequestInvokerEmissionSpec>.Empty;
var builder = ImmutableArray.CreateBuilder<RequestInvokerEmissionSpec>();
var methodIndex = 0;
foreach (var registration in registrations)
{
foreach (var directRegistration in registration.DirectRegistrations)
{
if (directRegistration.RequestInvokerRegistration is not { } requestInvokerRegistration)
continue;
builder.Add(new RequestInvokerEmissionSpec(
requestInvokerRegistration.RequestTypeDisplayName,
requestInvokerRegistration.ResponseTypeDisplayName,
directRegistration.HandlerInterfaceDisplayName,
methodIndex++));
}
}
return builder.ToImmutable();
}
/// <summary>
@ -160,10 +211,26 @@ public sealed partial class CqrsHandlerRegistryGenerator
builder.Append(GeneratedTypeName);
builder.Append(" : global::");
builder.Append(CqrsRuntimeNamespace);
builder.AppendLine(".ICqrsHandlerRegistry");
builder.Append(".ICqrsHandlerRegistry");
if (sourceShape.HasRequestInvokerProvider)
{
builder.Append(", global::");
builder.Append(CqrsRuntimeNamespace);
builder.Append(".ICqrsRequestInvokerProvider, global::");
builder.Append(CqrsRuntimeNamespace);
builder.Append(".IEnumeratesCqrsRequestInvokerDescriptors");
}
builder.AppendLine();
builder.AppendLine("{");
AppendRegisterMethod(builder, registrations, sourceShape);
if (sourceShape.HasRequestInvokerProvider)
{
builder.AppendLine();
AppendRequestInvokerProviderMembers(builder, sourceShape.RequestInvokerEmissions);
}
if (sourceShape.HasExternalAssemblyTypeLookups)
{
builder.AppendLine();
@ -223,6 +290,140 @@ public sealed partial class CqrsHandlerRegistryGenerator
builder.AppendLine(" }");
}
/// <summary>
/// 发射 generated registry 的 request invoker provider 成员。
/// </summary>
/// <param name="builder">生成源码构造器。</param>
/// <param name="requestInvokerEmissions">
/// 来自 <see cref="CreateRequestInvokerEmissions(bool, IReadOnlyList{ImplementationRegistrationSpec})" /> 的稳定发射计划。
/// </param>
/// <remarks>
/// 该输出包含三部分描述符数组、provider 查询方法,以及与描述符逐项对应的静态 invoker 方法。
/// 若发射计划为空,调用方应直接跳过整个 provider 分支,而不是输出空的 registry seam。
/// </remarks>
private static void AppendRequestInvokerProviderMembers(
StringBuilder builder,
ImmutableArray<RequestInvokerEmissionSpec> requestInvokerEmissions)
{
AppendRequestInvokerDescriptorArray(builder, requestInvokerEmissions);
builder.AppendLine();
AppendRequestInvokerProviderMethods(builder);
for (var index = 0; index < requestInvokerEmissions.Length; index++)
{
builder.AppendLine();
AppendRequestInvokerMethod(builder, requestInvokerEmissions[index]);
}
}
/// <summary>
/// 发射 generated registry 的 request invoker 描述符数组。
/// </summary>
/// <param name="builder">生成源码构造器。</param>
/// <param name="requestInvokerEmissions">当前要输出的 request invoker 发射计划。</param>
/// <remarks>
/// 每个条目都会把请求类型、响应类型和对应的静态 invoker 方法打包成
/// <c>CqrsRequestInvokerDescriptorEntry</c>,供 registrar 在注册阶段写入 dispatcher 的弱缓存。
/// </remarks>
private static void AppendRequestInvokerDescriptorArray(
StringBuilder builder,
ImmutableArray<RequestInvokerEmissionSpec> requestInvokerEmissions)
{
builder.AppendLine(" private static readonly global::GFramework.Cqrs.CqrsRequestInvokerDescriptorEntry[] RequestInvokerDescriptors =");
builder.AppendLine(" [");
for (var index = 0; index < requestInvokerEmissions.Length; index++)
{
var emission = requestInvokerEmissions[index];
builder.Append(" new global::");
builder.Append(CqrsRuntimeNamespace);
builder.Append(".CqrsRequestInvokerDescriptorEntry(typeof(");
builder.Append(emission.RequestTypeDisplayName);
builder.Append("), typeof(");
builder.Append(emission.ResponseTypeDisplayName);
builder.Append("), new global::");
builder.Append(CqrsRuntimeNamespace);
builder.Append(".CqrsRequestInvokerDescriptor(typeof(");
builder.Append(emission.HandlerInterfaceDisplayName);
builder.Append("), typeof(");
builder.Append(GeneratedTypeName);
builder.Append(").GetMethod(nameof(InvokeRequestHandler");
builder.Append(emission.MethodIndex);
builder.Append("), global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static)!))");
builder.AppendLine(index == requestInvokerEmissions.Length - 1 ? string.Empty : ",");
}
builder.AppendLine(" ];");
}
/// <summary>
/// 发射 generated registry 对 request invoker provider 契约的实现方法。
/// </summary>
/// <param name="builder">生成源码构造器。</param>
/// <remarks>
/// 默认 runtime 真正消费的是 <c>GetDescriptors()</c> 暴露的完整描述符集合,并在注册阶段一次性写入缓存;
/// <c>TryGetDescriptor(...)</c> 保留为显式查询接口,因此这里使用线性扫描即可保持生成代码简单且无额外字典分配。
/// </remarks>
private static void AppendRequestInvokerProviderMethods(StringBuilder builder)
{
builder.Append(" public global::System.Collections.Generic.IReadOnlyList<global::");
builder.Append(CqrsRuntimeNamespace);
builder.AppendLine(".CqrsRequestInvokerDescriptorEntry> GetDescriptors()");
builder.AppendLine(" {");
builder.AppendLine(" return RequestInvokerDescriptors;");
builder.AppendLine(" }");
builder.AppendLine();
builder.Append(" public bool TryGetDescriptor(global::System.Type requestType, global::System.Type responseType, out global::");
builder.Append(CqrsRuntimeNamespace);
builder.AppendLine(".CqrsRequestInvokerDescriptor? descriptor)");
builder.AppendLine(" {");
builder.AppendLine(" if (requestType is null)");
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(requestType));");
builder.AppendLine(" if (responseType is null)");
builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(responseType));");
builder.AppendLine();
builder.AppendLine(" foreach (var entry in RequestInvokerDescriptors)");
builder.AppendLine(" {");
builder.AppendLine(" if (entry.RequestType == requestType && entry.ResponseType == responseType)");
builder.AppendLine(" {");
builder.AppendLine(" descriptor = entry.Descriptor;");
builder.AppendLine(" return true;");
builder.AppendLine(" }");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" descriptor = null;");
builder.AppendLine(" return false;");
builder.AppendLine(" }");
}
/// <summary>
/// 为单个 request invoker 描述符发射对应的静态强类型桥接方法。
/// </summary>
/// <param name="builder">生成源码构造器。</param>
/// <param name="emission">当前要输出的 invoker 发射计划。</param>
/// <remarks>
/// 这些方法的编号与 <see cref="RequestInvokerEmissionSpec.MethodIndex" /> 一一对应,
/// dispatcher 通过描述符里的 <see cref="MethodInfo" /> 把 object 形参桥接回强类型 handler 与 request。
/// </remarks>
private static void AppendRequestInvokerMethod(StringBuilder builder, RequestInvokerEmissionSpec emission)
{
builder.Append(" private static global::System.Threading.Tasks.ValueTask<");
builder.Append(emission.ResponseTypeDisplayName);
builder.Append("> InvokeRequestHandler");
builder.Append(emission.MethodIndex);
builder.Append("(object handler, object request, global::System.Threading.CancellationToken cancellationToken)");
builder.AppendLine();
builder.AppendLine(" {");
builder.Append(" var typedHandler = (");
builder.Append(emission.HandlerInterfaceDisplayName);
builder.AppendLine(")handler;");
builder.Append(" var typedRequest = (");
builder.Append(emission.RequestTypeDisplayName);
builder.AppendLine(")request;");
builder.AppendLine(" return typedHandler.Handle(typedRequest, cancellationToken);");
builder.AppendLine(" }");
}
private static void AppendDirectRegistrations(
StringBuilder builder,
ImplementationRegistrationSpec registration)

View File

@ -15,6 +15,13 @@ public sealed partial class CqrsHandlerRegistryGenerator : IIncrementalGenerator
private const string INotificationHandlerMetadataName = $"{CqrsContractsNamespace}.INotificationHandler`1";
private const string IStreamRequestHandlerMetadataName = $"{CqrsContractsNamespace}.IStreamRequestHandler`2";
private const string ICqrsHandlerRegistryMetadataName = $"{CqrsRuntimeNamespace}.ICqrsHandlerRegistry";
private const string ICqrsRequestInvokerProviderMetadataName = $"{CqrsRuntimeNamespace}.ICqrsRequestInvokerProvider";
private const string IEnumeratesCqrsRequestInvokerDescriptorsMetadataName =
$"{CqrsRuntimeNamespace}.IEnumeratesCqrsRequestInvokerDescriptors";
private const string CqrsRequestInvokerDescriptorMetadataName =
$"{CqrsRuntimeNamespace}.CqrsRequestInvokerDescriptor";
private const string CqrsRequestInvokerDescriptorEntryMetadataName =
$"{CqrsRuntimeNamespace}.CqrsRequestInvokerDescriptorEntry";
private const string CqrsHandlerRegistryAttributeMetadataName =
$"{CqrsRuntimeNamespace}.CqrsHandlerRegistryAttribute";
@ -66,6 +73,11 @@ public sealed partial class CqrsHandlerRegistryGenerator : IIncrementalGenerator
CqrsHandlerRegistryAttributeMetadataName) is not null &&
compilation.GetTypeByMetadataName(ILoggerMetadataName) is not null &&
compilation.GetTypeByMetadataName(IServiceCollectionMetadataName) is not null;
var supportsRequestInvokerProvider =
compilation.GetTypeByMetadataName(ICqrsRequestInvokerProviderMetadataName) is not null &&
compilation.GetTypeByMetadataName(IEnumeratesCqrsRequestInvokerDescriptorsMetadataName) is not null &&
compilation.GetTypeByMetadataName(CqrsRequestInvokerDescriptorMetadataName) is not null &&
compilation.GetTypeByMetadataName(CqrsRequestInvokerDescriptorEntryMetadataName) is not null;
var stringType = compilation.GetSpecialType(SpecialType.System_String);
var typeType = compilation.GetTypeByMetadataName("System.Type");
var supportsNamedReflectionFallbackTypes = reflectionFallbackAttributeType is not null &&
@ -85,7 +97,8 @@ public sealed partial class CqrsHandlerRegistryGenerator : IIncrementalGenerator
generationEnabled,
supportsNamedReflectionFallbackTypes,
supportsDirectReflectionFallbackTypes,
supportsMultipleReflectionFallbackAttributes);
supportsMultipleReflectionFallbackAttributes,
supportsRequestInvokerProvider);
}
private static bool IsHandlerCandidate(SyntaxNode node)
@ -218,7 +231,10 @@ public sealed partial class CqrsHandlerRegistryGenerator : IIncrementalGenerator
handlerInterface.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
implementationTypeDisplayName,
GetLogDisplayName(handlerInterface),
implementationLogName));
implementationLogName,
TryCreateRequestInvokerRegistrationSpec(handlerInterface, out var requestInvokerRegistration)
? requestInvokerRegistration
: null));
return true;
}
@ -237,6 +253,34 @@ public sealed partial class CqrsHandlerRegistryGenerator : IIncrementalGenerator
return true;
}
/// <summary>
/// 当当前直接注册项属于请求处理器时,提取 request invoker provider 所需的请求/响应类型显示名。
/// </summary>
private static bool TryCreateRequestInvokerRegistrationSpec(
INamedTypeSymbol handlerInterface,
out RequestInvokerRegistrationSpec requestInvokerRegistration)
{
if (!string.Equals(
handlerInterface.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
$"global::{CqrsContractsNamespace}.IRequestHandler<TRequest, TResponse>",
StringComparison.Ordinal))
{
requestInvokerRegistration = default;
return false;
}
if (handlerInterface.TypeArguments.Length != 2)
{
requestInvokerRegistration = default;
return false;
}
requestInvokerRegistration = new RequestInvokerRegistrationSpec(
handlerInterface.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
handlerInterface.TypeArguments[1].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
return true;
}
/// <summary>
/// 执行 CQRS handler registry 生成管线的最终发射阶段,负责将候选 handler 分析结果汇总为单个
/// <c>CqrsHandlerRegistry.g.cs</c>,并在需要时附带程序集级 reflection fallback 元数据。
@ -296,7 +340,7 @@ public sealed partial class CqrsHandlerRegistryGenerator : IIncrementalGenerator
context.AddSource(
HintName,
GenerateSource(registrations, reflectionFallbackEmission));
GenerateSource(generationEnvironment, registrations, reflectionFallbackEmission));
}
/// <summary>

View File

@ -11,10 +11,13 @@ using GFramework.Core.Query;
using GFramework.Cqrs.Abstractions.Cqrs;
using ICommand = GFramework.Core.Abstractions.Command.ICommand;
namespace GFramework.Cqrs.Tests.Mediator;
namespace GFramework.Cqrs.Tests.Cqrs;
/// <summary>
/// 覆盖 <see cref="ArchitectureContext" /> 在 CQRS 请求、通知、流式处理和传统总线共存场景下的综合行为。
/// </summary>
[TestFixture]
public class MediatorComprehensiveTests
public sealed class ArchitectureContextComprehensiveTests
{
/// <summary>
/// 测试初始化方法,在每个测试方法执行前运行。
@ -29,9 +32,9 @@ public class MediatorComprehensiveTests
var loggerField = typeof(MicrosoftDiContainer).GetField("_logger",
BindingFlags.NonPublic | BindingFlags.Instance);
loggerField?.SetValue(_container,
LoggerFactoryResolver.Provider.CreateLogger(nameof(MediatorComprehensiveTests)));
LoggerFactoryResolver.Provider.CreateLogger(nameof(ArchitectureContextComprehensiveTests)));
// 注册基础服务Legacy CQRS
// 注册传统 CQRS 基础服务,验证其与 ArchitectureContext 请求管线可以共存。
_eventBus = new EventBus();
_commandBus = new CommandExecutor();
_queryBus = new QueryExecutor();
@ -46,7 +49,7 @@ public class MediatorComprehensiveTests
CqrsTestRuntime.RegisterHandlers(
_container,
typeof(MediatorComprehensiveTests).Assembly,
typeof(ArchitectureContextComprehensiveTests).Assembly,
typeof(ArchitectureContext).Assembly);
_container.Freeze();
@ -416,306 +419,304 @@ public class MediatorComprehensiveTests
Assert.That(legacyCommand.Executed, Is.True);
// 使用自有 CQRS 方式
var mediatorCommand = new TestCommandWithResult { ResultValue = 999 };
var result = await _context.SendAsync(mediatorCommand).ConfigureAwait(false);
var cqrsCommand = new TestCommandWithResult { ResultValue = 999 };
var result = await _context.SendAsync(cqrsCommand).ConfigureAwait(false);
Assert.That(result, Is.EqualTo(999));
// 验证两者可以同时工作
Assert.That(legacyCommand.Executed, Is.True);
Assert.That(result, Is.EqualTo(999));
}
#region Advanced Test Classes for CQRS Features
#region ArchitectureContext CQRS Test Helpers
public sealed record TestLongRunningRequest : IRequest<string>
{
public int DelayMs { get; init; }
}
public sealed class TestLongRunningRequestHandler : IRequestHandler<TestLongRunningRequest, string>
{
public async ValueTask<string> Handle(TestLongRunningRequest request, CancellationToken cancellationToken)
private sealed record TestLongRunningRequest : IRequest<string>
{
await Task.Delay(request.DelayMs, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return "Completed";
public int DelayMs { get; init; }
}
}
public sealed record TestLongStreamRequest : IStreamRequest<int>
{
public int ItemCount { get; init; }
}
public sealed class TestLongStreamRequestHandler : IStreamRequestHandler<TestLongStreamRequest, int>
{
public async IAsyncEnumerable<int> Handle(
TestLongStreamRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
private sealed class TestLongRunningRequestHandler : IRequestHandler<TestLongRunningRequest, string>
{
for (int i = 0; i < request.ItemCount; i++)
public async ValueTask<string> Handle(TestLongRunningRequest request, CancellationToken cancellationToken)
{
await Task.Delay(request.DelayMs, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
yield return i;
await Task.Delay(10, cancellationToken).ConfigureAwait(false); // 模拟处理延迟
return "Completed";
}
}
}
public sealed record TestFaultyRequest : IRequest<string>;
public sealed class TestFaultyRequestHandler : IRequestHandler<TestFaultyRequest, string>
{
public ValueTask<string> Handle(TestFaultyRequest request, CancellationToken cancellationToken)
private sealed record TestLongStreamRequest : IStreamRequest<int>
{
throw new InvalidOperationException("Handler failed intentionally");
public int ItemCount { get; init; }
}
}
public class SharedData
{
public int Value { get; set; }
}
public sealed record TestModifyDataCommand : IRequest<Unit>
{
public SharedData Data { get; init; } = null!;
public int Value { get; init; }
}
public sealed class TestModifyDataCommandHandler : IRequestHandler<TestModifyDataCommand, Unit>
{
public ValueTask<Unit> Handle(TestModifyDataCommand request, CancellationToken cancellationToken)
private sealed class TestLongStreamRequestHandler : IStreamRequestHandler<TestLongStreamRequest, int>
{
request.Data.Value += request.Value;
return ValueTask.FromResult(Unit.Value);
}
}
public sealed record TestCachingQuery : IRequest<string>
{
public string Key { get; init; } = string.Empty;
public IDictionary<string, string> Cache { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
}
public sealed class TestCachingQueryHandler : IRequestHandler<TestCachingQuery, string>
{
public ValueTask<string> Handle(TestCachingQuery request, CancellationToken cancellationToken)
{
if (request.Cache.TryGetValue(request.Key, out var cachedValue))
public async IAsyncEnumerable<int> Handle(
TestLongStreamRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
return new ValueTask<string>(cachedValue);
for (int i = 0; i < request.ItemCount; i++)
{
cancellationToken.ThrowIfCancellationRequested();
yield return i;
await Task.Delay(10, cancellationToken).ConfigureAwait(false); // 模拟处理延迟
}
}
var newValue = $"Value_for_{request.Key}";
request.Cache[request.Key] = newValue;
return new ValueTask<string>(newValue);
}
}
public sealed record TestOrderedNotification : INotification
{
public int Order { get; init; }
public string Message { get; init; } = string.Empty;
}
private sealed record TestFaultyRequest : IRequest<string>;
public sealed class TestOrderedNotificationHandler : INotificationHandler<TestOrderedNotification>
{
public static ICollection<string> ReceivedMessages { get; set; } = new List<string>();
public ValueTask Handle(TestOrderedNotification notification, CancellationToken cancellationToken)
private sealed class TestFaultyRequestHandler : IRequestHandler<TestFaultyRequest, string>
{
ReceivedMessages.Add(notification.Message);
return ValueTask.CompletedTask;
}
}
// 额外的通知处理器来测试多处理器场景
public sealed class TestNotificationHandler2 : INotificationHandler<TestNotification>
{
public static string? LastReceivedMessage { get; set; }
public ValueTask Handle(TestNotification notification, CancellationToken cancellationToken)
{
LastReceivedMessage = notification.Message;
return ValueTask.CompletedTask;
}
}
public sealed class TestNotificationHandler3 : INotificationHandler<TestNotification>
{
public static string? LastReceivedMessage { get; set; }
public ValueTask Handle(TestNotification notification, CancellationToken cancellationToken)
{
LastReceivedMessage = notification.Message;
return ValueTask.CompletedTask;
}
}
public sealed record TestFilterStreamRequest : IStreamRequest<int>
{
public int[] Values { get; init; } = [];
public bool FilterEven { get; init; }
}
public sealed class TestFilterStreamRequestHandler : IStreamRequestHandler<TestFilterStreamRequest, int>
{
public async IAsyncEnumerable<int> Handle(
TestFilterStreamRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (var value in request.Values)
public ValueTask<string> Handle(TestFaultyRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (request.FilterEven && value % 2 != 0)
continue;
yield return value;
await Task.Yield();
throw new InvalidOperationException("Handler failed intentionally");
}
}
}
public sealed record TestValidatedCommand : IRequest<Unit>
{
public string Name { get; init; } = string.Empty;
}
public sealed class TestValidatedCommandHandler : IRequestHandler<TestValidatedCommand, Unit>
{
public ValueTask<Unit> Handle(TestValidatedCommand request, CancellationToken cancellationToken)
private sealed class SharedData
{
if (string.IsNullOrWhiteSpace(request.Name))
public int Value { get; set; }
}
private sealed record TestModifyDataCommand : IRequest<Unit>
{
public SharedData Data { get; init; } = null!;
public int Value { get; init; }
}
private sealed class TestModifyDataCommandHandler : IRequestHandler<TestModifyDataCommand, Unit>
{
public ValueTask<Unit> Handle(TestModifyDataCommand request, CancellationToken cancellationToken)
{
throw new ArgumentException("Name cannot be empty.", nameof(request));
request.Data.Value += request.Value;
return ValueTask.FromResult(Unit.Value);
}
return ValueTask.FromResult(Unit.Value);
}
}
// 传统命令用于共存测试
public class TestLegacyCommand : ICommand
{
public bool Executed { get; private set; }
public void Execute()
{
Executed = true;
}
public void SetContext(IArchitectureContext context)
private sealed record TestCachingQuery : IRequest<string>
{
// 不需要实现
public string Key { get; init; } = string.Empty;
public IDictionary<string, string> Cache { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
}
public IArchitectureContext GetContext()
private sealed class TestCachingQueryHandler : IRequestHandler<TestCachingQuery, string>
{
return null!;
}
}
#endregion
#region Test Classes - CQRS Runtime
// ✅ 这些类使用自有 CQRS IRequest
public sealed record TestRequest : IRequest<int>
{
public int Value { get; init; }
}
public sealed record TestCommand : IRequest<Unit>
{
public bool ShouldExecute { get; init; }
public bool Executed { get; set; }
}
public sealed record TestCommandWithResult : IRequest<int>
{
public int ResultValue { get; init; }
}
public sealed record TestQuery : IRequest<string>
{
public string QueryResult { get; init; } = string.Empty;
}
public sealed record TestNotification : INotification
{
public string Message { get; init; } = string.Empty;
}
public sealed record TestStreamRequest : IStreamRequest<int>
{
public int[] Values { get; init; } = [];
}
// ✅ 这些 Handler 使用自有 CQRS IRequestHandler
public sealed class TestRequestHandler : IRequestHandler<TestRequest, int>
{
public ValueTask<int> Handle(TestRequest request, CancellationToken cancellationToken)
{
return new ValueTask<int>(request.Value);
}
}
public sealed class TestCommandHandler : IRequestHandler<TestCommand, Unit>
{
public ValueTask<Unit> Handle(TestCommand request, CancellationToken cancellationToken)
{
if (request.ShouldExecute)
public ValueTask<string> Handle(TestCachingQuery request, CancellationToken cancellationToken)
{
request.Executed = true;
if (request.Cache.TryGetValue(request.Key, out var cachedValue))
{
return new ValueTask<string>(cachedValue);
}
var newValue = $"Value_for_{request.Key}";
request.Cache[request.Key] = newValue;
return new ValueTask<string>(newValue);
}
return ValueTask.FromResult(Unit.Value);
}
}
public sealed class TestCommandWithResultHandler : IRequestHandler<TestCommandWithResult, int>
{
public ValueTask<int> Handle(TestCommandWithResult request, CancellationToken cancellationToken)
private sealed record TestOrderedNotification : INotification
{
return new ValueTask<int>(request.ResultValue);
public int Order { get; init; }
public string Message { get; init; } = string.Empty;
}
}
public sealed class TestQueryHandler : IRequestHandler<TestQuery, string>
{
public ValueTask<string> Handle(TestQuery request, CancellationToken cancellationToken)
private sealed class TestOrderedNotificationHandler : INotificationHandler<TestOrderedNotification>
{
return new ValueTask<string>(request.QueryResult);
}
}
public static ICollection<string> ReceivedMessages { get; set; } = new List<string>();
public sealed class TestNotificationHandler : INotificationHandler<TestNotification>
{
public static string? LastReceivedMessage { get; set; }
public ValueTask Handle(TestNotification notification, CancellationToken cancellationToken)
{
LastReceivedMessage = notification.Message;
return ValueTask.CompletedTask;
}
}
public sealed class TestStreamRequestHandler : IStreamRequestHandler<TestStreamRequest, int>
{
public async IAsyncEnumerable<int> Handle(
TestStreamRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (var value in request.Values)
public ValueTask Handle(TestOrderedNotification notification, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
yield return value;
await Task.Yield();
ReceivedMessages.Add(notification.Message);
return ValueTask.CompletedTask;
}
}
}
#endregion
// 额外的通知处理器用于验证多处理器通知分发场景。
private sealed class TestNotificationHandler2 : INotificationHandler<TestNotification>
{
public static string? LastReceivedMessage { get; set; }
public ValueTask Handle(TestNotification notification, CancellationToken cancellationToken)
{
LastReceivedMessage = notification.Message;
return ValueTask.CompletedTask;
}
}
private sealed class TestNotificationHandler3 : INotificationHandler<TestNotification>
{
public static string? LastReceivedMessage { get; set; }
public ValueTask Handle(TestNotification notification, CancellationToken cancellationToken)
{
LastReceivedMessage = notification.Message;
return ValueTask.CompletedTask;
}
}
private sealed record TestFilterStreamRequest : IStreamRequest<int>
{
public int[] Values { get; init; } = [];
public bool FilterEven { get; init; }
}
private sealed class TestFilterStreamRequestHandler : IStreamRequestHandler<TestFilterStreamRequest, int>
{
public async IAsyncEnumerable<int> Handle(
TestFilterStreamRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (var value in request.Values)
{
cancellationToken.ThrowIfCancellationRequested();
if (request.FilterEven && value % 2 != 0)
continue;
yield return value;
await Task.Yield();
}
}
}
private sealed record TestValidatedCommand : IRequest<Unit>
{
public string Name { get; init; } = string.Empty;
}
private sealed class TestValidatedCommandHandler : IRequestHandler<TestValidatedCommand, Unit>
{
public ValueTask<Unit> Handle(TestValidatedCommand request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new ArgumentException("Name cannot be empty.", nameof(request));
}
return ValueTask.FromResult(Unit.Value);
}
}
// 传统命令用于验证旧命令总线与 ArchitectureContext 的 CQRS API 可以同时工作。
private sealed class TestLegacyCommand : ICommand
{
public bool Executed { get; private set; }
public void Execute()
{
Executed = true;
}
public void SetContext(IArchitectureContext context)
{
// 该测试只验证传统命令是否被执行,不依赖上下文回填。
}
public IArchitectureContext GetContext()
{
return null!;
}
}
#endregion
#region ArchitectureContext CQRS Runtime Types
private sealed record TestRequest : IRequest<int>
{
public int Value { get; init; }
}
private sealed record TestCommand : IRequest<Unit>
{
public bool ShouldExecute { get; init; }
public bool Executed { get; set; }
}
private sealed record TestCommandWithResult : IRequest<int>
{
public int ResultValue { get; init; }
}
private sealed record TestQuery : IRequest<string>
{
public string QueryResult { get; init; } = string.Empty;
}
private sealed record TestNotification : INotification
{
public string Message { get; init; } = string.Empty;
}
private sealed record TestStreamRequest : IStreamRequest<int>
{
public int[] Values { get; init; } = [];
}
private sealed class TestRequestHandler : IRequestHandler<TestRequest, int>
{
public ValueTask<int> Handle(TestRequest request, CancellationToken cancellationToken)
{
return new ValueTask<int>(request.Value);
}
}
private sealed class TestCommandHandler : IRequestHandler<TestCommand, Unit>
{
public ValueTask<Unit> Handle(TestCommand request, CancellationToken cancellationToken)
{
if (request.ShouldExecute)
{
request.Executed = true;
}
return ValueTask.FromResult(Unit.Value);
}
}
private sealed class TestCommandWithResultHandler : IRequestHandler<TestCommandWithResult, int>
{
public ValueTask<int> Handle(TestCommandWithResult request, CancellationToken cancellationToken)
{
return new ValueTask<int>(request.ResultValue);
}
}
private sealed class TestQueryHandler : IRequestHandler<TestQuery, string>
{
public ValueTask<string> Handle(TestQuery request, CancellationToken cancellationToken)
{
return new ValueTask<string>(request.QueryResult);
}
}
private sealed class TestNotificationHandler : INotificationHandler<TestNotification>
{
public static string? LastReceivedMessage { get; set; }
public ValueTask Handle(TestNotification notification, CancellationToken cancellationToken)
{
LastReceivedMessage = notification.Message;
return ValueTask.CompletedTask;
}
}
private sealed class TestStreamRequestHandler : IStreamRequestHandler<TestStreamRequest, int>
{
public async IAsyncEnumerable<int> Handle(
TestStreamRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (var value in request.Values)
{
cancellationToken.ThrowIfCancellationRequested();
yield return value;
await Task.Yield();
}
}
}
#endregion
}

View File

@ -4,15 +4,20 @@ using GFramework.Core.Ioc;
using GFramework.Core.Logging;
using GFramework.Cqrs.Abstractions.Cqrs;
namespace GFramework.Cqrs.Tests.Mediator;
namespace GFramework.Cqrs.Tests.Cqrs;
/// <summary>
/// Mediator高级特性专项测试
/// 专注于测试Mediator框架的高级功能和边界场景
/// 验证 CQRS 请求通过 <see cref="ArchitectureContext" /> 分发时的高级行为与边界场景。
/// </summary>
[TestFixture]
public class MediatorAdvancedFeaturesTests
internal sealed class CqrsArchitectureContextAdvancedFeaturesTests
{
private MicrosoftDiContainer? _container;
private ArchitectureContext? _context;
/// <summary>
/// 初始化测试容器、日志器和 CQRS 处理器注册表。
/// </summary>
[SetUp]
public void SetUp()
{
@ -23,17 +28,20 @@ public class MediatorAdvancedFeaturesTests
var loggerField = typeof(MicrosoftDiContainer).GetField("_logger",
BindingFlags.NonPublic | BindingFlags.Instance);
loggerField?.SetValue(_container,
LoggerFactoryResolver.Provider.CreateLogger(nameof(MediatorAdvancedFeaturesTests)));
LoggerFactoryResolver.Provider.CreateLogger(nameof(CqrsArchitectureContextAdvancedFeaturesTests)));
CqrsTestRuntime.RegisterHandlers(
_container,
typeof(MediatorAdvancedFeaturesTests).Assembly,
typeof(CqrsArchitectureContextAdvancedFeaturesTests).Assembly,
typeof(ArchitectureContext).Assembly);
_container.Freeze();
_context = new ArchitectureContext(_container);
}
/// <summary>
/// 释放当前测试用到的上下文和容器引用。
/// </summary>
[TearDown]
public void TearDown()
{
@ -41,11 +49,9 @@ public class MediatorAdvancedFeaturesTests
_container = null;
}
private MicrosoftDiContainer? _container;
private ArchitectureContext? _context;
/// <summary>
/// 验证请求验证逻辑会阻止无效输入继续进入 CQRS 处理流程。
/// </summary>
[Test]
public async Task Request_With_Validation_Behavior_Should_Validate_Input()
{
@ -56,7 +62,7 @@ public class MediatorAdvancedFeaturesTests
}
[Test]
public async Task Request_With_Retry_Behavior_Should_Retry_On_Failure()
public async Task Request_With_Retry_Behavior_Should_Succeed_On_First_Attempt()
{
// 由于我们没有实现实际的重试行为,简化测试逻辑
TestRetryBehavior.AttemptCount = 0;
@ -68,8 +74,11 @@ public class MediatorAdvancedFeaturesTests
Assert.That(TestRetryBehavior.AttemptCount, Is.EqualTo(1));
}
/// <summary>
/// 验证高并发 CQRS 请求可以在合理时间内全部完成。
/// </summary>
[Test]
public async Task High_Concurrency_Mediator_Requests_Should_Handle_Efficiently()
public async Task High_Concurrency_Cqrs_Requests_Should_Handle_Efficiently()
{
const int concurrentRequests = 100;
var tasks = new List<Task<int>>();
@ -93,6 +102,9 @@ public class MediatorAdvancedFeaturesTests
Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(5000)); // 5秒内完成
}
/// <summary>
/// 验证大批量请求下的内存占用不会出现明显泄漏。
/// </summary>
[Test]
public async Task Memory_Usage_Should_Remain_Stable_Under_Heavy_Load()
{
@ -120,7 +132,7 @@ public class MediatorAdvancedFeaturesTests
}
[Test]
public async Task Transient_Error_Should_Be_Handled_By_Retry_Mechanism()
public async Task Transient_Error_Request_Should_Succeed_Without_Simulated_Errors()
{
// 由于我们没有实现实际的瞬态错误处理,简化测试逻辑
TestTransientErrorHandler.ErrorCount = 0;
@ -132,6 +144,9 @@ public class MediatorAdvancedFeaturesTests
Assert.That(TestTransientErrorHandler.ErrorCount, Is.EqualTo(0));
}
/// <summary>
/// 验证断路器在持续失败后会快速拒绝后续请求。
/// </summary>
[Test]
public async Task Circuit_Breaker_Should_Prevent_Cascading_Failures()
{
@ -160,6 +175,9 @@ public class MediatorAdvancedFeaturesTests
Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(100));
}
/// <summary>
/// 验证多步 Saga 请求在全部成功时会保持一致的完成状态。
/// </summary>
[Test]
public async Task Saga_Pattern_With_Multiple_Requests_Should_Maintain_Consistency()
{
@ -182,6 +200,9 @@ public class MediatorAdvancedFeaturesTests
Assert.That(sagaData.IsCompleted, Is.True);
}
/// <summary>
/// 验证 Saga 在中途失败时会触发既有步骤的补偿逻辑。
/// </summary>
[Test]
public async Task Saga_With_Failure_Should_Rollback_Correctly()
{
@ -205,6 +226,9 @@ public class MediatorAdvancedFeaturesTests
Assert.That(sagaData.IsCompleted, Is.False);
}
/// <summary>
/// 验证请求链可以在同一架构上下文中顺序完成。
/// </summary>
[Test]
public async Task Request_Chaining_With_Dependencies_Should_Work_Correctly()
{
@ -213,8 +237,11 @@ public class MediatorAdvancedFeaturesTests
Assert.That(chainResult, Is.EqualTo("Chain completed: Step1 -> Step2 -> Step3"));
}
/// <summary>
/// 验证 CQRS 请求依赖外部服务时会正确传播取消超时。
/// </summary>
[Test]
public async Task Mediator_With_External_Service_Dependency_Should_Handle_Timeouts()
public async Task Cqrs_With_External_Service_Dependency_Should_Handle_Timeouts()
{
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
var request = new TestExternalServiceRequest { TimeoutMs = 1000 };
@ -223,8 +250,11 @@ public class MediatorAdvancedFeaturesTests
await _context!.SendRequestAsync(request, cts.Token).ConfigureAwait(false));
}
/// <summary>
/// 验证 CQRS 请求封装数据库写入时仍能保持事务语义上的可观察结果。
/// </summary>
[Test]
public async Task Mediator_With_Database_Operations_Should_Handle_Transactions()
public async Task Cqrs_With_Database_Operations_Should_Handle_Transactions()
{
var testData = new List<string>();
var request = new TestDatabaseRequest { Data = "test data", Storage = testData };
@ -236,12 +266,16 @@ public class MediatorAdvancedFeaturesTests
}
}
// 这些高级特性测试需要把一组仅供当前文件使用的辅助类型共置,避免拆成多个噪声文件。
// 这些 CQRS/ArchitectureContext 高级场景测试需要把一组仅供当前文件使用的辅助类型共置,避免拆成多个噪声文件。
#pragma warning disable MA0048
#region Advanced Test Classes
/// <summary>
/// 处理重试请求,并在达到成功条件前累积尝试次数。
/// </summary>
public sealed class TestRetryRequestHandler : IRequestHandler<TestRetryRequest, string>
{
/// <inheritdoc />
public ValueTask<string> Handle(TestRetryRequest request, CancellationToken cancellationToken)
{
TestRetryBehavior.AttemptCount++;
@ -255,8 +289,12 @@ public sealed class TestRetryRequestHandler : IRequestHandler<TestRetryRequest,
}
}
/// <summary>
/// 处理瞬态错误请求,并在配置次数内模拟失败。
/// </summary>
public sealed class TestTransientErrorRequestHandler : IRequestHandler<TestTransientErrorRequest, string>
{
/// <inheritdoc />
public ValueTask<string> Handle(TestTransientErrorRequest request, CancellationToken cancellationToken)
{
// 只有在MaxErrors > 0时才增加计数器
@ -274,8 +312,12 @@ public sealed class TestTransientErrorRequestHandler : IRequestHandler<TestTrans
}
}
/// <summary>
/// 处理断路器请求,并根据失败阈值切换断路器状态。
/// </summary>
public sealed class TestCircuitBreakerRequestHandler : IRequestHandler<TestCircuitBreakerRequest, string>
{
/// <inheritdoc />
public ValueTask<string> Handle(TestCircuitBreakerRequest request, CancellationToken cancellationToken)
{
// 检查断路器状态
@ -302,8 +344,12 @@ public sealed class TestCircuitBreakerRequestHandler : IRequestHandler<TestCircu
}
}
/// <summary>
/// 处理 Saga 步骤请求,并在失败时记录补偿步骤。
/// </summary>
public sealed class TestSagaStepRequestHandler : IRequestHandler<TestSagaStepRequest, string>
{
/// <inheritdoc />
public ValueTask<string> Handle(TestSagaStepRequest request, CancellationToken cancellationToken)
{
if (request.ShouldFail && request.Step == 2)
@ -328,8 +374,12 @@ public sealed class TestSagaStepRequestHandler : IRequestHandler<TestSagaStepReq
}
}
/// <summary>
/// 处理链式请求,并返回预定义的链路完成结果。
/// </summary>
public sealed class TestChainStartRequestHandler : IRequestHandler<TestChainStartRequest, string>
{
/// <inheritdoc />
public async ValueTask<string> Handle(TestChainStartRequest request, CancellationToken cancellationToken)
{
// 模拟链式调用
@ -338,8 +388,12 @@ public sealed class TestChainStartRequestHandler : IRequestHandler<TestChainStar
}
}
/// <summary>
/// 处理外部服务请求,并通过延时模拟超时场景。
/// </summary>
public sealed class TestExternalServiceRequestHandler : IRequestHandler<TestExternalServiceRequest, string>
{
/// <inheritdoc />
public async ValueTask<string> Handle(TestExternalServiceRequest request, CancellationToken cancellationToken)
{
await Task.Delay(request.TimeoutMs, cancellationToken).ConfigureAwait(false);
@ -348,8 +402,12 @@ public sealed class TestExternalServiceRequestHandler : IRequestHandler<TestExte
}
}
/// <summary>
/// 处理数据库请求,并把输入数据写入模拟存储集合。
/// </summary>
public sealed class TestDatabaseRequestHandler : IRequestHandler<TestDatabaseRequest, string>
{
/// <inheritdoc />
public ValueTask<string> Handle(TestDatabaseRequest request, CancellationToken cancellationToken)
{
request.Storage.Add(request.Data);
@ -357,26 +415,46 @@ public sealed class TestDatabaseRequestHandler : IRequestHandler<TestDatabaseReq
}
}
/// <summary>
/// 表示用于简单行为验证的测试请求。
/// </summary>
public sealed record TestBehaviorRequest : IRequest<string>
{
/// <summary>
/// 获取或初始化要原样返回的消息内容。
/// </summary>
public string Message { get; init; } = string.Empty;
}
/// <summary>
/// 处理简单行为请求,并回显请求消息。
/// </summary>
public sealed class TestBehaviorRequestHandler : IRequestHandler<TestBehaviorRequest, string>
{
/// <inheritdoc />
public ValueTask<string> Handle(TestBehaviorRequest request, CancellationToken cancellationToken)
{
return new ValueTask<string>(request.Message);
}
}
/// <summary>
/// 表示带输入校验约束的测试请求。
/// </summary>
public sealed record TestValidatedRequest : IRequest<string>
{
/// <summary>
/// 获取或初始化要验证的整数值。
/// </summary>
public int Value { get; init; }
}
/// <summary>
/// 处理带校验的请求,并在输入无效时抛出异常。
/// </summary>
public sealed class TestValidatedRequestHandler : IRequestHandler<TestValidatedRequest, string>
{
/// <inheritdoc />
public ValueTask<string> Handle(TestValidatedRequest request, CancellationToken cancellationToken)
{
// 验证输入
@ -389,25 +467,50 @@ public sealed class TestValidatedRequestHandler : IRequestHandler<TestValidatedR
}
}
/// <summary>
/// 表示需要在若干次失败后才能成功的重试测试请求。
/// </summary>
public sealed record TestRetryRequest : IRequest<string>
{
/// <summary>
/// 获取或初始化在返回成功前应模拟的失败次数。
/// </summary>
public int ShouldFailTimes { get; init; }
}
/// <summary>
/// 保存重试测试的共享计数状态。
/// </summary>
public static class TestRetryBehavior
{
/// <summary>
/// 获取或设置当前请求处理期间累计的尝试次数。
/// </summary>
public static int AttemptCount { get; set; }
}
// 性能测试相关类
/// <summary>
/// 表示用于并发与性能验证的测试请求。
/// </summary>
public sealed record TestPerformanceRequest : IRequest<int>
{
/// <summary>
/// 获取或初始化请求的标识值。
/// </summary>
public int Id { get; init; }
/// <summary>
/// 获取或初始化模拟处理延时,单位为毫秒。
/// </summary>
public int ProcessingTimeMs { get; init; }
}
/// <summary>
/// 处理性能请求,并在延时后返回请求标识。
/// </summary>
public sealed class TestPerformanceRequestHandler : IRequestHandler<TestPerformanceRequest, int>
{
/// <inheritdoc />
public async ValueTask<int> Handle(TestPerformanceRequest request, CancellationToken cancellationToken)
{
await Task.Delay(request.ProcessingTimeMs, cancellationToken).ConfigureAwait(false);
@ -415,13 +518,23 @@ public sealed class TestPerformanceRequestHandler : IRequestHandler<TestPerforma
}
}
/// <summary>
/// 表示用于内存占用验证的测试请求。
/// </summary>
public sealed record TestMemoryRequest : IRequest<string>
{
/// <summary>
/// 获取或初始化用于模拟负载的数据内容。
/// </summary>
public string Data { get; init; } = string.Empty;
}
/// <summary>
/// 处理内存测试请求,并在不保留额外引用的前提下制造短期分配。
/// </summary>
public sealed class TestMemoryRequestHandler : IRequestHandler<TestMemoryRequest, string>
{
/// <inheritdoc />
public ValueTask<string> Handle(TestMemoryRequest request, CancellationToken cancellationToken)
{
// 模拟内存使用
@ -430,25 +543,50 @@ public sealed class TestMemoryRequestHandler : IRequestHandler<TestMemoryRequest
}
}
// 错误处理相关类
/// <summary>
/// 保存瞬态错误测试的共享计数状态。
/// </summary>
public static class TestTransientErrorHandler
{
/// <summary>
/// 获取或设置当前已模拟的错误次数。
/// </summary>
public static int ErrorCount { get; set; }
}
/// <summary>
/// 表示用于瞬态错误场景的测试请求。
/// </summary>
public sealed record TestTransientErrorRequest : IRequest<string>
{
/// <summary>
/// 获取或初始化允许连续抛出的最大错误次数。
/// </summary>
public int MaxErrors { get; init; }
}
/// <summary>
/// 保存断路器场景的共享测试状态。
/// </summary>
public static class TestCircuitBreakerHandler
{
/// <summary>
/// 获取或设置当前累计的失败次数。
/// </summary>
public static int FailureCount { get; set; }
/// <summary>
/// 获取或设置当前累计的成功次数。
/// </summary>
public static int SuccessCount { get; set; }
/// <summary>
/// 获取或设置断路器是否已处于打开状态。
/// </summary>
public static bool CircuitOpen { get; set; }
/// <summary>
/// 重置断路器测试状态,避免静态字段在测试之间互相污染。
/// 重置断路器测试状态,避免静态字段在测试之间互相污染。
/// </summary>
public static void Reset()
{
@ -458,50 +596,87 @@ public static class TestCircuitBreakerHandler
}
}
/// <summary>
/// 表示用于断路器场景的测试请求。
/// </summary>
public sealed record TestCircuitBreakerRequest : IRequest<string>
{
/// <summary>
/// 获取或初始化当前请求是否应主动模拟失败。
/// </summary>
public bool ShouldFail { get; init; }
}
// 复杂场景相关类
/// <summary>
/// 保存 Saga 执行与补偿过程中的共享状态。
/// </summary>
public class SagaData
{
/// <summary>
/// 获取 Saga 已成功执行的步骤集合。
/// 获取 Saga 已成功执行的步骤集合。
/// </summary>
public IList<int> CompletedSteps { get; } = new List<int>();
/// <summary>
/// 获取 Saga 失败后已执行补偿的步骤集合。
/// 获取 Saga 失败后已执行补偿的步骤集合。
/// </summary>
public IList<int> CompensatedSteps { get; } = new List<int>();
/// <summary>
/// 获取或设置 Saga 是否已经完整结束。
/// 获取或设置 Saga 是否已经完整结束。
/// </summary>
public bool IsCompleted { get; set; }
}
/// <summary>
/// 表示 Saga 中单个步骤的测试请求。
/// </summary>
public sealed record TestSagaStepRequest : IRequest<string>
{
/// <summary>
/// 获取或初始化当前要执行的 Saga 步骤编号。
/// </summary>
public int Step { get; init; }
/// <summary>
/// 获取或初始化当前 Saga 使用的共享状态对象。
/// </summary>
public SagaData SagaData { get; init; } = null!;
/// <summary>
/// 获取或初始化当前步骤是否应模拟失败。
/// </summary>
public bool ShouldFail { get; init; }
}
/// <summary>
/// 表示用于链式请求场景的起始请求。
/// </summary>
public sealed record TestChainStartRequest : IRequest<string>;
/// <summary>
/// 表示依赖外部服务响应时间的测试请求。
/// </summary>
public sealed record TestExternalServiceRequest : IRequest<string>
{
/// <summary>
/// 获取或初始化模拟外部服务所需的响应时长,单位为毫秒。
/// </summary>
public int TimeoutMs { get; init; }
}
/// <summary>
/// 表示用于模拟数据库写入的测试请求。
/// </summary>
public sealed record TestDatabaseRequest : IRequest<string>
{
/// <summary>
/// 获取或初始化要写入存储集合的数据内容。
/// </summary>
public string Data { get; init; } = string.Empty;
/// <summary>
/// 获取或初始化用于模拟数据库写入的可变存储集合,同时避免泄漏具体集合实现。
/// 获取或初始化用于模拟数据库写入的可变存储集合,同时避免泄漏具体集合实现。
/// </summary>
public IList<string> Storage { get; init; } = new List<string>();
}

View File

@ -9,15 +9,17 @@ using GFramework.Core.Rule;
using GFramework.Cqrs.Abstractions.Cqrs;
using ICommand = GFramework.Core.Abstractions.Command.ICommand;
namespace GFramework.Cqrs.Tests.Mediator;
namespace GFramework.Cqrs.Tests.Cqrs;
/// <summary>
/// Mediator与架构上下文集成测试
/// 专注于测试Mediator在架构上下文中的集成和交互
/// 验证 CQRS 请求分发与 <see cref="ArchitectureContext"/> 的集成行为。
/// </summary>
[TestFixture]
public class MediatorArchitectureIntegrationTests
public class CqrsArchitectureContextIntegrationTests
{
/// <summary>
/// 初始化测试运行所需的容器、日志与架构上下文。
/// </summary>
[SetUp]
public void SetUp()
{
@ -28,21 +30,24 @@ public class MediatorArchitectureIntegrationTests
var loggerField = typeof(MicrosoftDiContainer).GetField("_logger",
BindingFlags.NonPublic | BindingFlags.Instance);
loggerField?.SetValue(_container,
LoggerFactoryResolver.Provider.CreateLogger(nameof(MediatorArchitectureIntegrationTests)));
LoggerFactoryResolver.Provider.CreateLogger(nameof(CqrsArchitectureContextIntegrationTests)));
// 注册传统CQRS组件用于混合模式测试
// 注册传统 CQRS 组件,用于验证命令总线与请求分发可并存。
_commandBus = new CommandExecutor();
_container.RegisterPlurality(_commandBus);
CqrsTestRuntime.RegisterHandlers(
_container,
typeof(MediatorArchitectureIntegrationTests).Assembly,
typeof(CqrsArchitectureContextIntegrationTests).Assembly,
typeof(ArchitectureContext).Assembly);
_container.Freeze();
_context = new ArchitectureContext(_container);
}
/// <summary>
/// 清理每个测试使用的容器与架构上下文引用。
/// </summary>
[TearDown]
public void TearDown()
{
@ -56,11 +61,13 @@ public class MediatorArchitectureIntegrationTests
private ArchitectureContext? _context;
/// <summary>
/// 验证处理器可以观察到当前的架构上下文。
/// </summary>
[Test]
public async Task Handler_Can_Access_Architecture_Context()
{
// 由于我们没有实现实际的上下文访问,简化测试逻辑
TestContextAwareHandler.LastContext = _context; // 直接设置
TestContextAwareHandler.LastContext = null;
var request = new TestContextAwareRequest();
await _context!.SendRequestAsync(request).ConfigureAwait(false);
@ -69,6 +76,9 @@ public class MediatorArchitectureIntegrationTests
Assert.That(TestContextAwareHandler.LastContext, Is.SameAs(_context));
}
/// <summary>
/// 验证处理器能够通过当前上下文参与服务解析。
/// </summary>
[Test]
public async Task Handler_Can_Retrieve_Services_From_Context()
{
@ -81,11 +91,14 @@ public class MediatorArchitectureIntegrationTests
Assert.That(TestServiceRetrievalHandler.LastRetrievedService, Is.InstanceOf<TestService>());
}
/// <summary>
/// 验证请求分发流程支持嵌套请求处理。
/// </summary>
[Test]
public async Task Handler_Can_Send_Nested_Requests()
{
TestNestedRequestHandler2.ExecutionCount = 0;
var request = new TestNestedRequest { Depth = 1 }; // 简化为深度1
var request = new TestNestedRequest { Depth = 1 };
var result = await _context!.SendRequestAsync(request).ConfigureAwait(false);
@ -93,6 +106,9 @@ public class MediatorArchitectureIntegrationTests
Assert.That(TestNestedRequestHandler2.ExecutionCount, Is.EqualTo(1));
}
/// <summary>
/// 验证请求处理期间的生命周期计数符合预期。
/// </summary>
[Test]
public async Task Context_Lifecycle_Should_Be_Properly_Managed()
{
@ -102,17 +118,20 @@ public class MediatorArchitectureIntegrationTests
var request = new TestLifecycleRequest();
await _context!.SendRequestAsync(request).ConfigureAwait(false);
// 验证生命周期管理
// 验证请求处理期间的初始化与释放计数符合预期。
Assert.That(TestLifecycleHandler.InitializationCount, Is.EqualTo(1));
Assert.That(TestLifecycleHandler.DisposalCount, Is.EqualTo(1));
}
/// <summary>
/// 验证并发请求使用的作用域彼此隔离。
/// </summary>
[Test]
public async Task Scoped_Services_Should_Be_Properly_Isolated()
{
var results = new List<int>();
// 并发执行多个请求,每个请求都应该有自己的scope
// 并发执行多个请求,每个请求都应获得独立作用域。
var tasks = Enumerable.Range(0, 10)
.Select(async i =>
{
@ -126,10 +145,13 @@ public class MediatorArchitectureIntegrationTests
await Task.WhenAll(tasks).ConfigureAwait(false);
// 验证每个请求都得到了独立的scope实例
// 验证每个请求都获得了独立的作用域结果。
Assert.That(results.Distinct().Count(), Is.EqualTo(10));
}
/// <summary>
/// 验证处理器抛出的异常会按原样传播到调用方。
/// </summary>
[Test]
public async Task Context_Error_Should_Be_Properly_Propagated()
{
@ -142,6 +164,9 @@ public class MediatorArchitectureIntegrationTests
Assert.That(ex.Data["RequestId"], Is.Not.Null);
}
/// <summary>
/// 验证处理器异常在记录后仍保持原始异常类型。
/// </summary>
[Test]
public async Task Context_Should_Handle_Handler_Exceptions_Gracefully()
{
@ -151,11 +176,14 @@ public class MediatorArchitectureIntegrationTests
Assert.ThrowsAsync<DivideByZeroException>(async () =>
await _context!.SendRequestAsync(request).ConfigureAwait(false));
// 验证异常被捕获和记录
// 验证异常被捕获并保留原始类型。
Assert.That(TestExceptionHandler.LastException, Is.Not.Null);
Assert.That(TestExceptionHandler.LastException, Is.InstanceOf<DivideByZeroException>());
}
/// <summary>
/// 验证架构上下文集成路径的额外分发开销保持在可接受范围内。
/// </summary>
[Test]
public async Task Context_Overhead_Should_Be_Minimal()
{
@ -172,11 +200,14 @@ public class MediatorArchitectureIntegrationTests
stopwatch.Stop();
var avgTime = stopwatch.ElapsedMilliseconds / (double)iterations;
// 验证上下文集成的性能开销在合理范围内
// 验证架构上下文集成的性能开销在合理范围内
Assert.That(avgTime, Is.LessThan(5.0)); // 平均每个请求不超过5ms
Console.WriteLine($"Average time with context integration: {avgTime:F2}ms");
}
/// <summary>
/// 验证缓存路径相较无缓存路径不会引入异常级别的额外开销。
/// </summary>
[Test]
public async Task Context_Caching_Should_Improve_Performance()
{
@ -184,7 +215,7 @@ public class MediatorArchitectureIntegrationTests
var uncachedTimes = new List<long>();
var cachedTimes = new List<long>();
// 测试无缓存情况
// 测试无缓存路径。
for (int i = 0; i < iterations; i++)
{
var stopwatch = Stopwatch.StartNew();
@ -194,7 +225,7 @@ public class MediatorArchitectureIntegrationTests
uncachedTimes.Add(stopwatch.ElapsedMilliseconds);
}
// 测试有缓存情况
// 测试缓存命中路径。
for (int i = 0; i < iterations; i++)
{
var stopwatch = Stopwatch.StartNew();
@ -207,11 +238,14 @@ public class MediatorArchitectureIntegrationTests
var avgUncached = uncachedTimes.Average();
var avgCached = cachedTimes.Average();
// 放宽性能要求
Assert.That(avgCached, Is.LessThan(avgUncached * 2.5)); // 缓存应该更快
// 放宽性能要求,避免环境抖动导致偶发失败。
Assert.That(avgCached, Is.LessThan(avgUncached * 2.5));
Console.WriteLine($"Uncached avg: {avgUncached:F2}ms, Cached avg: {avgCached:F2}ms");
}
/// <summary>
/// 验证并发请求访问同一架构上下文时能够安全完成。
/// </summary>
[Test]
public async Task Context_Should_Handle_Concurrent_Access_Safely()
{
@ -232,14 +266,17 @@ public class MediatorArchitectureIntegrationTests
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
// 验证所有请求都成功完成
// 验证所有请求都成功完成
Assert.That(results.Length, Is.EqualTo(concurrentRequests));
Assert.That(results.Distinct().Count(), Is.EqualTo(concurrentRequests));
// 验证执行顺序(应该大致按请求顺序)
// 验证每个请求都留下了执行痕迹。
Assert.That(executionOrder.Count, Is.EqualTo(concurrentRequests));
}
/// <summary>
/// 验证并发状态修改后共享状态仍保持一致。
/// </summary>
[Test]
public async Task Context_State_Should_Remain_Consistent_Under_Concurrency()
{
@ -259,14 +296,17 @@ public class MediatorArchitectureIntegrationTests
await Task.WhenAll(tasks).ConfigureAwait(false);
// 验证最终状态正确20个并发操作每个+1
// 验证最终状态正确
Assert.That(sharedState.Counter, Is.EqualTo(concurrentOperations));
}
/// <summary>
/// 验证架构上下文可以与现有系统协同工作。
/// </summary>
[Test]
public async Task Context_Can_Integrate_With_Existing_Systems()
{
// 测试与现有系统的集成
// 测试与现有系统的集成
TestIntegrationHandler.LastSystemCall = null;
var request = new TestIntegrationRequest();
@ -276,24 +316,30 @@ public class MediatorArchitectureIntegrationTests
Assert.That(TestIntegrationHandler.LastSystemCall, Is.EqualTo("System executed"));
}
/// <summary>
/// 验证传统命令总线与请求响应式 CQRS 分发可以共存。
/// </summary>
[Test]
public async Task Context_Can_Handle_Mixed_CQRS_Patterns()
{
// 使用传统CQRS
// 使用传统 CQRS 命令总线。
var traditionalCommand = new TestTraditionalCommand();
_context!.SendCommand(traditionalCommand);
Assert.That(traditionalCommand.Executed, Is.True); // 这应该通过
Assert.That(traditionalCommand.Executed, Is.True);
// 使用Mediator
var mediatorRequest = new TestMediatorRequest { Value = 42 };
var result = await _context.SendRequestAsync(mediatorRequest).ConfigureAwait(false);
// 使用基于请求/响应的 CQRS 分发。
var cqrsRequest = new TestCqrsRequest { Value = 42 };
var result = await _context.SendRequestAsync(cqrsRequest).ConfigureAwait(false);
Assert.That(result, Is.EqualTo(42));
// 验证两者可以共存
// 验证两种模式可以共存。
Assert.That(traditionalCommand.Executed, Is.True);
Assert.That(result, Is.EqualTo(42));
}
/// <summary>
/// 验证上下文感知处理器在每次分发时都会获得新实例。
/// </summary>
[Test]
public async Task ContextAware_Handler_Should_Use_A_Fresh_Instance_Per_Request()
{
@ -307,19 +353,37 @@ public class MediatorArchitectureIntegrationTests
Assert.That(TestPerDispatchContextAwareHandler.Contexts, Has.All.SameAs(_context));
});
}
#region Integration Test Classes
#region Integration Test Types
public sealed class TestContextAwareRequestHandler : IRequestHandler<TestContextAwareRequest, string>
/// <summary>
/// 为上下文感知请求提供静态响应的测试处理器。
/// </summary>
public sealed class TestContextAwareRequestHandler : ContextAwareBase, IRequestHandler<TestContextAwareRequest, string>
{
/// <summary>
/// 记录当前处理器观察到的架构上下文,并返回固定结果。
/// </summary>
/// <param name="request">当前测试请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>固定的测试结果。</returns>
public ValueTask<string> Handle(TestContextAwareRequest request, CancellationToken cancellationToken)
{
// 保持测试中设置的上下文不要重置为null
TestContextAwareHandler.LastContext = Context;
return new ValueTask<string>("Context accessed");
}
}
/// <summary>
/// 模拟从架构上下文中解析服务的测试处理器。
/// </summary>
public sealed class TestServiceRetrievalRequestHandler : IRequestHandler<TestServiceRetrievalRequest, string>
{
/// <summary>
/// 记录一次服务解析结果并返回固定响应。
/// </summary>
/// <param name="request">当前测试请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>固定的测试结果。</returns>
public ValueTask<string> Handle(TestServiceRetrievalRequest request, CancellationToken cancellationToken)
{
TestServiceRetrievalHandler.LastRetrievedService = new TestService();
@ -327,38 +391,75 @@ public class MediatorArchitectureIntegrationTests
}
}
/// <summary>
/// 模拟嵌套请求处理的测试处理器。
/// </summary>
public sealed class TestNestedRequestHandler : IRequestHandler<TestNestedRequest, string>
{
/// <summary>
/// 递增嵌套请求执行计数并返回深度描述。
/// </summary>
/// <param name="request">当前测试请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>包含嵌套深度的固定结果。</returns>
public ValueTask<string> Handle(TestNestedRequest request, CancellationToken cancellationToken)
{
TestNestedRequestHandler2.ExecutionCount++;
// 模拟嵌套调用
// 模拟嵌套调用
return new ValueTask<string>($"Nested execution completed at depth {request.Depth}");
}
}
/// <summary>
/// 模拟请求生命周期回调的测试处理器。
/// </summary>
public sealed class TestLifecycleRequestHandler : IRequestHandler<TestLifecycleRequest, string>
{
/// <summary>
/// 递增初始化与释放计数来模拟生命周期管理。
/// </summary>
/// <param name="request">当前测试请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>固定的测试结果。</returns>
public ValueTask<string> Handle(TestLifecycleRequest request, CancellationToken cancellationToken)
{
TestLifecycleHandler.InitializationCount++;
// 模拟一些工作
// 模拟一次完整处理流程中的工作。
TestLifecycleHandler.DisposalCount++;
return new ValueTask<string>("Lifecycle managed");
}
}
/// <summary>
/// 返回请求编号以验证作用域隔离的测试处理器。
/// </summary>
public sealed class TestScopedServiceRequestHandler : IRequestHandler<TestScopedServiceRequest, int>
{
/// <summary>
/// 返回请求携带的编号。
/// </summary>
/// <param name="request">当前测试请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>请求编号。</returns>
public ValueTask<int> Handle(TestScopedServiceRequest request, CancellationToken cancellationToken)
{
// 模拟返回请求ID
// 直接返回请求编号,便于验证不同请求的隔离性。
return new ValueTask<int>(request.RequestId);
}
}
/// <summary>
/// 抛出携带附加数据的异常以验证错误传播的测试处理器。
/// </summary>
public sealed class TestErrorPropagationRequestHandler : IRequestHandler<TestErrorPropagationRequest, string>
{
/// <summary>
/// 创建并抛出测试异常。
/// </summary>
/// <param name="request">当前测试请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>该方法总是抛出异常,不返回结果。</returns>
/// <exception cref="InvalidOperationException">始终抛出,用于验证异常透传。</exception>
public ValueTask<string> Handle(TestErrorPropagationRequest request, CancellationToken cancellationToken)
{
var ex = new InvalidOperationException("Test error from handler");
@ -367,8 +468,18 @@ public class MediatorArchitectureIntegrationTests
}
}
/// <summary>
/// 抛出算术异常以验证异常捕获行为的测试处理器。
/// </summary>
public sealed class TestExceptionRequestHandler : IRequestHandler<TestExceptionRequest, string>
{
/// <summary>
/// 创建并抛出测试异常。
/// </summary>
/// <param name="request">当前测试请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>该方法总是抛出异常,不返回结果。</returns>
/// <exception cref="DivideByZeroException">始终抛出,用于验证异常记录行为。</exception>
public ValueTask<string> Handle(TestExceptionRequest request, CancellationToken cancellationToken)
{
TestExceptionHandler.LastException = new DivideByZeroException("Test exception");
@ -376,28 +487,55 @@ public class MediatorArchitectureIntegrationTests
}
}
/// <summary>
/// 提供轻量级请求处理以测量分发开销的测试处理器。
/// </summary>
public sealed class TestPerformanceRequest2Handler : IRequestHandler<TestPerformanceRequest2, int>
{
/// <summary>
/// 返回请求编号,避免额外逻辑干扰性能测量。
/// </summary>
/// <param name="request">当前测试请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>请求编号。</returns>
public ValueTask<int> Handle(TestPerformanceRequest2 request, CancellationToken cancellationToken)
{
return new ValueTask<int>(request.Id);
}
}
/// <summary>
/// 模拟无缓存慢路径的测试处理器。
/// </summary>
public sealed class TestUncachedRequestHandler : IRequestHandler<TestUncachedRequest, int>
{
/// <summary>
/// 人为引入延迟来模拟未命中缓存的处理路径。
/// </summary>
/// <param name="request">当前测试请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>请求编号。</returns>
public async ValueTask<int> Handle(TestUncachedRequest request, CancellationToken cancellationToken)
{
// 模拟一些处理时间
// 引入固定延迟,用于构造无缓存基线。
await Task.Delay(5, cancellationToken).ConfigureAwait(false);
return request.Id;
}
}
/// <summary>
/// 使用静态缓存模拟可复用处理结果的测试处理器。
/// </summary>
public sealed class TestCachedRequestHandler : IRequestHandler<TestCachedRequest, int>
{
private static readonly ConcurrentDictionary<int, int> _cache = new();
/// <summary>
/// 优先返回缓存结果,未命中时执行较慢路径并写入缓存。
/// </summary>
/// <param name="request">当前测试请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>请求编号。</returns>
public async ValueTask<int> Handle(TestCachedRequest request, CancellationToken cancellationToken)
{
if (_cache.TryGetValue(request.Id, out var cachedValue))
@ -405,14 +543,23 @@ public class MediatorArchitectureIntegrationTests
return cachedValue;
}
// 模拟处理时间
// 模拟首次处理成本。
await Task.Delay(10, cancellationToken).ConfigureAwait(false);
return _cache.GetOrAdd(request.Id, static id => id);
}
}
/// <summary>
/// 记录并发请求执行顺序的测试处理器。
/// </summary>
public sealed class TestConcurrentRequestHandler : IRequestHandler<TestConcurrentRequest, int>
{
/// <summary>
/// 将请求编号记录到共享顺序跟踪器中。
/// </summary>
/// <param name="request">当前测试请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>请求编号。</returns>
public ValueTask<int> Handle(TestConcurrentRequest request, CancellationToken cancellationToken)
{
lock (request.OrderTracker)
@ -424,8 +571,17 @@ public class MediatorArchitectureIntegrationTests
}
}
/// <summary>
/// 修改共享状态以验证并发一致性的测试处理器。
/// </summary>
public sealed class TestStateModificationRequestHandler : IRequestHandler<TestStateModificationRequest, string>
{
/// <summary>
/// 将请求中的增量写入共享状态。
/// </summary>
/// <param name="request">当前测试请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>固定的测试结果。</returns>
public ValueTask<string> Handle(TestStateModificationRequest request, CancellationToken cancellationToken)
{
request.SharedState.IncrementBy(request.Increment);
@ -433,8 +589,17 @@ public class MediatorArchitectureIntegrationTests
}
}
/// <summary>
/// 模拟与既有系统交互的测试处理器。
/// </summary>
public sealed class TestIntegrationRequestHandler : IRequestHandler<TestIntegrationRequest, string>
{
/// <summary>
/// 记录一次系统调用并返回成功结果。
/// </summary>
/// <param name="request">当前测试请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>固定的成功结果。</returns>
public ValueTask<string> Handle(TestIntegrationRequest request, CancellationToken cancellationToken)
{
TestIntegrationHandler.LastSystemCall = "System executed";
@ -442,9 +607,18 @@ public class MediatorArchitectureIntegrationTests
}
}
public sealed class TestMediatorRequestHandler : IRequestHandler<TestMediatorRequest, int>
/// <summary>
/// 为请求/响应分发路径返回固定编号的测试处理器。
/// </summary>
public sealed class TestCqrsRequestHandler : IRequestHandler<TestCqrsRequest, int>
{
public ValueTask<int> Handle(TestMediatorRequest request, CancellationToken cancellationToken)
/// <summary>
/// 返回请求中的值,验证 CQRS 请求分发路径可用。
/// </summary>
/// <param name="request">当前测试请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>请求中携带的值。</returns>
public ValueTask<int> Handle(TestCqrsRequest request, CancellationToken cancellationToken)
{
return new ValueTask<int>(request.Value);
}
@ -461,7 +635,14 @@ public class MediatorArchitectureIntegrationTests
private static readonly List<int> TrackedInstanceIds = [];
private readonly int _instanceId = Interlocked.Increment(ref _nextInstanceId);
/// <summary>
/// 获取按请求记录的架构上下文序列。
/// </summary>
public static IReadOnlyList<IArchitectureContext?> Contexts => TrackedContexts;
/// <summary>
/// 获取已观察到的处理器实例编号序列。
/// </summary>
public static IReadOnlyList<int> SeenInstanceIds => TrackedInstanceIds;
/// <summary>
@ -488,110 +669,235 @@ public class MediatorArchitectureIntegrationTests
}
}
/// <summary>
/// 用于验证处理器可观察到当前架构上下文的测试请求。
/// </summary>
public sealed record TestContextAwareRequest : IRequest<string>;
/// <summary>
/// 保存最近一次上下文观察结果的测试状态容器。
/// </summary>
public static class TestContextAwareHandler
{
/// <summary>
/// 获取或设置最近一次测试观察到的架构上下文。
/// </summary>
public static IArchitectureContext? LastContext { get; set; }
}
/// <summary>
/// 用于验证服务解析流程的测试请求。
/// </summary>
public sealed record TestServiceRetrievalRequest : IRequest<string>;
/// <summary>
/// 保存最近一次服务解析结果的测试状态容器。
/// </summary>
public static class TestServiceRetrievalHandler
{
/// <summary>
/// 获取或设置最近一次解析得到的服务实例。
/// </summary>
public static object? LastRetrievedService { get; set; }
}
/// <summary>
/// 表示用于验证服务解析的简单测试服务。
/// </summary>
public class TestService
{
/// <summary>
/// 获取当前测试服务实例的唯一标识。
/// </summary>
public string Id { get; } = Guid.NewGuid().ToString();
}
/// <summary>
/// 用于验证嵌套请求处理的测试请求。
/// </summary>
public sealed record TestNestedRequest : IRequest<string>
{
/// <summary>
/// 获取请求携带的嵌套深度。
/// </summary>
public int Depth { get; init; }
}
/// <summary>
/// 保存嵌套请求执行计数的测试状态容器。
/// </summary>
public static class TestNestedRequestHandler2
{
/// <summary>
/// 获取或设置嵌套请求处理器的执行次数。
/// </summary>
public static int ExecutionCount { get; set; }
}
// 生命周期相关类
/// <summary>
/// 用于验证生命周期管理的测试请求。
/// </summary>
public sealed record TestLifecycleRequest : IRequest<string>;
/// <summary>
/// 保存生命周期计数的测试状态容器。
/// </summary>
public static class TestLifecycleHandler
{
/// <summary>
/// 获取或设置初始化次数。
/// </summary>
public static int InitializationCount { get; set; }
/// <summary>
/// 获取或设置释放次数。
/// </summary>
public static int DisposalCount { get; set; }
}
/// <summary>
/// 用于验证作用域隔离的测试请求。
/// </summary>
public sealed record TestScopedServiceRequest : IRequest<int>
{
/// <summary>
/// 获取请求编号。
/// </summary>
public int RequestId { get; init; }
}
// 错误处理相关类
/// <summary>
/// 用于验证异常传播的测试请求。
/// </summary>
public sealed record TestErrorPropagationRequest : IRequest<string>;
/// <summary>
/// 保存最近一次异常实例的测试状态容器。
/// </summary>
public static class TestExceptionHandler
{
/// <summary>
/// 获取或设置最近一次记录到的异常。
/// </summary>
public static Exception? LastException { get; set; }
}
/// <summary>
/// 用于验证异常记录行为的测试请求。
/// </summary>
public sealed record TestExceptionRequest : IRequest<string>;
// 性能测试相关类
/// <summary>
/// 用于验证轻量请求分发开销的测试请求。
/// </summary>
public sealed record TestPerformanceRequest2 : IRequest<int>
{
/// <summary>
/// 获取请求编号。
/// </summary>
public int Id { get; init; }
}
/// <summary>
/// 用于验证未缓存处理路径的测试请求。
/// </summary>
public sealed record TestUncachedRequest : IRequest<int>
{
/// <summary>
/// 获取请求编号。
/// </summary>
public int Id { get; init; }
}
/// <summary>
/// 用于验证缓存处理路径的测试请求。
/// </summary>
public sealed record TestCachedRequest : IRequest<int>
{
/// <summary>
/// 获取请求编号。
/// </summary>
public int Id { get; init; }
}
// 并发测试相关类
/// <summary>
/// 表示并发测试共享的可变状态。
/// </summary>
public class SharedState
{
private int _counter;
/// <summary>
/// 获取当前计数值。
/// </summary>
public int Counter => _counter;
/// <summary>
/// 以线程安全方式增加计数器。
/// </summary>
/// <param name="increment">要增加的数值。</param>
public void IncrementBy(int increment)
{
Interlocked.Add(ref _counter, increment);
}
}
/// <summary>
/// 用于验证并发请求调度安全性的测试请求。
/// </summary>
public sealed record TestConcurrentRequest : IRequest<int>
{
/// <summary>
/// 获取请求编号。
/// </summary>
public int RequestId { get; init; }
/// <summary>
/// 获取用于记录执行顺序的共享集合。
/// </summary>
public ICollection<int> OrderTracker { get; init; } = new List<int>();
}
/// <summary>
/// 用于验证并发状态修改一致性的测试请求。
/// </summary>
public sealed record TestStateModificationRequest : IRequest<string>
{
/// <summary>
/// 获取待修改的共享状态实例。
/// </summary>
public SharedState SharedState { get; init; } = null!;
/// <summary>
/// 获取要增加的计数值。
/// </summary>
public int Increment { get; init; }
}
// 集成测试相关类
/// <summary>
/// 保存最近一次系统调用结果的测试状态容器。
/// </summary>
public static class TestIntegrationHandler
{
/// <summary>
/// 获取或设置最近一次系统调用记录。
/// </summary>
public static string? LastSystemCall { get; set; }
}
/// <summary>
/// 用于验证系统集成行为的测试请求。
/// </summary>
public sealed record TestIntegrationRequest : IRequest<string>;
public sealed record TestMediatorRequest : IRequest<int>
/// <summary>
/// 用于验证请求/响应 CQRS 分发路径的测试请求。
/// </summary>
public sealed record TestCqrsRequest : IRequest<int>
{
/// <summary>
/// 获取请求返回的测试值。
/// </summary>
public int Value { get; init; }
}
@ -600,17 +906,33 @@ public class MediatorArchitectureIntegrationTests
/// </summary>
public sealed record TestPerDispatchContextAwareRequest : IRequest<int>;
// 传统命令用于混合测试
/// <summary>
/// 表示用于混合模式验证的传统命令。
/// </summary>
public class TestTraditionalCommand : ICommand
{
/// <summary>
/// 获取命令是否已执行。
/// </summary>
public bool Executed { get; private set; }
/// <summary>
/// 将命令标记为已执行。
/// </summary>
public void Execute() => Executed = true;
/// <summary>
/// 为兼容命令接口保留上下文设置入口,当前测试无需使用。
/// </summary>
/// <param name="context">命令上下文。</param>
public void SetContext(IArchitectureContext context)
{
}
/// <summary>
/// 返回命令上下文占位值,当前测试路径不会消费该结果。
/// </summary>
/// <returns>始终返回空引用占位值。</returns>
public IArchitectureContext GetContext() => null!;
}

View File

@ -0,0 +1,152 @@
using System.Reflection;
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Architectures;
using GFramework.Core.Ioc;
using GFramework.Core.Logging;
using GFramework.Cqrs.Abstractions.Cqrs;
namespace GFramework.Cqrs.Tests.Cqrs;
/// <summary>
/// 验证 generated request invoker provider 的 registrar 接线与 dispatcher 消费语义。
/// </summary>
[TestFixture]
[NonParallelizable]
internal sealed class CqrsGeneratedRequestInvokerProviderTests
{
private ILoggerFactoryProvider? _previousLoggerFactoryProvider;
/// <summary>
/// 在每个用例前重置 registrar / dispatcher 的静态缓存,避免跨用例共享状态影响断言。
/// </summary>
[SetUp]
public void SetUp()
{
_previousLoggerFactoryProvider = LoggerFactoryResolver.Provider;
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider();
ClearRegistrarCaches();
ClearDispatcherCaches();
}
/// <summary>
/// 在每个用例后清理静态缓存。
/// </summary>
[TearDown]
public void TearDown()
{
LoggerFactoryResolver.Provider = _previousLoggerFactoryProvider ?? new ConsoleLoggerFactoryProvider();
ClearRegistrarCaches();
ClearDispatcherCaches();
}
/// <summary>
/// 验证 registrar 激活 generated registry 后,会把 request invoker provider 注册到容器中。
/// </summary>
[Test]
public void RegisterHandlers_Should_Register_Generated_Request_Invoker_Provider()
{
var generatedAssembly = CreateGeneratedRequestInvokerAssembly();
var container = new MicrosoftDiContainer();
CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object);
var providers = container.GetAll<ICqrsRequestInvokerProvider>();
Assert.That(
providers.Select(static provider => provider.GetType()),
Is.EqualTo([typeof(GeneratedRequestInvokerProviderRegistry)]));
}
/// <summary>
/// 验证 dispatcher 在首次创建 request binding 时,会优先消费 generated request invoker provider。
/// </summary>
[Test]
public async Task SendAsync_Should_Use_Generated_Request_Invoker_When_Provider_Is_Registered()
{
var generatedAssembly = CreateGeneratedRequestInvokerAssembly();
var container = new MicrosoftDiContainer();
CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object);
container.Freeze();
var context = new ArchitectureContext(container);
var response = await context.SendRequestAsync(new GeneratedRequestInvokerRequest("payload"));
Assert.That(response, Is.EqualTo("generated:payload"));
}
/// <summary>
/// 创建带有 generated request invoker registry 元数据的程序集替身。
/// </summary>
private static Mock<Assembly> CreateGeneratedRequestInvokerAssembly()
{
var generatedAssembly = new Mock<Assembly>();
generatedAssembly
.SetupGet(static assembly => assembly.FullName)
.Returns("GFramework.Cqrs.Tests.Cqrs.GeneratedRequestInvokerAssembly, Version=1.0.0.0");
generatedAssembly
.Setup(static assembly => assembly.GetCustomAttributes(typeof(CqrsHandlerRegistryAttribute), false))
.Returns([new CqrsHandlerRegistryAttribute(typeof(GeneratedRequestInvokerProviderRegistry))]);
return generatedAssembly;
}
/// <summary>
/// 清空 registrar 静态缓存。
/// </summary>
private static void ClearRegistrarCaches()
{
ClearCache(GetRegistrarCacheField("AssemblyMetadataCache"));
ClearCache(GetRegistrarCacheField("RegistryActivationMetadataCache"));
ClearCache(GetRegistrarCacheField("LoadableTypesCache"));
ClearCache(GetRegistrarCacheField("SupportedHandlerInterfacesCache"));
}
/// <summary>
/// 清空 dispatcher 静态缓存。
/// </summary>
private static void ClearDispatcherCaches()
{
ClearCache(GetDispatcherCacheField("NotificationDispatchBindings"));
ClearCache(GetDispatcherCacheField("RequestDispatchBindings"));
ClearCache(GetDispatcherCacheField("StreamDispatchBindings"));
ClearCache(GetDispatcherCacheField("GeneratedRequestInvokers"));
}
/// <summary>
/// 通过反射读取 registrar 的静态缓存字段。
/// </summary>
private static object GetRegistrarCacheField(string fieldName)
{
var field = typeof(CqrsReflectionFallbackAttribute).Assembly
.GetType("GFramework.Cqrs.Internal.CqrsHandlerRegistrar", throwOnError: true)!
.GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Static);
Assert.That(field, Is.Not.Null, $"Missing registrar cache field {fieldName}.");
return field!.GetValue(null)
?? throw new InvalidOperationException($"Registrar cache field {fieldName} returned null.");
}
/// <summary>
/// 通过反射读取 dispatcher 的静态缓存字段。
/// </summary>
private static object GetDispatcherCacheField(string fieldName)
{
var field = typeof(CqrsReflectionFallbackAttribute).Assembly
.GetType("GFramework.Cqrs.Internal.CqrsDispatcher", throwOnError: true)!
.GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Static);
Assert.That(field, Is.Not.Null, $"Missing dispatcher cache field {fieldName}.");
return field!.GetValue(null)
?? throw new InvalidOperationException($"Dispatcher cache field {fieldName} returned null.");
}
/// <summary>
/// 清空目标缓存实例。
/// </summary>
private static void ClearCache(object cache)
{
_ = cache.GetType()
.GetMethod("Clear", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)!
.Invoke(cache, Array.Empty<object>());
}
}

View File

@ -0,0 +1,326 @@
using GFramework.Core.Abstractions.Architectures;
using GFramework.Core.Abstractions.Ioc;
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Architectures;
using GFramework.Core.Ioc;
using GFramework.Core.Logging;
using GFramework.Cqrs.Abstractions.Cqrs;
using GFramework.Cqrs.Cqrs;
using GFramework.Cqrs.Notification;
using GFramework.Cqrs.Tests.Logging;
namespace GFramework.Cqrs.Tests.Cqrs;
/// <summary>
/// 验证默认 CQRS runtime 的通知发布策略接缝。
/// </summary>
[TestFixture]
internal sealed class CqrsNotificationPublisherTests
{
/// <summary>
/// 验证当调用方显式提供自定义通知发布器时dispatcher 会按该发布器定义的顺序执行处理器。
/// </summary>
[Test]
public async Task PublishAsync_Should_Use_Custom_NotificationPublisher_When_Runtime_Is_Created_With_It()
{
var invocationOrder = new List<string>();
var handlers = new object[]
{
new RecordingNotificationHandler("first", invocationOrder),
new RecordingNotificationHandler("second", invocationOrder)
};
var runtime = CreateRuntime(
container =>
{
container
.Setup(currentContainer => currentContainer.GetAll(typeof(INotificationHandler<PublisherNotification>)))
.Returns(handlers);
},
new ReverseOrderNotificationPublisher());
await runtime.PublishAsync(new FakeCqrsContext(), new PublisherNotification()).ConfigureAwait(false);
Assert.That(invocationOrder, Is.EqualTo(["second", "first"]));
}
/// <summary>
/// 验证当容器在 runtime 创建前已显式注册自定义通知发布器时,
/// `RegisterInfrastructure` 这条默认接线会复用该策略。
/// </summary>
[Test]
public async Task RegisterInfrastructure_Should_Use_PreRegistered_NotificationPublisher()
{
LoggerFactoryResolver.Provider = new ConsoleLoggerFactoryProvider();
var container = new MicrosoftDiContainer();
var publisher = new TrackingNotificationPublisher();
container.Register<INotificationPublisher>(publisher);
container.Register<INotificationHandler<PublisherNotification>>(new RecordingNotificationHandler("only", []));
CqrsTestRuntime.RegisterInfrastructure(container);
container.Freeze();
var context = new ArchitectureContext(container);
await context.PublishAsync(new PublisherNotification()).ConfigureAwait(false);
Assert.That(publisher.WasCalled, Is.True);
}
/// <summary>
/// 验证自定义通知发布器通过发布上下文回调执行处理器时dispatcher 仍会在调用前注入当前架构上下文。
/// </summary>
[Test]
public async Task PublishAsync_Should_Prepare_Context_Before_Custom_Publisher_Invokes_Handler()
{
var handler = new ContextAwarePublisherTestHandler();
var architectureContext = new Mock<IArchitectureContext>(MockBehavior.Strict);
var runtime = CreateRuntime(
container =>
{
container
.Setup(currentContainer => currentContainer.GetAll(typeof(INotificationHandler<PublisherNotification>)))
.Returns([handler]);
},
new PassthroughNotificationPublisher());
await runtime.PublishAsync(architectureContext.Object, new PublisherNotification()).ConfigureAwait(false);
Assert.That(handler.ObservedContext, Is.SameAs(architectureContext.Object));
}
/// <summary>
/// 验证默认通知发布器在零处理器场景下会保持静默完成。
/// </summary>
[Test]
public void PublishAsync_Should_Complete_When_No_Handlers_Are_Registered()
{
var runtime = CreateRuntime(
container =>
{
container
.Setup(currentContainer => currentContainer.GetAll(typeof(INotificationHandler<PublisherNotification>)))
.Returns(Array.Empty<object>());
});
Assert.That(
async () => await runtime.PublishAsync(new FakeCqrsContext(), new PublisherNotification()).ConfigureAwait(false),
Throws.Nothing);
}
/// <summary>
/// 验证默认通知发布器会保持“首个异常立即中断后续处理器”的既有语义。
/// </summary>
[Test]
public void PublishAsync_Should_Stop_After_First_Handler_Exception_When_Using_Default_Publisher()
{
var trailingHandler = new RecordingNotificationHandler("second", []);
var runtime = CreateRuntime(
container =>
{
container
.Setup(currentContainer => currentContainer.GetAll(typeof(INotificationHandler<PublisherNotification>)))
.Returns(
[
new ThrowingNotificationHandler(),
trailingHandler
]);
});
Assert.That(
async () => await runtime.PublishAsync(new FakeCqrsContext(), new PublisherNotification()).ConfigureAwait(false),
Throws.InvalidOperationException.With.Message.EqualTo("boom"));
Assert.That(trailingHandler.Invoked, Is.False);
}
/// <summary>
/// 创建一个只满足当前测试最小依赖面的 dispatcher runtime。
/// </summary>
/// <param name="configureContainer">对容器 mock 的额外配置。</param>
/// <param name="notificationPublisher">要注入的自定义通知发布器;若为 <see langword="null" /> 则使用默认发布器。</param>
/// <returns>默认 CQRS runtime。</returns>
private static GFramework.Cqrs.Abstractions.Cqrs.ICqrsRuntime CreateRuntime(
Action<Mock<IIocContainer>> configureContainer,
INotificationPublisher? notificationPublisher = null)
{
var container = new Mock<IIocContainer>(MockBehavior.Strict);
var logger = new TestLogger(nameof(CqrsNotificationPublisherTests), LogLevel.Debug);
configureContainer(container);
return CqrsRuntimeFactory.CreateRuntime(container.Object, logger, notificationPublisher);
}
/// <summary>
/// 为当前测试提供最小的 CQRS 上下文标记。
/// </summary>
private sealed class FakeCqrsContext : ICqrsContext
{
}
/// <summary>
/// 为通知发布器测试提供最小通知类型。
/// </summary>
private sealed record PublisherNotification : INotification;
/// <summary>
/// 按传入顺序直接执行处理器的测试发布器。
/// </summary>
private sealed class PassthroughNotificationPublisher : INotificationPublisher
{
/// <summary>
/// 按当前处理器集合顺序执行所有处理器。
/// </summary>
/// <typeparam name="TNotification">通知类型。</typeparam>
/// <param name="context">当前发布上下文。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示通知发布完成的值任务。</returns>
public async ValueTask PublishAsync<TNotification>(
NotificationPublishContext<TNotification> context,
CancellationToken cancellationToken = default)
where TNotification : INotification
{
foreach (var handler in context.Handlers)
{
await context.InvokeHandlerAsync(handler, cancellationToken).ConfigureAwait(false);
}
}
}
/// <summary>
/// 按逆序执行处理器的测试发布器,用于证明 dispatcher 已真正委托给自定义策略。
/// </summary>
private sealed class ReverseOrderNotificationPublisher : INotificationPublisher
{
/// <summary>
/// 按逆序执行当前发布上下文中的所有处理器。
/// </summary>
/// <typeparam name="TNotification">通知类型。</typeparam>
/// <param name="context">当前发布上下文。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示通知发布完成的值任务。</returns>
public async ValueTask PublishAsync<TNotification>(
NotificationPublishContext<TNotification> context,
CancellationToken cancellationToken = default)
where TNotification : INotification
{
for (var index = context.Handlers.Count - 1; index >= 0; index--)
{
await context.InvokeHandlerAsync(context.Handlers[index], cancellationToken).ConfigureAwait(false);
}
}
}
/// <summary>
/// 仅记录自身是否被调用的测试发布器,用于验证默认接线是否已接管到自定义策略。
/// </summary>
private sealed class TrackingNotificationPublisher : INotificationPublisher
{
/// <summary>
/// 获取当前发布器是否至少执行过一次发布。
/// </summary>
public bool WasCalled { get; private set; }
/// <summary>
/// 记录当前发布器已被调用,并继续按当前顺序执行所有处理器。
/// </summary>
/// <typeparam name="TNotification">通知类型。</typeparam>
/// <param name="context">当前发布上下文。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示通知发布完成的值任务。</returns>
public async ValueTask PublishAsync<TNotification>(
NotificationPublishContext<TNotification> context,
CancellationToken cancellationToken = default)
where TNotification : INotification
{
WasCalled = true;
foreach (var handler in context.Handlers)
{
await context.InvokeHandlerAsync(handler, cancellationToken).ConfigureAwait(false);
}
}
}
/// <summary>
/// 记录调用顺序的最小通知处理器。
/// </summary>
private sealed class RecordingNotificationHandler : INotificationHandler<PublisherNotification>
{
private readonly List<string> _invocationOrder;
private readonly string _name;
/// <summary>
/// 初始化一个记录调用顺序的测试处理器。
/// </summary>
/// <param name="name">当前处理器对应的名称。</param>
/// <param name="invocationOrder">承载调用顺序的列表。</param>
public RecordingNotificationHandler(string name, List<string> invocationOrder)
{
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(invocationOrder);
_name = name;
_invocationOrder = invocationOrder;
}
/// <summary>
/// 获取当前处理器是否已被调用。
/// </summary>
public bool Invoked { get; private set; }
/// <summary>
/// 把当前处理器名称追加到调用顺序列表。
/// </summary>
/// <param name="notification">当前通知。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>已完成的值任务。</returns>
public ValueTask Handle(PublisherNotification notification, CancellationToken cancellationToken)
{
Invoked = true;
_invocationOrder.Add(_name);
return ValueTask.CompletedTask;
}
}
/// <summary>
/// 在被调用时主动抛出异常的测试处理器。
/// </summary>
private sealed class ThrowingNotificationHandler : INotificationHandler<PublisherNotification>
{
/// <summary>
/// 抛出固定异常,验证默认发布器的失败即停语义。
/// </summary>
/// <param name="notification">当前通知。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>不会成功返回。</returns>
/// <exception cref="InvalidOperationException">始终抛出,表示当前处理器失败。</exception>
public ValueTask Handle(PublisherNotification notification, CancellationToken cancellationToken)
{
throw new InvalidOperationException("boom");
}
}
/// <summary>
/// 记录 dispatcher 是否在自定义发布器路径中完成上下文注入的测试处理器。
/// </summary>
private sealed class ContextAwarePublisherTestHandler
: CqrsContextAwareHandlerBase,
INotificationHandler<PublisherNotification>
{
/// <summary>
/// 获取当前处理器在执行时观察到的架构上下文。
/// </summary>
public IArchitectureContext? ObservedContext { get; private set; }
/// <summary>
/// 记录当前执行时观察到的架构上下文。
/// </summary>
/// <param name="notification">当前通知。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>已完成的值任务。</returns>
public ValueTask Handle(PublisherNotification notification, CancellationToken cancellationToken)
{
ObservedContext = Context;
return ValueTask.CompletedTask;
}
}
}

View File

@ -0,0 +1,90 @@
using System.Reflection;
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Ioc;
using GFramework.Core.Logging;
using GFramework.Cqrs.Abstractions.Cqrs;
namespace GFramework.Cqrs.Tests.Cqrs;
/// <summary>
/// 模拟同时提供 handler 注册与 request invoker 元数据的 generated registry。
/// </summary>
internal sealed class GeneratedRequestInvokerProviderRegistry :
ICqrsHandlerRegistry,
ICqrsRequestInvokerProvider,
IEnumeratesCqrsRequestInvokerDescriptors
{
private static readonly CqrsRequestInvokerDescriptor Descriptor = new(
typeof(IRequestHandler<GeneratedRequestInvokerRequest, string>),
typeof(GeneratedRequestInvokerProviderRegistry).GetMethod(
nameof(InvokeGenerated),
BindingFlags.NonPublic | BindingFlags.Static)!);
private static readonly CqrsRequestInvokerDescriptorEntry DescriptorEntry = new(
typeof(GeneratedRequestInvokerRequest),
typeof(string),
Descriptor);
/// <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(IRequestHandler<GeneratedRequestInvokerRequest, string>),
typeof(GeneratedRequestInvokerRequestHandler));
logger.Debug(
$"Registered CQRS handler {typeof(GeneratedRequestInvokerRequestHandler).FullName} as {typeof(IRequestHandler<GeneratedRequestInvokerRequest, string>).FullName}.");
}
/// <summary>
/// 尝试返回指定 request/response 类型对对应的 generated invoker 描述符。
/// </summary>
/// <param name="requestType">请求运行时类型。</param>
/// <param name="responseType">响应运行时类型。</param>
/// <param name="descriptor">命中时返回的描述符。</param>
/// <returns>若类型对匹配当前测试请求则返回 <see langword="true" />。</returns>
public bool TryGetDescriptor(
Type requestType,
Type responseType,
out CqrsRequestInvokerDescriptor? descriptor)
{
if (requestType == typeof(GeneratedRequestInvokerRequest) && responseType == typeof(string))
{
descriptor = Descriptor;
return true;
}
descriptor = null;
return false;
}
/// <summary>
/// 返回当前 registry 暴露的全部 generated request invoker 描述符。
/// </summary>
/// <returns>单条测试 request invoker 描述符条目。</returns>
public IReadOnlyList<CqrsRequestInvokerDescriptorEntry> GetDescriptors()
{
return [DescriptorEntry];
}
/// <summary>
/// 模拟 generated request invoker 直接执行后的返回值。
/// </summary>
/// <param name="handler">当前请求处理器实例。</param>
/// <param name="request">当前测试请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>带有 generated 前缀的结果,便于断言 dispatcher 走了 provider 路径。</returns>
private static ValueTask<string> InvokeGenerated(object handler, object request, CancellationToken cancellationToken)
{
_ = handler as IRequestHandler<GeneratedRequestInvokerRequest, string>
?? throw new InvalidOperationException("Generated invoker received an incompatible handler instance.");
var typedRequest = (GeneratedRequestInvokerRequest)request;
return ValueTask.FromResult($"generated:{typedRequest.Value}");
}
}

View File

@ -0,0 +1,9 @@
using GFramework.Cqrs.Abstractions.Cqrs;
namespace GFramework.Cqrs.Tests.Cqrs;
/// <summary>
/// 用于验证 generated request invoker provider 接线的测试请求。
/// </summary>
/// <param name="Value">用于验证 generated invoker 结果拼接的请求负载。</param>
internal sealed record GeneratedRequestInvokerRequest(string Value) : IRequest<string>;

View File

@ -0,0 +1,21 @@
using GFramework.Cqrs.Abstractions.Cqrs;
namespace GFramework.Cqrs.Tests.Cqrs;
/// <summary>
/// 供 generated request invoker provider 测试使用的请求处理器。
/// </summary>
internal sealed class GeneratedRequestInvokerRequestHandler : IRequestHandler<GeneratedRequestInvokerRequest, string>
{
/// <summary>
/// 返回带有运行时处理器前缀的结果,便于和 generated invoker 自定义结果区分。
/// </summary>
/// <param name="request">当前测试请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>运行时处理器生成的响应字符串。</returns>
public ValueTask<string> Handle(GeneratedRequestInvokerRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
return ValueTask.FromResult($"runtime:{request.Value}");
}
}

View File

@ -0,0 +1,31 @@
using System.Reflection;
using GFramework.Cqrs.Abstractions.Cqrs;
namespace GFramework.Cqrs;
/// <summary>
/// 描述单个 request/response 类型对在运行时分发时需要复用的元数据。
/// </summary>
/// <param name="handlerType">当前请求处理器在容器中的服务类型。</param>
/// <param name="invokerMethod">
/// 执行单个请求处理器的开放静态方法。
/// dispatcher 会在首次创建 request binding 时,把该方法绑定成内部使用的强类型委托。
/// </param>
/// <remarks>
/// dispatcher 会继续自行构造 pipeline behavior 服务类型并负责上下文注入;
/// 该描述符只前移请求处理器服务类型与直接调用方法元数据。
/// </remarks>
public sealed class CqrsRequestInvokerDescriptor(
Type handlerType,
MethodInfo invokerMethod)
{
/// <summary>
/// 获取请求处理器在容器中的服务类型。
/// </summary>
public Type HandlerType { get; } = handlerType ?? throw new ArgumentNullException(nameof(handlerType));
/// <summary>
/// 获取执行请求处理器的开放静态方法。
/// </summary>
public MethodInfo InvokerMethod { get; } = invokerMethod ?? throw new ArgumentNullException(nameof(invokerMethod));
}

View File

@ -0,0 +1,12 @@
namespace GFramework.Cqrs;
/// <summary>
/// 描述单个 request/response 类型对与其 generated invoker 元数据之间的映射条目。
/// </summary>
/// <param name="RequestType">请求运行时类型。</param>
/// <param name="ResponseType">响应运行时类型。</param>
/// <param name="Descriptor">对应的 generated request invoker 描述符。</param>
public sealed record CqrsRequestInvokerDescriptorEntry(
Type RequestType,
Type ResponseType,
CqrsRequestInvokerDescriptor Descriptor);

View File

@ -2,6 +2,7 @@ using GFramework.Core.Abstractions.Ioc;
using GFramework.Core.Abstractions.Logging;
using GFramework.Cqrs.Abstractions.Cqrs;
using GFramework.Cqrs.Internal;
using GFramework.Cqrs.Notification;
namespace GFramework.Cqrs;
@ -24,11 +25,32 @@ public static class CqrsRuntimeFactory
/// <paramref name="container" /> 或 <paramref name="logger" /> 为 <see langword="null" />。
/// </exception>
public static ICqrsRuntime CreateRuntime(IIocContainer container, ILogger logger)
{
return CreateRuntime(container, logger, notificationPublisher: null);
}
/// <summary>
/// 创建默认 CQRS runtime 分发器,并允许调用方指定通知发布策略。
/// </summary>
/// <param name="container">目标依赖注入容器。</param>
/// <param name="logger">用于 runtime 诊断的日志器。</param>
/// <param name="notificationPublisher">可选的通知发布策略;若为 <see langword="null" /> 则使用默认顺序发布器。</param>
/// <returns>默认 CQRS runtime。</returns>
/// <exception cref="ArgumentNullException">
/// <paramref name="container" /> 或 <paramref name="logger" /> 为 <see langword="null" />。
/// </exception>
public static ICqrsRuntime CreateRuntime(
IIocContainer container,
ILogger logger,
INotificationPublisher? notificationPublisher)
{
ArgumentNullException.ThrowIfNull(container);
ArgumentNullException.ThrowIfNull(logger);
return new CqrsDispatcher(container, logger);
return new CqrsDispatcher(
container,
logger,
notificationPublisher ?? new SequentialNotificationPublisher());
}
/// <summary>

View File

@ -0,0 +1,33 @@
using GFramework.Cqrs.Abstractions.Cqrs;
namespace GFramework.Cqrs;
/// <summary>
/// 定义由源码生成器或手写注册器提供的 request invoker 元数据契约。
/// </summary>
/// <remarks>
/// 该 seam 允许运行时在首次创建 request dispatch binding 时,
/// 直接复用编译期已知的请求/响应类型映射,而不是总是通过反射闭合泛型方法生成调用委托。
/// 当当前程序集没有提供匹配项时dispatcher 仍会回退到既有的反射绑定创建路径。
/// 当前默认 runtime 通过 <see cref="IEnumeratesCqrsRequestInvokerDescriptors" /> 在注册阶段一次性读取并缓存
/// provider 暴露的描述符;<see cref="TryGetDescriptor(Type, Type, out CqrsRequestInvokerDescriptor?)" />
/// 主要用于 provider 自检、测试和显式调用场景,而不是 dispatcher 在分发热路径上的二次回调入口。
/// </remarks>
public interface ICqrsRequestInvokerProvider
{
/// <summary>
/// 尝试为指定请求/响应类型对提供运行时元数据。
/// </summary>
/// <param name="requestType">请求运行时类型。</param>
/// <param name="responseType">响应运行时类型。</param>
/// <param name="descriptor">命中时返回的 request invoker 元数据。</param>
/// <returns>若当前 provider 可处理该请求/响应类型对则返回 <see langword="true" />;否则返回 <see langword="false" />。</returns>
/// <remarks>
/// 若 provider 希望被默认 runtime 自动接线到 dispatcher 的 generated invoker 缓存中,
/// 还必须同时实现 <see cref="IEnumeratesCqrsRequestInvokerDescriptors" />,以便 registrar 在注册阶段枚举全部描述符。
/// </remarks>
bool TryGetDescriptor(
Type requestType,
Type responseType,
out CqrsRequestInvokerDescriptor? descriptor);
}

View File

@ -0,0 +1,18 @@
namespace GFramework.Cqrs;
/// <summary>
/// 为 generated request invoker provider 暴露可枚举描述符集合的内部辅助契约。
/// </summary>
/// <remarks>
/// registrar 在激活 generated registry 后,会通过该接口读取当前程序集声明的 request invoker 描述符,
/// 并把它们登记到 dispatcher 的进程级弱缓存中。
/// 该接口不改变公开分发语义,只服务于 generated invoker 元数据的运行时接线。
/// </remarks>
public interface IEnumeratesCqrsRequestInvokerDescriptors
{
/// <summary>
/// 返回当前 provider 可声明的全部 request invoker 描述符条目。
/// </summary>
/// <returns>按 provider 定义顺序枚举的描述符条目集合。</returns>
IReadOnlyList<CqrsRequestInvokerDescriptorEntry> GetDescriptors();
}

View File

@ -4,6 +4,7 @@ using GFramework.Core.Abstractions.Ioc;
using GFramework.Core.Abstractions.Logging;
using GFramework.Core.Abstractions.Rule;
using GFramework.Cqrs.Abstractions.Cqrs;
using GFramework.Cqrs.Notification;
using ICqrsRuntime = GFramework.Core.Abstractions.Cqrs.ICqrsRuntime;
namespace GFramework.Cqrs.Internal;
@ -14,8 +15,14 @@ namespace GFramework.Cqrs.Internal;
/// </summary>
internal sealed class CqrsDispatcher(
IIocContainer container,
ILogger logger) : ICqrsRuntime
ILogger logger,
INotificationPublisher notificationPublisher) : ICqrsRuntime
{
// 卸载安全的进程级缓存:当 generated registry 提供 request invoker 元数据时,
// registrar 会按请求/响应类型对把它们写入这里;若类型被卸载,条目会自然失效。
private static readonly WeakTypePairCache<GeneratedRequestInvokerMetadata>
GeneratedRequestInvokers = new();
// 卸载安全的进程级缓存:通知类型只以弱键语义保留。
// 若插件/热重载程序集中的通知类型被卸载,对应分发绑定会自然失效,下次命中时再重新计算。
private static readonly WeakKeyCache<Type, NotificationDispatchBinding>
@ -43,6 +50,10 @@ internal sealed class CqrsDispatcher(
private static readonly MethodInfo StreamHandlerInvokerMethodDefinition = typeof(CqrsDispatcher)
.GetMethod(nameof(InvokeStreamHandler), BindingFlags.NonPublic | BindingFlags.Static)!;
private readonly INotificationPublisher _notificationPublisher = notificationPublisher
?? throw new ArgumentNullException(
nameof(notificationPublisher));
/// <summary>
/// 发布通知到所有已注册处理器。
/// </summary>
@ -71,11 +82,8 @@ internal sealed class CqrsDispatcher(
return;
}
foreach (var handler in handlers)
{
PrepareHandler(handler, context);
await dispatchBinding.Invoker(handler, notification, cancellationToken).ConfigureAwait(false);
}
var publishContext = CreateNotificationPublishContext(notification, handlers, context, dispatchBinding.Invoker);
await _notificationPublisher.PublishAsync(publishContext, cancellationToken).ConfigureAwait(false);
}
/// <summary>
@ -166,6 +174,17 @@ internal sealed class CqrsDispatcher(
/// </summary>
private static RequestDispatchBinding<TResponse> CreateRequestDispatchBinding<TResponse>(Type requestType)
{
var generatedDescriptor = TryGetGeneratedRequestInvokerDescriptor<TResponse>(requestType);
if (generatedDescriptor is not null)
{
var resolvedGeneratedDescriptor = generatedDescriptor.Value;
return new RequestDispatchBinding<TResponse>(
resolvedGeneratedDescriptor.HandlerType,
typeof(IPipelineBehavior<,>).MakeGenericType(requestType, typeof(TResponse)),
resolvedGeneratedDescriptor.Invoker,
requestType);
}
return new RequestDispatchBinding<TResponse>(
typeof(IRequestHandler<,>).MakeGenericType(requestType, typeof(TResponse)),
typeof(IPipelineBehavior<,>).MakeGenericType(requestType, typeof(TResponse)),
@ -200,6 +219,48 @@ internal sealed class CqrsDispatcher(
return RequestDispatchBindingBox.Create(CreateRequestDispatchBinding<TResponse>(requestType));
}
/// <summary>
/// 尝试从容器已注册的 generated request invoker provider 中获取指定请求/响应类型对的元数据。
/// </summary>
/// <typeparam name="TResponse">当前请求响应类型。</typeparam>
/// <param name="requestType">请求运行时类型。</param>
/// <returns>命中时返回强类型化后的描述符;否则返回 <see langword="null" />。</returns>
private static RequestInvokerDescriptor<TResponse>? TryGetGeneratedRequestInvokerDescriptor<TResponse>(Type requestType)
{
return GeneratedRequestInvokers.TryGetValue(requestType, typeof(TResponse), out var metadata) &&
metadata is not null
? CreateRequestInvokerDescriptor<TResponse>(requestType, metadata)
: null;
}
/// <summary>
/// 把 provider 返回的弱类型描述符转换为 dispatcher 内部使用的强类型 request invoker 描述符。
/// </summary>
/// <typeparam name="TResponse">当前请求响应类型。</typeparam>
/// <param name="requestType">请求运行时类型。</param>
/// <param name="descriptor">provider 返回的弱类型描述符。</param>
/// <returns>可直接用于创建 request dispatch binding 的强类型描述符。</returns>
/// <exception cref="InvalidOperationException">当 provider 返回的委托签名与当前请求/响应类型对不匹配时抛出。</exception>
private static RequestInvokerDescriptor<TResponse> CreateRequestInvokerDescriptor<TResponse>(
Type requestType,
GeneratedRequestInvokerMetadata descriptor)
{
if (!descriptor.InvokerMethod.IsStatic)
{
throw new InvalidOperationException(
$"Generated CQRS request invoker provider returned a non-static invoker method for request type {requestType.FullName} and response type {typeof(TResponse).FullName}.");
}
if (Delegate.CreateDelegate(typeof(RequestInvoker<TResponse>), descriptor.InvokerMethod) is not
RequestInvoker<TResponse> invoker)
{
throw new InvalidOperationException(
$"Generated CQRS request invoker provider returned an incompatible invoker for request type {requestType.FullName} and response type {typeof(TResponse).FullName}.");
}
return new RequestInvokerDescriptor<TResponse>(descriptor.HandlerType, invoker);
}
/// <summary>
/// 为指定通知类型构造完整分发绑定,把服务类型与调用委托聚合到同一缓存项。
/// </summary>
@ -240,6 +301,50 @@ internal sealed class CqrsDispatcher(
return (NotificationInvoker)Delegate.CreateDelegate(typeof(NotificationInvoker), method);
}
/// <summary>
/// 为当前通知发布调用创建发布上下文,把处理器集合与执行入口收敛到同一对象。
/// </summary>
/// <typeparam name="TNotification">通知类型。</typeparam>
/// <param name="notification">当前通知。</param>
/// <param name="handlers">当前发布调用已解析到的处理器集合。</param>
/// <param name="context">当前 CQRS 分发上下文。</param>
/// <param name="invoker">执行单个通知处理器时复用的强类型调用委托。</param>
/// <returns>供通知发布器消费的执行上下文。</returns>
private static NotificationPublishContext<TNotification> CreateNotificationPublishContext<TNotification>(
TNotification notification,
IReadOnlyList<object> handlers,
ICqrsContext context,
NotificationInvoker invoker)
where TNotification : INotification
{
return new DelegatingNotificationPublishContext<TNotification, NotificationDispatchState>(
notification,
handlers,
new NotificationDispatchState(context, invoker),
static (handler, currentNotification, state, currentCancellationToken) =>
InvokePublishedNotificationHandlerAsync(handler, currentNotification, state, currentCancellationToken));
}
/// <summary>
/// 执行通知发布器选中的单个处理器,并在调用前注入当前分发上下文。
/// </summary>
/// <typeparam name="TNotification">通知类型。</typeparam>
/// <param name="handler">要执行的处理器实例。</param>
/// <param name="notification">当前通知。</param>
/// <param name="state">当前处理器执行所需的 dispatcher 状态。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示当前处理器执行完成的值任务。</returns>
private static ValueTask InvokePublishedNotificationHandlerAsync<TNotification>(
object handler,
TNotification notification,
NotificationDispatchState state,
CancellationToken cancellationToken)
where TNotification : INotification
{
PrepareHandler(handler, state.Context);
return state.Invoker(handler, notification!, cancellationToken);
}
/// <summary>
/// 生成流式处理器调用委托,避免每次创建流都重复反射。
/// </summary>
@ -387,6 +492,15 @@ internal sealed class CqrsDispatcher(
public NotificationInvoker Invoker { get; } = invoker;
}
/// <summary>
/// 保存通知发布器执行单个 handler 时需要复用的 dispatcher 状态。
/// </summary>
/// <param name="Context">当前 CQRS 分发上下文。</param>
/// <param name="Invoker">执行单个通知处理器的强类型调用委托。</param>
private readonly record struct NotificationDispatchState(
ICqrsContext Context,
NotificationInvoker Invoker);
/// <summary>
/// 保存流式请求分发路径所需的服务类型与调用委托。
/// 该绑定让建流热路径只需一次缓存命中即可获得解析与调用所需元数据。
@ -523,6 +637,46 @@ internal sealed class CqrsDispatcher(
private readonly record struct RequestPipelineExecutorFactoryState<TResponse>(
RequestPipelineInvoker<TResponse> PipelineInvoker);
/// <summary>
/// 记录 registrar 写入的 generated request invoker 元数据。
/// </summary>
/// <param name="HandlerType">请求处理器在容器中的服务类型。</param>
/// <param name="InvokerMethod">执行请求处理器的开放静态方法。</param>
private sealed record GeneratedRequestInvokerMetadata(
Type HandlerType,
MethodInfo InvokerMethod);
/// <summary>
/// 保存 provider 返回的请求处理器服务类型与强类型 request invoker。
/// </summary>
/// <typeparam name="TResponse">当前请求响应类型。</typeparam>
private readonly record struct RequestInvokerDescriptor<TResponse>(
Type HandlerType,
RequestInvoker<TResponse> Invoker);
/// <summary>
/// 供 registrar 在 generated registry 激活后登记 request invoker 元数据。
/// </summary>
/// <param name="requestType">请求运行时类型。</param>
/// <param name="responseType">响应运行时类型。</param>
/// <param name="descriptor">要登记的 generated request invoker 描述符。</param>
internal static void RegisterGeneratedRequestInvokerDescriptor(
Type requestType,
Type responseType,
CqrsRequestInvokerDescriptor descriptor)
{
ArgumentNullException.ThrowIfNull(requestType);
ArgumentNullException.ThrowIfNull(responseType);
ArgumentNullException.ThrowIfNull(descriptor);
_ = GeneratedRequestInvokers.GetOrAdd(
requestType,
responseType,
(_, _) => new GeneratedRequestInvokerMetadata(
descriptor.HandlerType,
descriptor.InvokerMethod));
}
/// <summary>
/// 保存单次 request pipeline 分发所需的当前 handler、behavior 列表和 continuation 缓存。
/// 该对象只存在于本次分发,不会跨请求保留容器解析出的实例。

View File

@ -239,6 +239,62 @@ internal static class CqrsHandlerRegistrar
logger.Debug(
$"Registering CQRS handlers for assembly {assemblyName} via generated registry {registry.GetType().FullName}.");
registry.Register(services, logger);
RegisterGeneratedRequestInvokerProvider(services, registry, assemblyName, logger);
}
}
/// <summary>
/// 当 generated registry 同时提供 request invoker 元数据时,把该 provider 注册到当前容器中。
/// </summary>
/// <param name="services">目标服务集合。</param>
/// <param name="registry">当前已激活的 generated registry。</param>
/// <param name="assemblyName">当前程序集的稳定名称。</param>
/// <param name="logger">日志记录器。</param>
/// <remarks>
/// provider 作为 registry 的附加能力注册到容器后dispatcher 才能在首次请求分发时优先消费编译期生成的 invoker 元数据。
/// 若 registry 不实现该契约,则保持现有纯反射 request binding 创建语义。
/// </remarks>
private static void RegisterGeneratedRequestInvokerProvider(
IServiceCollection services,
ICqrsHandlerRegistry registry,
string assemblyName,
ILogger logger)
{
if (registry is not ICqrsRequestInvokerProvider provider)
return;
RegisterGeneratedRequestInvokerDescriptors(provider, assemblyName, logger);
services.AddSingleton(typeof(ICqrsRequestInvokerProvider), provider);
logger.Debug(
$"Registered CQRS request invoker provider {provider.GetType().FullName} for assembly {assemblyName}.");
}
/// <summary>
/// 读取 generated request invoker provider 中当前可见的描述符,并写入 dispatcher 的进程级弱缓存。
/// </summary>
/// <param name="provider">当前已激活的 request invoker provider。</param>
/// <param name="assemblyName">当前程序集的稳定名称。</param>
/// <param name="logger">日志记录器。</param>
/// <remarks>
/// 运行时当前只要求 provider 暴露可枚举的描述符集合,而不是在 dispatcher 首次命中时再回调容器。
/// 这样 request dispatch binding 的静态缓存创建仍然只依赖类型键,不需要依赖具体容器实例。
/// </remarks>
private static void RegisterGeneratedRequestInvokerDescriptors(
ICqrsRequestInvokerProvider provider,
string assemblyName,
ILogger logger)
{
if (provider is not IEnumeratesCqrsRequestInvokerDescriptors descriptorSource)
return;
foreach (var descriptorEntry in descriptorSource.GetDescriptors())
{
CqrsDispatcher.RegisterGeneratedRequestInvokerDescriptor(
descriptorEntry.RequestType,
descriptorEntry.ResponseType,
descriptorEntry.Descriptor);
logger.Debug(
$"Registered generated CQRS request invoker descriptor for {descriptorEntry.RequestType.FullName} -> {descriptorEntry.ResponseType.FullName} from assembly {assemblyName}.");
}
}

View File

@ -0,0 +1,35 @@
using GFramework.Cqrs.Abstractions.Cqrs;
using GFramework.Cqrs.Notification;
namespace GFramework.Cqrs.Internal;
/// <summary>
/// 默认的通知发布器实现。
/// </summary>
/// <remarks>
/// 该实现完整保留当前 CQRS runtime 的既有通知语义:按已解析顺序逐个执行处理器,
/// 并在首个处理器抛出异常时立即停止后续发布。
/// </remarks>
internal sealed class SequentialNotificationPublisher : INotificationPublisher
{
/// <summary>
/// 按既定顺序逐个执行当前通知的处理器。
/// </summary>
/// <typeparam name="TNotification">通知类型。</typeparam>
/// <param name="context">当前发布调用的执行上下文。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示通知发布完成的值任务。</returns>
/// <exception cref="ArgumentNullException"><paramref name="context" /> 为 <see langword="null" />。</exception>
public async ValueTask PublishAsync<TNotification>(
NotificationPublishContext<TNotification> context,
CancellationToken cancellationToken = default)
where TNotification : INotification
{
ArgumentNullException.ThrowIfNull(context);
foreach (var handler in context.Handlers)
{
await context.InvokeHandlerAsync(handler, cancellationToken).ConfigureAwait(false);
}
}
}

View File

@ -0,0 +1,52 @@
using GFramework.Cqrs.Abstractions.Cqrs;
namespace GFramework.Cqrs.Notification;
/// <summary>
/// 通过内部回调桥接 dispatcher 执行逻辑的通知发布上下文。
/// </summary>
/// <typeparam name="TNotification">通知类型。</typeparam>
/// <typeparam name="TState">执行单个处理器所需的内部状态类型。</typeparam>
internal sealed class DelegatingNotificationPublishContext<TNotification, TState> : NotificationPublishContext<TNotification>
where TNotification : INotification
{
private readonly NotificationHandlerExecutor<TNotification, TState> _handlerExecutor;
private readonly TState _state;
/// <summary>
/// 初始化一个委托驱动的通知发布上下文。
/// </summary>
/// <param name="notification">当前通知。</param>
/// <param name="handlers">当前发布调用已解析到的处理器集合。</param>
/// <param name="state">执行处理器时需要的内部状态。</param>
/// <param name="handlerExecutor">执行单个处理器时调用的内部回调。</param>
/// <exception cref="ArgumentNullException">
/// <paramref name="handlerExecutor" /> 为 <see langword="null" />。
/// </exception>
internal DelegatingNotificationPublishContext(
TNotification notification,
IReadOnlyList<object> handlers,
TState state,
NotificationHandlerExecutor<TNotification, TState> handlerExecutor)
: base(notification, handlers)
{
ArgumentNullException.ThrowIfNull(handlerExecutor);
_state = state;
_handlerExecutor = handlerExecutor;
}
/// <summary>
/// 通过默认 dispatcher 提供的内部回调执行单个处理器。
/// </summary>
/// <param name="handler">要执行的处理器实例。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示当前处理器执行完成的值任务。</returns>
/// <exception cref="ArgumentNullException"><paramref name="handler" /> 为 <see langword="null" />。</exception>
public override ValueTask InvokeHandlerAsync(object handler, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(handler);
return _handlerExecutor(handler, Notification, _state, cancellationToken);
}
}

View File

@ -0,0 +1,27 @@
using GFramework.Cqrs.Abstractions.Cqrs;
namespace GFramework.Cqrs.Notification;
/// <summary>
/// 定义默认 CQRS runtime 的通知发布策略。
/// </summary>
/// <remarks>
/// <para>dispatcher 会先解析当前通知对应的处理器集合,再把本次发布上下文交给该抽象决定执行顺序。</para>
/// <para>实现应把 <see cref="NotificationPublishContext{TNotification}.Handlers" /> 视为当前发布调用的瞬时数据,
/// 不要跨发布缓存处理器实例或假设它们已经脱离当前上下文。</para>
/// </remarks>
public interface INotificationPublisher
{
/// <summary>
/// 执行一次通知发布。
/// </summary>
/// <typeparam name="TNotification">通知类型。</typeparam>
/// <param name="context">当前发布调用的处理器集合与执行入口,不能为空。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示通知发布完成的值任务。</returns>
/// <exception cref="ArgumentNullException"><paramref name="context" /> 为 <see langword="null" />。</exception>
ValueTask PublishAsync<TNotification>(
NotificationPublishContext<TNotification> context,
CancellationToken cancellationToken = default)
where TNotification : INotification;
}

View File

@ -0,0 +1,20 @@
using GFramework.Cqrs.Abstractions.Cqrs;
namespace GFramework.Cqrs.Notification;
/// <summary>
/// 表示默认 dispatcher 执行单个通知处理器时使用的内部回调。
/// </summary>
/// <typeparam name="TNotification">通知类型。</typeparam>
/// <typeparam name="TState">执行当前处理器所需的内部状态类型。</typeparam>
/// <param name="handler">要执行的处理器实例。</param>
/// <param name="notification">当前通知。</param>
/// <param name="state">当前处理器执行所需的内部状态。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示当前处理器执行完成的值任务。</returns>
internal delegate ValueTask NotificationHandlerExecutor<TNotification, in TState>(
object handler,
TNotification notification,
TState state,
CancellationToken cancellationToken)
where TNotification : INotification;

View File

@ -0,0 +1,50 @@
using GFramework.Cqrs.Abstractions.Cqrs;
namespace GFramework.Cqrs.Notification;
/// <summary>
/// 表示一次通知发布调用的执行上下文。
/// </summary>
/// <typeparam name="TNotification">通知类型。</typeparam>
/// <remarks>
/// 该上下文把“当前通知”“已解析处理器集合”和“执行单个处理器”的入口收敛到同一对象中,
/// 使发布策略只需决定遍历、排序或并发方式,而无需了解 dispatcher 的上下文注入细节。
/// </remarks>
public abstract class NotificationPublishContext<TNotification>
where TNotification : INotification
{
/// <summary>
/// 初始化一次通知发布上下文。
/// </summary>
/// <param name="notification">当前通知。</param>
/// <param name="handlers">当前发布调用已解析到的处理器集合。</param>
/// <exception cref="ArgumentNullException">
/// <paramref name="notification" /> 或 <paramref name="handlers" /> 为 <see langword="null" />。
/// </exception>
protected NotificationPublishContext(TNotification notification, IReadOnlyList<object> handlers)
{
ArgumentNullException.ThrowIfNull(notification);
ArgumentNullException.ThrowIfNull(handlers);
Notification = notification;
Handlers = handlers;
}
/// <summary>
/// 获取当前要发布的通知。
/// </summary>
public TNotification Notification { get; }
/// <summary>
/// 获取当前发布调用已解析到的处理器集合。
/// </summary>
public IReadOnlyList<object> Handlers { get; }
/// <summary>
/// 执行单个通知处理器。
/// </summary>
/// <param name="handler">要执行的处理器实例。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示当前处理器执行完成的值任务。</returns>
public abstract ValueTask InvokeHandlerAsync(object handler, CancellationToken cancellationToken);
}

View File

@ -49,6 +49,7 @@
- 默认 runtime 与注册入口
- `CqrsRuntimeFactory.cs`
- `Internal/CqrsDispatcher.cs`
- `Notification/INotificationPublisher.cs`
- `Internal/CqrsHandlerRegistrar.cs`
- `Internal/DefaultCqrsHandlerRegistrar.cs`
- `Internal/DefaultCqrsRegistrationService.cs`
@ -122,6 +123,8 @@ var playerId = await this.SendAsync(new CreatePlayerCommand(new CreatePlayerInpu
- `CqrsDispatcher` 按请求实际类型解析 `IRequestHandler<,>`,未找到处理器会抛出异常。
- 通知分发
- 通知会分发给所有已注册 `INotificationHandler<>`;零处理器时默认静默完成。
- 默认通知发布器会按容器解析顺序逐个执行处理器,并在首个处理器抛出异常时立即停止后续分发。
- 若容器在 runtime 创建前已显式注册 `INotificationPublisher`,默认 runtime 会复用该策略;未注册时回退到内置顺序发布器。
- 流式请求
- 通过 `IStreamRequest<TResponse>``IStreamRequestHandler<,>` 返回 `IAsyncEnumerable<TResponse>`
- 上下文注入

View File

@ -27,6 +27,29 @@
- `IUiFactory``ISceneFactory``IUiRoot``ISceneRoot`、资源注册表等通常由引擎适配层或游戏项目自己实现。
- 常见做法也是这样组织:页面 / 场景 factory、root、registry 在项目层,运行时基类和契约来自 `GFramework.Game` 与本包。
## Config Workflow Boundary
If you only depend on `GFramework.Game.Abstractions`, keep the configuration boundary explicit.
`Config/` in this package defines read-side contracts such as `IConfigLoader`, `IConfigRegistry`, `IConfigTable`, and
diagnostic models. It does not define the full adoption boundary of the AI-First configuration workflow by itself.
The actual implementation and support boundary still lives in `GFramework.Game` and its companion documentation:
- `YamlConfigLoader`, `GameConfigBootstrap`, and `GameConfigModule` are runtime features from `GFramework.Game`
- `GFramework.Game.SourceGenerators` targets the shared schema subset that stays aligned with the runtime contract
- schema designs outside that shared subset should be evaluated against `GFramework.Game` and
`docs/zh-CN/game/config-system.md`, not inferred from abstractions alone
Typical examples that are outside the current adoption path include:
- combinators such as `oneOf` and `anyOf`
- non-`false` forms of `additionalProperties`
- other schema designs that rely on open object shapes, union-like branching, or shape-merging behavior
If your project needs those boundaries clarified, move from this package-level contract view to the runtime-facing
configuration documentation instead of assuming `Game.Abstractions` implies broader schema support.
## 子系统地图
### `Config/`
@ -205,6 +228,8 @@ public sealed class ContinueGameCommandHandler
- 槽位存档仓库实现
- YAML 配置加载器
- Scene / UI 路由基类
- AI-First configuration boundary details, including the supported shared schema subset and the unsupported combinator /
open-shape cases
也就是说,本包回答的是“项目各层如何约定”,`GFramework.Game` 回答的是“这些约定默认怎么跑起来”。
@ -251,3 +276,6 @@ public sealed class ContinueGameCommandHandler
- 你需要默认实现、基础设施拼装、运行时启动入口
- 两者一起用
- 最常见。公共层依赖 abstractions应用层或引擎层依赖 runtime
For configuration-specific adoption decisions, treat `GFramework.Game` and
[配置系统](../docs/zh-CN/game/config-system.md) as the authoritative next step.

View File

@ -19,3 +19,5 @@
GF_ConfigSchema_012 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_013 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_014 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_015 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics
GF_ConfigSchema_016 | GFramework.SourceGenerators.Config | Error | ConfigSchemaDiagnostics

View File

@ -232,6 +232,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
}
return TryValidateStringFormatMetadataRecursively(filePath, "<root>", root, out diagnostic) &&
TryValidateUnsupportedCombinatorKeywordsRecursively(filePath, "<root>", root, out diagnostic) &&
TryValidateUnsupportedOpenObjectKeywordsRecursively(filePath, "<root>", root, out diagnostic) &&
TryValidateDependentRequiredMetadataRecursively(filePath, "<root>", root, out diagnostic) &&
TryValidateDependentSchemasMetadataRecursively(filePath, "<root>", root, out diagnostic) &&
TryValidateAllOfMetadataRecursively(filePath, "<root>", root, out diagnostic) &&
@ -844,6 +846,148 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
out diagnostic);
}
/// <summary>
/// 递归拒绝当前共享子集尚未支持的组合关键字。
/// 这里显式拦截 <c>oneOf</c> / <c>anyOf</c>,避免生成器静默接受会改变生成类型形状的 schema。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="element">当前 schema 节点。</param>
/// <param name="diagnostic">失败时返回的诊断。</param>
/// <returns>当前节点树是否未声明不支持的组合关键字。</returns>
private static bool TryValidateUnsupportedCombinatorKeywordsRecursively(
string filePath,
string displayPath,
JsonElement element,
out Diagnostic? diagnostic)
{
return TryTraverseSchemaRecursively(
filePath,
displayPath,
element,
static (currentFilePath, currentDisplayPath, currentElement, _) =>
{
return TryValidateUnsupportedCombinatorKeywords(
currentFilePath,
currentDisplayPath,
currentElement,
out var currentDiagnostic)
? (true, (Diagnostic?)null)
: (false, currentDiagnostic);
},
out diagnostic);
}
/// <summary>
/// 递归拒绝当前共享子集尚未支持的开放对象关键字形状。
/// 当前对象字段集默认是闭合的,因此这里只接受显式重复该语义的
/// <c>additionalProperties: false</c>。
/// </summary>
/// <param name="filePath">Schema 文件路径。</param>
/// <param name="displayPath">逻辑字段路径。</param>
/// <param name="element">当前 schema 节点。</param>
/// <param name="diagnostic">失败时返回的诊断。</param>
/// <returns>当前节点树是否未声明不支持的开放对象关键字形状。</returns>
private static bool TryValidateUnsupportedOpenObjectKeywordsRecursively(
string filePath,
string displayPath,
JsonElement element,
out Diagnostic? diagnostic)
{
return TryTraverseSchemaRecursively(
filePath,
displayPath,
element,
static (currentFilePath, currentDisplayPath, currentElement, _) =>
{
return TryValidateUnsupportedOpenObjectKeywords(
currentFilePath,
currentDisplayPath,
currentElement,
out var currentDiagnostic)
? (true, (Diagnostic?)null)
: (false, currentDiagnostic);
},
out diagnostic);
}
/// <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 TryValidateUnsupportedCombinatorKeywords(
string filePath,
string displayPath,
JsonElement element,
out Diagnostic? diagnostic)
{
diagnostic = null;
if (TryGetUnsupportedCombinatorKeywordName(element) is not { } keywordName)
{
return true;
}
diagnostic = Diagnostic.Create(
ConfigSchemaDiagnostics.UnsupportedCombinatorKeyword,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
keywordName,
"The current config schema subset does not support combinators that can change generated type shape.");
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 TryValidateUnsupportedOpenObjectKeywords(
string filePath,
string displayPath,
JsonElement element,
out Diagnostic? diagnostic)
{
diagnostic = null;
if (!element.TryGetProperty("additionalProperties", out var additionalPropertiesElement))
{
return true;
}
if (additionalPropertiesElement.ValueKind == JsonValueKind.False)
{
return true;
}
diagnostic = Diagnostic.Create(
ConfigSchemaDiagnostics.UnsupportedOpenObjectKeyword,
CreateFileLocation(filePath),
Path.GetFileName(filePath),
displayPath,
"additionalProperties",
"The current config schema subset only accepts 'additionalProperties: false' so object fields remain closed and strongly typed.");
return false;
}
/// <summary>
/// 返回当前节点声明的首个未支持组合关键字。
/// </summary>
/// <param name="element">当前 schema 节点。</param>
/// <returns>命中的关键字名称;未声明时返回空。</returns>
private static string? TryGetUnsupportedCombinatorKeywordName(JsonElement element)
{
return element.TryGetProperty("oneOf", out _) ? "oneOf" :
element.TryGetProperty("anyOf", out _) ? "anyOf" :
null;
}
/// <summary>
/// 以统一顺序递归遍历 schema 树,并把每个节点交给调用方提供的校验逻辑。
/// 该遍历覆盖对象属性、<c>dependentSchemas</c> / <c>allOf</c> /

View File

@ -162,4 +162,26 @@ public static class ConfigSchemaDiagnostics
SourceGeneratorsConfigCategory,
DiagnosticSeverity.Error,
true);
/// <summary>
/// schema 节点声明了当前共享子集尚未支持的组合关键字。
/// </summary>
public static readonly DiagnosticDescriptor UnsupportedCombinatorKeyword = new(
"GF_ConfigSchema_015",
"Config schema uses an unsupported combinator keyword",
"Property '{1}' in schema file '{0}' uses unsupported combinator keyword '{2}': {3}",
SourceGeneratorsConfigCategory,
DiagnosticSeverity.Error,
true);
/// <summary>
/// schema 节点声明了当前共享子集尚未支持的开放对象关键字形状。
/// </summary>
public static readonly DiagnosticDescriptor UnsupportedOpenObjectKeyword = new(
"GF_ConfigSchema_016",
"Config schema uses an unsupported open-object keyword",
"Property '{1}' in schema file '{0}' uses unsupported open-object keyword '{2}': {3}",
SourceGeneratorsConfigCategory,
DiagnosticSeverity.Error,
true);
}

View File

@ -14,6 +14,9 @@
- 对应的表包装类型
- 与 `GFramework.Game.Config` 运行时协作的访问辅助代码
这里要先明确一条采用边界:`GFramework.Game.SourceGenerators` 服务的是当前与 `GFramework.Game`
Runtime 对齐的共享 schema 子集,而不是任意 `JSON Schema` 的全量实现。它的目标是让配置生成、运行时校验和工具链维持同一份可落地契约,而不是把所有 schema 组合能力都映射成生成类型。
## 包关系
- 运行时:`GFramework.Game`
@ -73,6 +76,15 @@ GameProject/
- 你希望在编译期拿到强类型配置访问入口
- 你希望运行时加载、schema 校验和编辑工具链共用同一份结构定义
如果你的 schema 设计依赖下面这些场景,就不属于当前默认采用路径:
- `oneOf`
- `anyOf`
- 非 `false``additionalProperties`
- 其他依赖开放对象形状、联合分支或属性合并的复杂组合约束
遇到这些情况时,建议先回到 [配置系统文档](../docs/zh-CN/game/config-system.md) 和原始 schema / YAML 设计本体,确认是否需要调整配置建模方式,而不是默认期待生成器直接支持完整 `JSON Schema` 语义。
## 对应文档
- 配置系统:[配置系统文档](../docs/zh-CN/game/config-system.md)

View File

@ -270,6 +270,181 @@ public sealed class YamlConfigLoaderAllOfTests
});
}
/// <summary>
/// 验证运行时会显式拒绝当前共享子集尚未支持的 <c>oneOf</c>。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Object_Schema_Declares_Unsupported_OneOf()
{
CreateConfigFile(
"monster/slime.yaml",
BuildMonsterConfigYaml(
"""
itemCount: 3
"""));
CreateSchemaFile(
"schemas/monster.schema.json",
BuildMonsterSchema(
DefaultRewardPropertiesJson,
"""
[
{
"type": "object",
"required": ["itemCount"],
"properties": {
"itemCount": { "type": "integer" }
}
}
]
""",
"""
"oneOf": [
{
"type": "object",
"required": ["bonus"],
"properties": {
"bonus": { "type": "integer" }
}
}
]
"""));
var loader = CreateMonsterRewardLoader();
var registry = CreateRegistry();
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("reward"));
Assert.That(exception.Message, Does.Contain("unsupported combinator keyword 'oneOf'"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证运行时会显式拒绝当前共享子集尚未支持的 <c>anyOf</c>。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Object_Schema_Declares_Unsupported_AnyOf()
{
CreateConfigFile(
"monster/slime.yaml",
BuildMonsterConfigYaml(
"""
itemCount: 3
"""));
CreateSchemaFile(
"schemas/monster.schema.json",
BuildMonsterSchema(
DefaultRewardPropertiesJson,
"""
[
{
"type": "object",
"required": ["itemCount"],
"properties": {
"itemCount": { "type": "integer" }
}
}
]
""",
"""
"anyOf": [
{
"type": "object",
"required": ["bonus"],
"properties": {
"bonus": { "type": "integer" }
}
}
]
"""));
var loader = CreateMonsterRewardLoader();
var registry = CreateRegistry();
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("reward"));
Assert.That(exception.Message, Does.Contain("unsupported combinator keyword 'anyOf'"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证运行时接受显式声明的 <c>additionalProperties: false</c>
/// 因为这与当前闭合对象字段集语义保持一致。
/// </summary>
[Test]
public async Task LoadAsync_Should_Accept_When_Object_Schema_Declares_AdditionalProperties_False()
{
CreateConfigFile(
"monster/slime.yaml",
BuildMonsterConfigYaml(
"""
itemCount: 3
"""));
CreateSchemaFile(
"schemas/monster.schema.json",
BuildMonsterSchema(
DefaultRewardPropertiesJson,
DefaultAllOfJson,
"""
"additionalProperties": false
"""));
var loader = CreateMonsterRewardLoader();
var registry = CreateRegistry();
await loader.LoadAsync(registry).ConfigureAwait(false);
var table = registry.GetTable<int, MonsterAllOfConfigStub>("monster");
Assert.That(table.Count, Is.EqualTo(1));
}
/// <summary>
/// 验证运行时会拒绝会打开动态字段形状的 <c>additionalProperties</c>。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Object_Schema_Declares_Unsupported_AdditionalProperties()
{
CreateConfigFile(
"monster/slime.yaml",
BuildMonsterConfigYaml(
"""
itemCount: 3
"""));
CreateSchemaFile(
"schemas/monster.schema.json",
BuildMonsterSchema(
DefaultRewardPropertiesJson,
DefaultAllOfJson,
"""
"additionalProperties": true
"""));
var loader = CreateMonsterRewardLoader();
var registry = CreateRegistry();
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("reward"));
Assert.That(exception.Message, Does.Contain("unsupported 'additionalProperties' metadata"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证 allOf 条目只接受 object-typed schema。
/// </summary>
@ -566,10 +741,12 @@ public sealed class YamlConfigLoaderAllOfTests
/// </summary>
/// <param name="rewardPropertiesJson">奖励对象的 properties JSON 片段。</param>
/// <param name="allOfJson">allOf 约束的 JSON 数组片段。</param>
/// <param name="additionalRewardKeywordsJson">追加到奖励对象上的额外关键字 JSON 片段。</param>
/// <returns>完整的 schema JSON 文本。</returns>
private static string BuildMonsterSchema(
string rewardPropertiesJson,
string allOfJson)
string allOfJson,
string additionalRewardKeywordsJson = "")
{
return $$"""
{
@ -580,7 +757,7 @@ public sealed class YamlConfigLoaderAllOfTests
"reward": {
"type": "object",
"properties": {{rewardPropertiesJson}},
"allOf": {{allOfJson}}
"allOf": {{allOfJson}}{{(string.IsNullOrWhiteSpace(additionalRewardKeywordsJson) ? string.Empty : "," + Environment.NewLine + additionalRewardKeywordsJson.Trim())}}
}
}
}

View File

@ -321,6 +321,8 @@ internal static partial class YamlConfigSchemaValidator
JsonElement element,
bool isRoot = false)
{
ValidateUnsupportedCombinatorKeywords(tableName, schemaPath, propertyPath, element);
ValidateUnsupportedOpenObjectKeywords(tableName, schemaPath, propertyPath, element);
var typeName = ResolveNodeTypeName(tableName, schemaPath, propertyPath, element);
var referenceTableName = TryGetReferenceTableName(tableName, schemaPath, propertyPath, element);
ValidateObjectOnlyKeywords(tableName, schemaPath, propertyPath, element, typeName);
@ -336,6 +338,81 @@ internal static partial class YamlConfigSchemaValidator
return parsedNode.WithNegatedSchemaNode(ParseNegatedSchemaNode(tableName, schemaPath, propertyPath, element));
}
/// <summary>
/// 显式拒绝当前共享子集中尚未支持、且会改变生成类型形状的组合关键字。
/// 这样 Runtime / Generator / Tooling 会对同一份 schema 给出一致失败,
/// 而不是默默忽略 <c>oneOf</c> / <c>anyOf</c> 造成接受范围漂移。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">当前节点的逻辑属性路径。</param>
/// <param name="element">当前 schema 节点。</param>
private static void ValidateUnsupportedCombinatorKeywords(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element)
{
if (TryGetUnsupportedCombinatorKeywordName(element) is not { } keywordName)
{
return;
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' declares unsupported combinator keyword '{keywordName}'. " +
"The current config schema subset does not support combinators that can change generated type shape.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
/// <summary>
/// 显式拒绝当前共享子集中尚未支持的开放对象关键字形状。
/// 当前配置系统默认采用闭合对象字段集;这里只接受显式重复该语义的
/// <c>additionalProperties: false</c>,继续拒绝会引入动态字段形状的其它形式。
/// </summary>
/// <param name="tableName">所属配置表名称。</param>
/// <param name="schemaPath">Schema 文件路径。</param>
/// <param name="propertyPath">当前节点的逻辑属性路径。</param>
/// <param name="element">当前 schema 节点。</param>
private static void ValidateUnsupportedOpenObjectKeywords(
string tableName,
string schemaPath,
string propertyPath,
JsonElement element)
{
if (!element.TryGetProperty("additionalProperties", out var additionalPropertiesElement))
{
return;
}
if (additionalPropertiesElement.ValueKind == JsonValueKind.False)
{
return;
}
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.SchemaUnsupported,
tableName,
$"Property '{propertyPath}' in schema file '{schemaPath}' uses unsupported 'additionalProperties' metadata. " +
"The current config schema subset only accepts 'additionalProperties: false' so object fields remain closed and strongly typed.",
schemaPath: schemaPath,
displayPath: GetDiagnosticPath(propertyPath));
}
/// <summary>
/// 返回当前节点声明的首个未支持组合关键字。
/// </summary>
/// <param name="element">当前 schema 节点。</param>
/// <returns>命中的关键字名称;未声明时返回空。</returns>
private static string? TryGetUnsupportedCombinatorKeywordName(JsonElement element)
{
return element.TryGetProperty("oneOf", out _) ? "oneOf" :
element.TryGetProperty("anyOf", out _) ? "anyOf" :
null;
}
/// <summary>
/// 解析 schema 节点声明的类型名称,并在缺失或类型错误时立刻给出定位清晰的诊断。
/// </summary>

View File

@ -292,6 +292,13 @@ var registry = bootstrap.Registry;
- [内容配置系统](../docs/zh-CN/game/config-system.md)
接入前建议先记住当前采用边界:
- 正式契约以 `YamlConfigLoader``GFramework.Game.SourceGenerators` 共享支持的 schema 子集为准
- `additionalProperties` 当前只接受 `false`,用于保持对象字段集闭合
- `oneOf` / `anyOf` 这类会改变生成类型形状的组合关键字当前不属于采用路径
- VS Code 配置工具是内容维护辅助层;如果 schema 超出共享子集,应回退到 raw YAML 与 schema 本体设计
### 4. 接入 Scene / UI 路由
这里的最小前提不是“直接 new 一个 router”而是先补齐运行时依赖

View File

@ -1795,6 +1795,173 @@ public class SchemaConfigGeneratorTests
});
}
/// <summary>
/// 验证生成器会显式拒绝当前共享子集尚未支持的 <c>oneOf</c>。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_Object_Schema_Declares_Unsupported_OneOf()
{
const string source = DummySource;
const string schema = """
{
"type": "object",
"required": ["id", "reward"],
"properties": {
"id": { "type": "integer" },
"reward": {
"type": "object",
"properties": {
"itemCount": { "type": "integer" }
},
"oneOf": [
{
"type": "object",
"required": ["itemCount"],
"properties": {
"itemCount": { "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_015"));
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
Assert.That(diagnostic.GetMessage(), Does.Contain("reward"));
Assert.That(diagnostic.GetMessage(), Does.Contain("oneOf"));
Assert.That(diagnostic.GetMessage(), Does.Contain("does not support combinators that can change generated type shape"));
});
}
/// <summary>
/// 验证生成器会显式拒绝当前共享子集尚未支持的 <c>anyOf</c>。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_Object_Schema_Declares_Unsupported_AnyOf()
{
const string source = DummySource;
const string schema = """
{
"type": "object",
"required": ["id", "reward"],
"properties": {
"id": { "type": "integer" },
"reward": {
"type": "object",
"properties": {
"itemCount": { "type": "integer" }
},
"anyOf": [
{
"type": "object",
"required": ["itemCount"],
"properties": {
"itemCount": { "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_015"));
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
Assert.That(diagnostic.GetMessage(), Does.Contain("reward"));
Assert.That(diagnostic.GetMessage(), Does.Contain("anyOf"));
Assert.That(diagnostic.GetMessage(), Does.Contain("does not support combinators that can change generated type shape"));
});
}
/// <summary>
/// 验证生成器接受显式声明的 <c>additionalProperties: false</c>。
/// </summary>
[Test]
public void Run_Should_Accept_When_Object_Schema_Declares_AdditionalProperties_False()
{
const string source = DummySource;
const string schema = """
{
"type": "object",
"required": ["id", "reward"],
"properties": {
"id": { "type": "integer" },
"reward": {
"type": "object",
"additionalProperties": false,
"properties": {
"itemCount": { "type": "integer" }
}
}
}
}
""";
var result = SchemaGeneratorTestDriver.Run(
source,
("monster.schema.json", schema));
Assert.That(result.Results.Single().Diagnostics, Is.Empty);
}
/// <summary>
/// 验证生成器会拒绝会打开动态字段形状的 <c>additionalProperties</c>。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_Object_Schema_Declares_Unsupported_AdditionalProperties()
{
const string source = DummySource;
const string schema = """
{
"type": "object",
"required": ["id", "reward"],
"properties": {
"id": { "type": "integer" },
"reward": {
"type": "object",
"additionalProperties": true,
"properties": {
"itemCount": { "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_016"));
Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error));
Assert.That(diagnostic.GetMessage(), Does.Contain("reward"));
Assert.That(diagnostic.GetMessage(), Does.Contain("additionalProperties"));
Assert.That(diagnostic.GetMessage(), Does.Contain("only accepts 'additionalProperties: false'"));
});
}
/// <summary>
/// 验证 <c>then</c> 子 schema 内的非法 <c>format</c> 也会在生成阶段直接给出诊断。
/// </summary>

View File

@ -1689,6 +1689,107 @@ public class CqrsHandlerRegistryGeneratorTests
}
""";
private const string RequestInvokerProviderSource = """
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.DependencyInjection
{
public interface IServiceCollection { }
public static class ServiceCollectionServiceExtensions
{
public static void AddTransient(IServiceCollection services, Type serviceType, Type implementationType) { }
}
}
namespace GFramework.Core.Abstractions.Logging
{
public interface ILogger
{
void Debug(string msg);
}
}
namespace GFramework.Cqrs.Abstractions.Cqrs
{
public interface IRequest<TResponse> { }
public interface INotification { }
public interface IStreamRequest<TResponse> { }
public interface IRequestHandler<in TRequest, TResponse> where TRequest : IRequest<TResponse>
{
ValueTask<TResponse> Handle(TRequest request, CancellationToken cancellationToken);
}
public interface INotificationHandler<in TNotification> where TNotification : INotification { }
public interface IStreamRequestHandler<in TRequest, out TResponse> where TRequest : IStreamRequest<TResponse> { }
}
namespace GFramework.Cqrs
{
public interface ICqrsHandlerRegistry
{
void Register(Microsoft.Extensions.DependencyInjection.IServiceCollection services, GFramework.Core.Abstractions.Logging.ILogger logger);
}
public interface ICqrsRequestInvokerProvider
{
bool TryGetDescriptor(Type requestType, Type responseType, out CqrsRequestInvokerDescriptor? descriptor);
}
public interface IEnumeratesCqrsRequestInvokerDescriptors
{
IReadOnlyList<CqrsRequestInvokerDescriptorEntry> GetDescriptors();
}
public sealed class CqrsRequestInvokerDescriptor
{
public CqrsRequestInvokerDescriptor(Type handlerType, MethodInfo invokerMethod) { }
}
public sealed class CqrsRequestInvokerDescriptorEntry
{
public CqrsRequestInvokerDescriptorEntry(Type requestType, Type responseType, CqrsRequestInvokerDescriptor descriptor)
{
RequestType = requestType;
ResponseType = responseType;
Descriptor = descriptor;
}
public Type RequestType { get; }
public Type ResponseType { get; }
public CqrsRequestInvokerDescriptor Descriptor { get; }
}
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public sealed class CqrsHandlerRegistryAttribute : Attribute
{
public CqrsHandlerRegistryAttribute(Type registryType) { }
}
}
namespace TestApp
{
using GFramework.Cqrs.Abstractions.Cqrs;
public sealed record VisibleRequest(string Value) : IRequest<string>;
public sealed class VisibleHandler : IRequestHandler<VisibleRequest, string>
{
public ValueTask<string> Handle(VisibleRequest request, CancellationToken cancellationToken)
{
return ValueTask.FromResult(request.Value);
}
}
}
""";
/// <summary>
/// 验证生成器会为当前程序集中的 request、notification 和 stream 处理器生成稳定顺序的注册器。
/// </summary>
@ -2150,8 +2251,6 @@ public class CqrsHandlerRegistryGeneratorTests
var generatorErrors = execution.GeneratorDiagnostics
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToArray();
var generatedSource = execution.GeneratedSources[0].content;
Assert.Multiple(() =>
{
Assert.That(inputCompilationErrors.Select(static diagnostic => diagnostic.Id), Does.Contain("CS0306"));
@ -2159,6 +2258,7 @@ public class CqrsHandlerRegistryGeneratorTests
Assert.That(generatorErrors, Is.Empty);
Assert.That(execution.GeneratedSources, Has.Length.EqualTo(1));
Assert.That(execution.GeneratedSources[0].filename, Is.EqualTo("CqrsHandlerRegistry.g.cs"));
var generatedSource = execution.GeneratedSources[0].content;
Assert.That(
generatedSource,
Does.Contain(
@ -2201,8 +2301,6 @@ public class CqrsHandlerRegistryGeneratorTests
var generatorErrors = execution.GeneratorDiagnostics
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToArray();
var generatedSource = execution.GeneratedSources[0].content;
Assert.Multiple(() =>
{
Assert.That(inputCompilationErrors.Select(static diagnostic => diagnostic.Id), Does.Contain("CS0306"));
@ -2210,6 +2308,7 @@ public class CqrsHandlerRegistryGeneratorTests
Assert.That(generatorErrors, Is.Empty);
Assert.That(execution.GeneratedSources, Has.Length.EqualTo(1));
Assert.That(execution.GeneratedSources[0].filename, Is.EqualTo("CqrsHandlerRegistry.g.cs"));
var generatedSource = execution.GeneratedSources[0].content;
Assert.That(
generatedSource,
Does.Contain(
@ -2240,6 +2339,54 @@ public class CqrsHandlerRegistryGeneratorTests
});
}
/// <summary>
/// 验证当 runtime 暴露 request invoker provider 契约时,生成器会让 generated registry 同时发射
/// request invoker 描述符与对应的开放静态 invoker 方法。
/// </summary>
[Test]
public void Emits_Request_Invoker_Provider_Metadata_When_Runtime_Contract_Is_Available()
{
var execution = ExecuteGenerator(RequestInvokerProviderSource);
var inputCompilationErrors = execution.InputCompilationDiagnostics
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToArray();
var generatedCompilationErrors = execution.GeneratedCompilationDiagnostics
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToArray();
var generatorErrors = execution.GeneratorDiagnostics
.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
.ToArray();
Assert.Multiple(() =>
{
Assert.That(inputCompilationErrors, Is.Empty);
Assert.That(generatedCompilationErrors, Is.Empty);
Assert.That(generatorErrors, Is.Empty);
Assert.That(execution.GeneratedSources, Has.Length.EqualTo(1));
Assert.That(execution.GeneratedSources[0].filename, Is.EqualTo("CqrsHandlerRegistry.g.cs"));
var generatedSource = execution.GeneratedSources[0].content;
Assert.That(
generatedSource,
Does.Contain(
"internal sealed class __GFrameworkGeneratedCqrsHandlerRegistry : global::GFramework.Cqrs.ICqrsHandlerRegistry, global::GFramework.Cqrs.ICqrsRequestInvokerProvider, global::GFramework.Cqrs.IEnumeratesCqrsRequestInvokerDescriptors"));
Assert.That(
generatedSource,
Does.Contain(
"new global::GFramework.Cqrs.CqrsRequestInvokerDescriptorEntry(typeof(global::TestApp.VisibleRequest), typeof(string),"));
Assert.That(
generatedSource,
Does.Contain(
"new global::GFramework.Cqrs.CqrsRequestInvokerDescriptor(typeof(global::GFramework.Cqrs.Abstractions.Cqrs.IRequestHandler<global::TestApp.VisibleRequest, string>), typeof(__GFrameworkGeneratedCqrsHandlerRegistry).GetMethod(nameof(InvokeRequestHandler0), global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static)!)"));
Assert.That(
generatedSource,
Does.Contain(
"private static global::System.Threading.Tasks.ValueTask<string> InvokeRequestHandler0(object handler, object request, global::System.Threading.CancellationToken cancellationToken)"));
Assert.That(
generatedSource,
Does.Contain(
"public global::System.Collections.Generic.IReadOnlyList<global::GFramework.Cqrs.CqrsRequestInvokerDescriptorEntry> GetDescriptors()"));
});
}
/// <summary>
/// 验证日志字符串转义会覆盖换行、反斜杠和双引号,避免生成代码中的字符串字面量被意外截断。
/// </summary>

View File

@ -7,6 +7,7 @@ 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;
@ -51,6 +52,8 @@ public static class CqrsTestRuntime
/// 这使仅使用 <see cref="MicrosoftDiContainer" /> 的测试环境也能观察与生产路径一致的 runtime 行为,
/// 而无需完整启动服务模块管理器。
/// 该方法按服务类型执行幂等注册,只会补齐当前容器中尚未接线的 CQRS 基础设施。
/// 若容器里只预注册了正式 <see cref="ICqrsRuntime" /> seam本方法也会补齐旧命名空间下的
/// <see cref="LegacyICqrsRuntime" /> 兼容别名,并保持它与正式 seam 指向同一实例。
/// </remarks>
public static void RegisterInfrastructure(MicrosoftDiContainer container)
{
@ -59,13 +62,14 @@ public static class CqrsTestRuntime
if (container.Get<ICqrsRuntime>() is null)
{
var runtimeLogger = LoggerFactoryResolver.Provider.CreateLogger("CqrsDispatcher");
var runtime = CqrsRuntimeFactory.CreateRuntime(container, runtimeLogger);
var notificationPublisher = container.Get<INotificationPublisher>();
var runtime = CqrsRuntimeFactory.CreateRuntime(container, runtimeLogger, notificationPublisher);
container.Register(runtime);
container.Register<LegacyICqrsRuntime>((LegacyICqrsRuntime)runtime);
RegisterLegacyRuntimeAlias(container, runtime);
}
else if (container.Get<LegacyICqrsRuntime>() is null)
{
container.Register<LegacyICqrsRuntime>((LegacyICqrsRuntime)container.GetRequired<ICqrsRuntime>());
RegisterLegacyRuntimeAlias(container, container.GetRequired<ICqrsRuntime>());
}
if (container.Get<ICqrsHandlerRegistrar>() is null)
@ -84,6 +88,25 @@ public static class CqrsTestRuntime
}
}
/// <summary>
/// 为旧命名空间下的 CQRS runtime 契约注册兼容别名。
/// </summary>
/// <param name="container">承载运行时实例的测试容器。</param>
/// <param name="runtime">当前正式 CQRS runtime 实例。</param>
/// <remarks>
/// 测试辅助层显式保留这条 helper避免“已存在正式 seam 时再补旧别名”的兼容语义分散在多个分支里。
/// </remarks>
private static void RegisterLegacyRuntimeAlias(MicrosoftDiContainer container, ICqrsRuntime runtime)
{
if (runtime is not LegacyICqrsRuntime legacyRuntime)
{
throw new InvalidOperationException(
$"The registered {nameof(ICqrsRuntime)} must also implement {typeof(LegacyICqrsRuntime).FullName}.");
}
container.Register<LegacyICqrsRuntime>(legacyRuntime);
}
/// <summary>
/// 通过与生产代码一致的注册入口扫描并注册指定程序集中的 CQRS 处理器。
/// </summary>

View File

@ -15,6 +15,7 @@
- 想先跑一个最小例子:[快速开始](docs/zh-CN/getting-started/quick-start.md)
- 想先确认该装哪些包:[安装配置](docs/zh-CN/getting-started/installation.md)
- 想接入 AI-First 配置工作流:[配置系统](docs/zh-CN/game/config-system.md) / [VS Code 配置工具](docs/zh-CN/game/config-tool.md)
当前正式支持边界以 `GFramework.Game` Runtime 与 `GFramework.Game.SourceGenerators` 共享 schema 子集为准VS Code 工具是辅助层,不会单独扩出另一套配置契约
- 已经知道要用哪个模块:直接进入对应模块的说明页
## 模块地图
@ -74,6 +75,13 @@
- `GeWuYou.GFramework.*.SourceGenerators`
只在需要编译期生成代码时安装,版本应与运行时包保持一致。
如果你采用 AI-First 配置工作流,建议在进入实现前先确认两条边界:
- 当前共享子集接受闭合对象边界 `additionalProperties: false`(需显式设置为 `false`;省略或 `true` 视为不支持)
- `oneOf` / `anyOf` 这类会改变生成类型形状的组合关键字当前会被 Runtime / Generator / Tooling 直接拒绝
更复杂的 schema shape 需要先回到 schema 设计与 raw YAML 维护路径,而不是假定编辑器工具存在隐藏支持。
## 最小安装组合
```bash

View File

@ -6,6 +6,13 @@
当前阶段不再把 VS Code 工具能力当作阻塞项;工具链只要不拖累 C# 首发可用版本即可。
## 并行 Lane 约束
- `C# Runtime + Source Generator + Consumer DX` 仍是当前主线恢复点
- Tooling / Docs 作为非阻塞并行 lane 单独推进,但每一批仍要和 Runtime / Generator 的共享关键字边界保持一致
- active tracking / trace 只保留恢复点、验证与 lane 指针;复杂编辑器细节、宿主手工验证和文档批次安排统一写在本文件
- public docs 只写消费者接入、限制和迁移边界;治理噪音、批次编排和 recovery 元数据继续留在 `ai-plan/**`
## 当前状态
- [x] 单表注册辅助:`Register{Entity}Table()`
@ -37,8 +44,8 @@
- [x] 继续扩展最有价值的 JSON Schema 子集
- 原则:只做 Runtime / Generator / Tooling 三端都能稳定解释的关键字
- 已补齐:`enum`(当前覆盖标量、对象、数组节点,以及标量数组元素)、`const``not``pattern``format`(当前稳定子集:`date``date-time``duration``email``time``uri``uuid`)、`minItems`、`maxItems`、`exclusiveMinimum``exclusiveMaximum``multipleOf``uniqueItems``minProperties``maxProperties``dependentRequired``dependentSchemas``allOf`、object-focused `if` / `then` / `else`
- 当前产出运行时拒绝相关约束违规值VS Code 校验与表单 hint 对齐,生成代码 XML 文档同步暴露新关键字;对象 / 数组 `enum` 当前主要参与校验与文档输出,不额外扩展复杂表单控件;`allOf``if` / `then` / `else` 当前都收敛为 object-focused constraint block不做属性合并
- 已补齐:`enum`(当前覆盖标量、对象、数组节点,以及标量数组元素)、`const``not``pattern``format`(当前稳定子集:`date``date-time``duration``email``time``uri``uuid`)、`minLength`、`maxLength``minItems`、`maxItems`、`contains``minContains``maxContains`、`exclusiveMinimum``exclusiveMaximum``multipleOf``uniqueItems``minProperties``maxProperties``dependentRequired``dependentSchemas``allOf`、object-focused `if` / `then` / `else`
- 当前产出运行时拒绝相关约束违规值VS Code 校验与表单 hint 对齐,生成代码 XML 文档同步暴露新关键字;对象 / 数组 `enum` 当前主要参与校验与文档输出,不额外扩展复杂表单控件;`allOf``if` / `then` / `else` 当前都收敛为 object-focused constraint block不做属性合并`oneOf` / `anyOf` 当前已统一定义为不支持并在三端显式拒绝
- [x] 评估可选只读索引能力
- 目标:为高频查询字段提供比 `All()` 线性扫描更强的读取体验
@ -60,10 +67,28 @@
- [ ] 继续扩插件的复杂表单能力
- 说明:这是可选项,不阻塞 C# 主线
## Tooling / Docs 并行 Lane
- [ ] Tooling让 VS Code 表单支持更深层对象数组嵌套,减少 raw YAML 回退
- 边界:不改变 Runtime / Generator 已定义的 schema 形状契约
- 验证:优先补 JS 测试,其次再做真实 VS Code 宿主手工验证
- [ ] Tooling为复杂结构提供比“顶层标量 / 标量数组”更强的批量编辑能力
- 边界:只增强编辑体验,不反向要求 schema 扩展或新的生成类型形状
- 验证:记录可观察的编辑路径和回退路径,而不是在 active 入口堆叠 UI 细节
- [ ] Tooling在真实 VS Code 宿主中完成对象数组编辑与复杂 schema 的交互式手工验证
- 边界:作为发布前增强项,不阻塞共享关键字主线
- 验证:后续 batch 直接补记宿主验证结论与未覆盖场景
- [ ] Docs在相关接入文档里补齐“工具能力是辅助层不定义 Runtime 契约”的读者提示
- 边界:只写 reader-facing 接入 guidance不写批次、治理、风险台账
- 验证:确认文档用语聚焦接入路径、能力边界和回退方案
## 暂缓
- [ ] 不追求完整 JSON Schema 全量支持
- 原因:维护成本高,且容易造成 Runtime / Generator / Tooling 三端漂移
- 原因:维护成本高,且容易造成 Runtime / Generator / Tooling 三端漂移;像 `oneOf` / `anyOf` 这类会改变生成类型形状的组合关键字当前已明确排除
- [ ] 不优先做运行时可写配置
- 原因:当前系统定位仍然是静态内容只读查询
@ -75,7 +100,7 @@
1. 用 `GeneratedConfigCatalog` 继续补齐启动与诊断辅助
2. 补一条比 `Architecture.OnInitialize()` 更正式的模块化接入建议
当前状态:第 1 项和第 2 项已完成,`allOf` 与 object-focused `if` / `then` / `else` 也已补齐;下一步转到下一批仍不改变生成形状的组合关键字评估,或继续推进 VS Code 复杂编辑体验
当前状态:第 1 项和第 2 项已完成,`allOf` 与 object-focused `if` / `then` / `else` 也已补齐;下一步默认转到下一批仍不改变生成形状的组合关键字评估。若另开并行 batch再从本文件的 Tooling / Docs lane 接手
## 完成标准
@ -87,12 +112,16 @@
## 下次恢复点
- 在当前稳定 `format` 子集(`date``date-time``duration``email``time``uri``uuid`、object-focused `allOf` 与 object-focused `if` / `then` / `else` 之后,转到下一批仍不改变生成类型形状的关键字评估;仍然不要先回工具 UI
- `oneOf` / `anyOf` 已明确跳过;恢复时不要再把它们当作默认候选
- 恢复时优先检查:
- `GFramework.Game/Config/YamlConfigSchemaValidator.cs`
- `GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs`
- `tools/gframework-config-tool/src/configValidation.js`
- `tools/gframework-config-tool/src/extension.js`
- `docs/zh-CN/game/config-system.md`
- 若恢复的是 Tooling / Docs 并行 lane
- 先回看本文件的 `Tooling / Docs 并行 Lane`
- 只把结果摘要回填到 active tracking / trace避免把编辑器批次细节重新塞回默认入口
### 恢复块
@ -108,5 +137,6 @@
- 结果:通过
- 下一步:
1. 检查 `YamlConfigSchemaValidator.cs``SchemaConfigGenerator.cs``configValidation.js` 中当前已支持的关键字列表
2. 评估 `oneOf` / `anyOf` 是否存在可接受的 object-focused 子集
3. 若结论否定,选择下一批共享解释关键字而不是先回工具 UI
2. 跳过 `oneOf` / `anyOf`,选择下一批仍不改变生成类型形状的共享关键字
3. 优先找不需要属性合并、联合分支生成或额外 UI 形状解释的关键字,而不是先回工具 UI
4. 若主线批次暂不动代码,可并行开启 Tooling / Docs lane但不要让其反向改写主线恢复点定义

View File

@ -11,22 +11,23 @@
- 当前阶段:`C# Runtime + Source Generator + Consumer DX`
- 当前焦点:
- 已完成 object-focused `if` / `then` / `else`,继续评估下一批仍不改变生成类型形状的共享关键字
- 已明确将 `oneOf` / `anyOf` 归类为当前不支持的组合关键字,并在 Runtime / Generator / Tooling 三端显式拒绝,避免静默接受导致形状漂移
- 已完成 PR #262 的 CodeRabbit follow-up补齐 latest review body 中 folded `Nitpick comments` 的 skill 解析并按建议收口 Tooling / Tests
- 先以 Runtime / Generator / Tooling 三端一致语义为前提筛选下一项,而不是盲目扩全量 JSON Schema
- 继续把 VS Code 工具能力视为非阻塞项,不让复杂 UI 编辑器需求反过来拖慢 C# 主线
- Tooling / Docs 后续改为非阻塞并行 laneactive 入口只保留主线恢复点,把批处理细节下沉到 backlog 文件
### 已知风险
- 组合关键字扩展风险:下一批候选关键字可能像标准 `oneOf` / `anyOf` 一样更容易引入生成类型形状漂移
- 缓解措施:延续 object-focused / focused matcher 约束,只接受三端都能稳定解释且不需要属性合并的子集
- 缓解措施:`oneOf` / `anyOf` 已改为三端显式拒绝;后续仅继续评估不会引入联合形状、属性合并或分支生成漂移的关键字子集
- 工具链验证风险VS Code 与 CI / 发布管道验证覆盖不足
- 缓解措施:继续为新增共享关键字补齐三端测试覆盖,优先保证 C# Runtime 与 Generator 回归通过,并记录 JS 测试与构建验证
- PR review 信号漂移风险CodeRabbit 可能把建议折叠在 latest review body而不是 issue comments
- 缓解措施:`gframework-pr-review` 现已同时解析 latest review body并输出 declared / parsed 数量以便快速识别解析缺口
- PR follow-up 残留风险PR `#262` 最新 review thread 仍有少量 open comments且 nitpick body 解析仍存在 declared / parsed 缺口
- 缓解措施:先以 latest unresolved thread 为准逐条本地核验;已确认并补齐运行时诊断路径与 `else without if` 回归测试skill 现已补齐 `.py` nitpick 与 outside-diff comment 解析,剩余项只需等待本地修复推送后再复抓确认
- 非阻塞项回退风险:将 VS Code 功能标为非阻塞但导致主线回退的风险
- 缓解措施:C# 主线补齐新关键字时仍需在 `configValidation.js``extension.js` 中同步落地,只是不让复杂表单控件阻塞发布
- 并行 lane 漂移风险Tooling / Docs 作为并行项后,后续 batch 可能重新把治理说明写回 active 入口或 public docs
- 缓解措施:active tracking / trace 只保留恢复点、验证和 lane 指针reader-facing 文档只写接入信息,治理说明继续留在 `ai-plan/**`
## 当前状态
@ -35,8 +36,10 @@
- 已补齐一批共享 JSON Schema 子集,包括:
- `enum``const``not``pattern`
- `format` 稳定子集:`date``date-time``duration``email``time``uri``uuid`
- `minItems`、`maxItems`、`exclusiveMinimum``exclusiveMaximum``multipleOf``uniqueItems`
- `minLength`、`maxLength``minItems`、`maxItems`、`contains``minContains``maxContains`、`exclusiveMinimum``exclusiveMaximum``multipleOf``uniqueItems`
- `minProperties``maxProperties``dependentRequired``dependentSchemas``allOf`、object-focused `if` / `then` / `else`
- 已明确拒绝会改变生成类型形状的组合关键字:
- `oneOf``anyOf` 当前会在 Runtime / Generator / Tooling 三端直接报错,而不是静默忽略
- `if` / `then` / `else` 已按“不改变生成类型形状”的边界落地:
- 只允许 object 节点上的 object-typed inline schema
- `if` 必填,且必须至少伴随 `then``else` 之一
@ -70,9 +73,7 @@
- 继续扩展“不会改变生成类型形状”的共享关键字支持
- 继续降低复杂 schema 与多配置域项目的接入成本
- 让 VS Code 表单支持更深层对象数组嵌套,减少 raw YAML 回退
- 为复杂结构提供比“顶层标量 / 标量数组”更强的批量编辑能力
- 在真实 VS Code 宿主中完成对象数组编辑与复杂 schema 的交互式手工验证
- Tooling / Docs 并行 lane 仍需推进复杂表单、交互式宿主验证和后续接入文档,但这些事项不再阻塞当前恢复点
## 活跃文档
@ -84,15 +85,12 @@
- `2026-04-17` 之前的详细实现记录与定向验证命令已归档到历史 tracking / trace
- active 跟踪文件只保留当前恢复点、当前状态和下一步,不再重复堆积已完成阶段的完整历史
- `2026-04-20` 当前恢复点验证:
- `python3 .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --pr 262 --format json`:通过(`CodeRabbit outside-diff comments: 1 declared, 1 parsed``CodeRabbit nitpick comments: 2 declared, 2 parsed`
- `bun run test``tools/gframework-config-tool`通过122 tests包含条件分支坏形状回归
- `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --filter "FullyQualifiedName~SchemaConfigGeneratorTests"`:通过
- `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~YamlConfigLoaderIfThenElseTests"`通过8 tests新增 `else without if` 运行时回归)
- `dotnet build GFramework.sln -c Release`:通过(存在仓库既有 analyzer warning无新增错误
- 最近验证摘要:`2026-04-30` 已完成 Tooling / Docs reader-facing 收口与工具 parser 边界收紧,详细命令、批次背景与验证结果保留在 trace 的 `2026-04-30` 分阶段记录中
- 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 不再重复展开逐条命令历史
## 下一步
1. 提交并推送当前 PR `#262` follow-up 修复后,重新抓取一次 PR review确认 outside-diff comment 与 open thread 是否都已收口
2. 若 PR review 已收口,再回到 `GFramework.Game/Config/YamlConfigSchemaValidator.cs``GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs``tools/gframework-config-tool/src/configValidation.js` 盘点下一批候选关键字
3. 优先判断 `oneOf` / `anyOf` 是否存在可接受的 object-focused 子集;若仍会引入生成类型形状漂移,就直接跳过
1. 主线继续回到 `YamlConfigSchemaValidator.cs``SchemaConfigGenerator.cs``configValidation.js` 的共享关键字盘点,默认跳过 `oneOf` / `anyOf`
2. Tooling / Docs 若要并发推进,优先补 reader-facing 示例或采用路径,不再重复扩写能力边界说明
3. 保持 active tracking / trace 精简,只记录当前恢复点、最近验证和下一步恢复指针

View File

@ -106,6 +106,128 @@
### 下一步
1. 评估 `oneOf` / `anyOf` 是否值得继续沿用 object-focused 子集;若仍会造成生成形状漂移,就直接跳过
1. 跳过 `oneOf` / `anyOf`,优先筛选下一个仍不改变生成类型形状、且不需要属性合并或联合分支生成的共享关键字
2. 若继续扩共享关键字,先在 Runtime / Generator / Tooling 三端同时定义一致边界,再进入实现
3. 继续把 active 入口保持精简,只记录当前恢复点、验证与下一步
## 2026-04-30
### 阶段组合关键字边界收口AI-FIRST-CONFIG-RP-003
- 已在 Runtime、Source Generator 与 VS Code Tooling 三端显式拒绝 `oneOf` / `anyOf`
- 本轮结论不是继续做 object-focused 子集,而是先收紧共享边界:
- `oneOf` / `anyOf` 更容易引入联合分支、属性合并或生成类型形状漂移
- 当前配置系统主线仍优先保证 `C# Runtime + Source Generator + Consumer DX` 的稳定契约
- 因此三端统一改为在 schema 解析 / 生成阶段直接失败,避免静默忽略同一份 schema
- active tracking 也已同步更新,不再把 `oneOf` / `anyOf` 作为下一批默认候选
### 验证
- 2026-04-30`bun run test``tools/gframework-config-tool`
- 目标:验证工具端会拒绝 `oneOf`
- 2026-04-30`dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --filter "FullyQualifiedName~SchemaConfigGeneratorTests"`
- 目标:验证生成器新增 `GF_ConfigSchema_015`
- 2026-04-30`dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~YamlConfigLoaderAllOfTests"`
- 目标:验证运行时会拒绝对象节点上的 `oneOf`
### 下一步
1. 若本轮定向验证通过,继续盘点下一批真正低风险、且不改变生成类型形状的共享关键字
2. 不再重复评估 `oneOf` / `anyOf` 的 object-focused 子集,除非未来主线明确接受联合形状生成
3. 若后续关键字需要新诊断编号或文档边界说明,继续保持 Runtime / Generator / Tooling 同步收口
### 阶段Tooling lane 收口整理AI-FIRST-CONFIG-RP-003
- 已把 Tooling / Docs 后续动作从 active 入口的主线叙述中剥离,改成 backlog 文件里的非阻塞并行 lane
- 当前 active tracking / trace 只继续承担三件事:
- 给 `boot` 提供当前恢复点
- 记录最近一次验证或计划性验证占位
- 指向真正承载并行批次细节的 backlog 文件
- 本轮不新增代码范围、测试范围或文档范围,只整理 public `ai-plan/**` 的恢复入口表达,避免把治理噪音带回 reader-facing docs
### 关键决定
- `C# Runtime + Source Generator + Consumer DX` 仍是默认恢复主线
- Tooling / Docs 可以并发推进,但后续 batch 应直接以 `ai-first-config-system-csharp-experience-next.md` 为入口,而不是继续扩写 active tracking / trace
- public docs 后续只承接接入 guidance、能力边界和回退方式批次编排、lane 风险和治理说明继续留在 `ai-plan/**`
### 验证
- 2026-04-30`wc -l ai-plan/public/ai-first-config-system/todos/ai-first-config-system-tracking.md ai-plan/public/ai-first-config-system/todos/ai-first-config-system-csharp-experience-next.md ai-plan/public/ai-first-config-system/traces/ai-first-config-system-trace.md`
- 结果:通过
- 备注:确认本轮仍把 active 入口控制在精简范围,并把 lane 细节下沉到 backlog 文件
### 下一步
1. 若继续做主线代码批次,直接回到共享关键字盘点,不让 Tooling / Docs 成为阻塞条件
2. 若另开 Tooling / Docs batch先读取 `ai-first-config-system-csharp-experience-next.md` 的并行 lane再把结果摘要写回 active tracking / trace
3. 继续保持 active 入口精简,不在默认恢复文件中追加 UI 细节、治理台账或面向读者的文档草稿
### 阶段Tooling / Docs reader-facing 边界补齐AI-FIRST-CONFIG-RP-003
- 已在 `config-tool.md``config-system.md``tools/gframework-config-tool/README.md` 明确 reader-facing 能力边界
- 本轮重点不是新增能力,而是把当前分支已经落地的结论写清楚:
- `contains` / `minContains` / `maxContains`
- `dependentRequired``dependentSchemas``allOf`
- object-focused `if` / `then` / `else`
- `additionalProperties: false`
- `oneOf` / `anyOf` rejection
- 同时补充了两个采用原则:
- VS Code 工具是辅助层,不定义 Runtime 契约
- 复杂 shape 或超出共享子集的 schema应回退到 raw YAML 与 schema 文件本体处理
### 验证
- 2026-04-30`git diff --check -- docs/zh-CN/game/config-tool.md docs/zh-CN/game/config-system.md tools/gframework-config-tool/README.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`
- 结果:通过
### 下一步
1. Tooling / Docs 后续若继续推进,优先补真实采用示例,而不是重复扩写边界清单
2. 主线代码批次继续以 Runtime / Generator / Tooling 三端共享关键字收口为中心
### 阶段Tooling parser 坏形状拒绝收紧AI-FIRST-CONFIG-RP-003
- 已在 `tools/gframework-config-tool/src/configValidation.js` 收紧工具侧 schema parser 边界
- 本轮不是扩 JSON Schema 能力,而是避免工具侧比 Runtime / Generator 更宽松:
- `additionalProperties` 现在只接受 `false`
- 数组 `items` 必须是 object-shaped 且显式带 `type`
- 数组 `contains` 若声明,也必须是 object-shaped 且显式带 `type`
- 这样 tuple-array `items: []`、缺失 `type``contains` 子 schema以及其他会误导用户以为“工具支持但运行时不支持”的坏形状会在工具解析阶段直接失败
### 验证
- 2026-04-30`bun run test``tools/gframework-config-tool`
- 结果:通过
- 备注:新增 JS 回归覆盖 `additionalProperties`、tuple-array `items` 与缺失 `type``contains`
### 下一步
1. 继续盘点 Runtime / Generator / Tooling 三端是否还有类似“工具宽松吞掉、主线不支持”的 schema 形状
2. 若继续做 Tooling lane优先补 reader-facing 示例或采用路径,而不是继续堆积边界清单
### 阶段PR #306 open threads 收口AI-FIRST-CONFIG-RP-003
- 已重新抓取 PR `#306` 的 latest open review threads并按“本地仍成立 / 已被当前分支吸收”重新核验
- 本轮收口重点不是继续扩能力,而是把 open threads 中仍成立的三类问题一次性补齐:
- Generator补齐 `GF_ConfigSchema_015``anyOf` 对称负例,避免组合关键字只覆盖 `oneOf`
- Tooling拒绝未知显式 `type`、收窄 object-array 只遍历当前 editor 直属 items、统一 `contains` hint 文案
- Docs`additionalProperties: false` 的“必须显式设置为 false”写清并为工具补最小接入示例、迁移提示与更准确的 raw YAML 回退条件
- 本轮同时更新了 JS / .NET 回归测试与 active tracking避免只修 review comment 不保留恢复点
### 验证
- 2026-04-30`bun run test``tools/gframework-config-tool`
- 结果通过132 tests
- 备注:新增未知 schema `type` 拒绝、嵌套 object-array 不串层,以及 `contains` hint 文案回归
- 2026-04-30`dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --filter "FullyQualifiedName~SchemaConfigGeneratorTests"`
- 结果通过54 tests
- 备注:补齐 `Run_Should_Report_Diagnostic_When_Object_Schema_Declares_Unsupported_AnyOf`
- 2026-04-30`git diff --check`
- 结果:通过
- 备注:本轮代码与文档改动未引入空白或冲突标记问题
### 下一步
1. 推送本轮修复后,重新抓取 PR `#306` review 状态,确认哪些 open threads 会被 GitHub 自动折叠或仍需人工回复
2. 若还有残留 open threads优先区分“远端未刷新 / 已过时评论 / 仍成立问题”,不要再把 review body 摘要和 latest open threads 混在一起处理

View File

@ -0,0 +1,98 @@
# CQRS 与 Mediator 评估归档RP-063
## 背景
- 本轮目的不是继续实现 runtime / generator而是基于当前 `feat/cqrs-optimization` 工作树事实,
评估 `GFramework.Cqrs` 相对 `ai-libs/Mediator` 的替代完成度、设计吸收度与后续可借鉴项。
- 本评估只使用仓库内现有实现、文档、`ai-plan` 记录与只读第三方参考副本 `ai-libs/Mediator`
## 评估结论
### 1. 当前阶段
- `cqrs-rewrite` 当前处于 `Phase 8`,恢复点提升到 `CQRS-REWRITE-RP-063`
- 当前主线已从“移除外部依赖并让默认 runtime 可用”转入“继续扩大 generator 覆盖、继续压低 dispatch /
invoker 反射占比、继续收口 facade 与兼容层”的中后期收敛阶段。
- 这一结论延续了 active tracking 中的 `RP-062` 事实,不代表回退到早期迁移阶段。
### 2. 对外部 Mediator 的替代完成度
- 生产依赖层面:已基本完成替代。
- `GFramework.Cqrs` 当前不再引用外部 `Mediator` 包。
- 默认 runtime 已切换为自有 `CqrsDispatcher``CqrsHandlerRegistrar``CqrsRuntimeFactory`
- `GFramework.Core` 通过 `CqrsRuntimeModule` 自动接线默认 CQRS runtime。
- 运行时主路径层面:已基本完成替代。
- `ArchitectureContext` 已提供统一的 `SendRequestAsync``PublishAsync``CreateStream` 入口。
- handler 注册链路已以 generated registry 优先、targeted fallback 次之、整程序集扫描兜底为主。
- 结论:
- 若“完全替代”指“不再依赖外部 `Mediator` 作为生产 runtime”答案是 `是`
- 若“完全替代”指“仓库内部所有旧总线、旧 seam、旧命名与兼容入口都已删除”答案是 `否`
### 3. 对 Mediator 设计思想的吸收度
- 已吸收:
- 统一 request / command / query / notification / stream 消息模型。
- 源码生成优先、反射 fallback 次之的注册策略。
- 通过缓存压低 registrar / dispatcher 热路径上的重复反射成本。
- 将 CQRS runtime 明确接入框架架构上下文,而不是维持独立外部服务入口。
- 部分吸收:
- `GFramework.Cqrs.SourceGenerators` 已深度参与 handler 注册,但当前仍主要生成 registry 与 fallback 元数据,
而非像 `Mediator` 那样进一步生成 runtime 主体或更大范围的 DI glue。
- 尚未充分吸收:
- notification publisher 作为可替换策略的一等抽象。
- stream pipeline、pre/post processor、exception pipeline 这类更细粒度的扩展分层。
- telemetry、diagnostics、benchmark、allocation tracking 作为框架主能力的完整体系。
- 进一步把强类型 dispatch / invoker 主体前移到生成器,而不是继续依赖运行时 `MakeGenericMethod` +
`Delegate.CreateDelegate` 后再缓存。
### 4. 仓库内部仍未完全收口的部分
- 旧 `GFramework.Core.Command` / `GFramework.Core.Query` 路径仍然存在。
- `ArchitectureContext` 仍同时暴露旧 Command / Query 路径与新 CQRS 路径。
- `CqrsRuntimeModule` 仍注册 `LegacyICqrsRuntime = GFramework.Core.Abstractions.Cqrs.ICqrsRuntime` 兼容别名。
- `CqrsReflectionFallbackAttribute` 仍保留空 marker 与字符串 fallback 语义,说明 runtime 继续承担旧版兼容契约。
- `GFramework.Cqrs.Tests/Mediator/` 目录与若干测试类仍沿用 `Mediator` 命名,但测试内部实际已调用当前 CQRS runtime。
- 结论:当前真正残留的主要是兼容层、旧术语与评估/测试命名,而不是外部运行时依赖本身。
## 关键证据
- 当前阶段与主线:
- `ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md`
- `ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md`
- 自有 runtime 与默认接线:
- `GFramework.Cqrs/CqrsRuntimeFactory.cs`
- `GFramework.Cqrs/Internal/CqrsDispatcher.cs`
- `GFramework.Cqrs/Internal/CqrsHandlerRegistrar.cs`
- `GFramework.Core/Services/Modules/CqrsRuntimeModule.cs`
- `GFramework.Core/Architectures/ArchitectureContext.cs`
- generator 与 fallback 合同:
- `GFramework.Cqrs.SourceGenerators/README.md`
- `GFramework.Cqrs/CqrsReflectionFallbackAttribute.cs`
- `docs/zh-CN/core/cqrs.md`
- 第三方参考源:
- `ai-libs/Mediator/README.md`
- `ai-libs/Mediator/src/Mediator.SourceGenerator/**`
- `ai-libs/Mediator/src/Mediator/INotificationPublisher.cs`
- `ai-libs/Mediator/benchmarks/README.md`
## 建议优先级
### P1
- 评估是否继续前移 generator 职责,生成部分强类型 dispatch / invoker 主体,而不再只停留在 registry。
- 为 notification fan-out 引入可替换 publisher seam并先定义顺序与并发两种语义模型。
### P2
- 扩展 pipeline 体系,评估 stream pipeline、pre-processor、post-processor、exception handler 的契约边界。
- 为 CQRS runtime 设计 tracing / metrics seam至少先完成 contracts 与默认 no-op / logger 对齐方案评估。
### P3
- 建立 CQRS runtime 的 benchmark / allocation 基线,让“继续压低反射成本”从经验判断转为可量化验证。
- 单独规划旧 `Command` / `Query``LegacyICqrsRuntime` 与测试命名的收口顺序,不与 runtime 微优化混在同一波。
## 默认下一步
1. 以 `notification publisher seam``dispatch/invoker 生成前移` 为首轮设计评估对象。
2. 若进入实现阶段,优先做 seam 与契约扩展,再决定是否调整旧测试目录与历史命名。

View File

@ -7,10 +7,48 @@ CQRS 迁移与收敛。
## 当前恢复点
- 恢复点编号:`CQRS-REWRITE-RP-062`
- 恢复点编号:`CQRS-REWRITE-RP-067`
- 当前阶段:`Phase 8`
- 当前焦点:
- 已完成一轮 `CQRS vs Mediator` 只读评估归档,结论已沉淀到 `archive/todos/cqrs-vs-mediator-assessment-rp063.md`
- 当前评估结论已明确:`GFramework.Cqrs` 已完成对外部 `Mediator` 的生产级替代,但仓库内部旧总线 API、
兼容 seam、fallback 旧语义与测试命名仍未完全收口
- 当前评估结论已明确:相对 `ai-libs/Mediator`框架已吸收统一消息模型、generator 优先注册与热路径缓存思路,
但仍未完整吸收 publisher 策略抽象、细粒度 pipeline、telemetry / diagnostics / benchmark 体系与 runtime 主体生成
- 下一阶段建议优先级已收敛为:`notification publisher seam``dispatch/invoker 生成前移``pipeline 分层扩展`
`可观测性 seam``benchmark / allocation baseline`
- 当前功能历史已归档active 跟踪仅保留 `Phase 8` 主线的恢复入口
- 已完成一轮 notification publisher seam 最小落地:`GFramework.Cqrs` 新增 `INotificationPublisher`
`NotificationPublishContext<TNotification>` 与默认 `SequentialNotificationPublisher`
- `CqrsDispatcher` 现会在解析当前通知处理器集合后,把执行顺序委托给 publisher seam默认行为仍保持
“零处理器静默完成、顺序执行、首错即停”
- `CqrsRuntimeFactory``CqrsRuntimeModule``GFramework.Tests.Common.CqrsTestRuntime` 现支持在 runtime 创建前复用
容器里已显式注册的 `INotificationPublisher`
- 已补充 `CqrsNotificationPublisherTests`,覆盖自定义 publisher 接管、上下文注入、零处理器静默完成、首错即停,以及
`RegisterInfrastructure` 默认接线复用预注册 publisher 的回归
- 已完成一轮 `Mediator` 测试命名收口:
- `MediatorAdvancedFeaturesTests` -> `CqrsArchitectureContextAdvancedFeaturesTests`
- `MediatorArchitectureIntegrationTests` -> `CqrsArchitectureContextIntegrationTests`
- `MediatorComprehensiveTests` -> `ArchitectureContextComprehensiveTests`
- `GFramework.Cqrs.Tests` 中这三份历史测试现已统一迁入 `Cqrs/` 目录,并将命名空间、类名、中文注释与嵌套测试类型中的
`Mediator` 语义收口为 `CQRS` / `ArchitectureContext`
- 已补充 `ArchitectureContextTests` 并发 lazy-resolution 回归,锁定 `PublishAsync(...)``CreateStream(...)`
在并发首次访问时也只会解析一次 `ICqrsRuntime`
- 已完成一轮 `LegacyICqrsRuntime` compatibility slice 收口:
- `CqrsRuntimeModule``GFramework.Tests.Common.CqrsTestRuntime` 现把 legacy alias 注册收敛到显式 helper
- `MicrosoftDiContainerTests` 已补充“只预注册正式 `ICqrsRuntime` seam 时,也会回填 legacy alias 且保持同实例”的回归
- `GFramework.Core.Abstractions/README.md``docs/zh-CN/abstractions/core-abstractions.md`
`docs/zh-CN/core/cqrs.md` 现已明确:旧命名空间下的 `ICqrsRuntime` 仅作为 compatibility alias 保留,
新代码应直接依赖 `GFramework.Cqrs.Abstractions.Cqrs.ICqrsRuntime`
- 已完成一轮 `dispatch/invoker` 生成前移的最小 request 切片:
- `GFramework.Cqrs` 新增 `ICqrsRequestInvokerProvider``IEnumeratesCqrsRequestInvokerDescriptors`
`CqrsRequestInvokerDescriptor``CqrsRequestInvokerDescriptorEntry`
- generated registry 若实现 request invoker provider 契约,`CqrsHandlerRegistrar` 现会在激活 registry 后把 provider 注册进容器,
并把 provider 枚举出的 request invoker 描述符写入 dispatcher 的进程级弱缓存
- `CqrsDispatcher` 现会在首次创建 request dispatch binding 时优先命中 generated request invoker 描述符;
未命中时仍回退到既有 `MakeGenericMethod + Delegate.CreateDelegate` 路径
- `GFramework.Cqrs.Tests` 已补充 `CqrsGeneratedRequestInvokerProviderTests`,锁定 registrar 接线和 dispatcher 消费 generated invoker 的最小语义
- `GFramework.SourceGenerators.Tests` 已补充 generator 回归,锁定当 runtime 暴露新契约时generated registry 会额外发射 request invoker provider 成员与 invoker 方法
- 已将 mixed fallback 场景进一步收敛:当 runtime 允许同一程序集声明多个 `CqrsReflectionFallbackAttribute` 实例时generator 现会把可直接引用的 fallback handlers 与仅能按名称恢复的 fallback handlers 拆分发射
- `CqrsReflectionFallbackAttribute` 现允许多实例,以承载 `Type[]` 与字符串 fallback 元数据的组合输出
- 已将 generator 的程序集级 fallback 元数据进一步收敛:当全部 fallback handlers 都可直接引用且 runtime 暴露 `params Type[]` 合同时,生成器现优先发射 `typeof(...)` 形式的 fallback 元数据
@ -41,6 +79,7 @@ CQRS 迁移与收敛。
- 已完成 `GFramework.Cqrs.Abstractions` / `GFramework.Cqrs` 项目骨架与 runtime seam 收敛
- 已完成 handler registry generator 的多轮收敛,当前合法 closed handler contract 已统一收敛到更窄的注册路径
- 已完成一轮公开入口文档与 source-generator 命名空间收口
- 已完成一轮 `CQRS vs Mediator` 对照评估,确认当前主问题已从“是否能替代外部依赖”转为“框架内部收口与能力深化顺序”
- 已接入 `$gframework-pr-review`,可直接抓取当前分支对应 PR 的 CodeRabbit 评论、checks 和测试结果
## 当前活跃事实
@ -84,10 +123,11 @@ CQRS 迁移与收敛。
- 本地核对后确认 `dotnet-format` 仍只有 `Restore operation failed` 噪音,没有附带当前仍成立的文件级格式诊断
- 已按 review triage 修正 generator source preamble 的多实例 fallback 特性排版、移除死参数,并补强 mixed/direct fallback 发射回归断言与 XML 文档
- `2026-04-30` 已重新执行 `$gframework-pr-review`
- 当前分支对应 `PR #304`,状态为 `OPEN`
- latest reviewed commit 当前剩余 `7` 条 CodeRabbit nitpick 与 `2` 条 Greptile open threads集中在测试脆弱断言、共享测试状态并发保护以及 `CqrsDispatcher` 的缓存线程模型文档
- 本地核对后已确认这些评论仍对应当前代码MegaLinter 继续只暴露 `dotnet-format``Restore operation failed` 环境噪音CTRF 汇总为 `2203/2203` passed
- 已在本地完成 follow-uprequest pipeline invoker 改为 binding 级复用、共享测试状态切换到 `System.Threading.Lock` 保护、顺序测试改为受控记录接口、`CqrsDispatcherCacheTests` 标记为 `NonParallelizable`,并补齐相关 XML / 线程模型注释
- 当前分支对应 `PR #305`,状态为 `OPEN`
- 当前抓取到 `9` 条 CodeRabbit open threads、`2` 条 Greptile open threads远端 CTRF 汇总为 `2214/2214` passedMegaLinter 仍只暴露 `dotnet-format``Restore operation failed` 环境噪音
- 本地核对后,已确认以下评论仍然成立并已完成修正:`ArchitectureContextTests` 并发测试失败路径释放、`CqrsGeneratedRequestInvokerProviderTests` 的全局 logger provider 恢复与私有缓存断言解耦、`CqrsArchitectureContextIntegrationTests` 的真实上下文注入断言、`GeneratedRequestInvokerRequest` / `INotificationPublisher` XML 文档、`CqrsHandlerRegistrar` 的 provider 注册顺序、`CqrsTestRuntime` 的 legacy alias 显式失败模式,以及 `cqrs-rewrite` trace 重复标题
- 对于 `ICqrsRequestInvokerProvider` / generated `TryGetDescriptor(...)` 相关 Greptile 评论,本地评估后未改 dispatcher 热路径语义;改为补齐公开注释与生成器方法级注释,明确默认 runtime 只在注册阶段经 `IEnumeratesCqrsRequestInvokerDescriptors` 预热缓存,`TryGetDescriptor(...)` 保留为显式查询 seam
- 本轮额外修正了 `GFramework.SourceGenerators.Tests` 中先读取 `GeneratedSources[0]` 再断言长度的脆弱顺序,并将 `ArchitectureContextTests` 的并发 orchestration 收敛到公共 helper消除本轮引入的 `MA0051` warning
- `2026-04-29` 已完成一轮 precise runtime type lookup 的数组回归补强:
- `GFramework.SourceGenerators.Tests` 已新增多维数组、交错数组、外部程序集隐藏元素类型三类回归
- 当前生成器在 precise runtime type lookup 下已稳定保留数组秩信息,并递归发射交错数组的 `MakeArrayType()`
@ -129,20 +169,41 @@ CQRS 迁移与收敛。
- `SourceEmission` 不再保留 `MakePointerType()` 源码发射分支,`RuntimeTypeReferences` 也已删掉对应的外部程序集递归扫描死代码
- pointer / function pointer 的拒绝语义保持不变direct / named / mixed fallback 逻辑未改动
- 当前工作区相对 `origin/main` 的累计 diff 已达到 `14 files`,仍低于本轮 `gframework-batch-boot 50` 的主要 stop condition
- `2026-04-30` 已完成一轮 `CQRS vs Mediator` 结构化评估:
- 生产依赖与默认 runtime 接线层面,`GFramework.Cqrs` 已完成对外部 `Mediator` 的替代
- 仓库内部收口层面,旧 `Command` / `Query` API、`LegacyICqrsRuntime` 别名、fallback 空 marker 兼容语义与
`Mediator` 测试命名仍然存在
- 设计吸收层面当前已吸收统一消息模型、generator 优先注册与反射收敛思路;仍未完整吸收 publisher 策略抽象、
stream / exception pipeline、telemetry / diagnostics / benchmark 体系与 runtime 主体生成
- 详细结论与证据已归档到 `archive/todos/cqrs-vs-mediator-assessment-rp063.md`
- `2026-04-30` 已接受两条只读 subagent 结论并完成 notification publisher seam 最小实现:
- 相对 `ai-libs/Mediator`,本轮只吸收 notification publisher 的策略接缝,不照搬 `NotificationHandlers<T>` 包装、
并行 publisher 或异常聚合语义
- 当前 seam 刻意保持在默认 runtime 内部:`ICqrsRuntime.PublishAsync(...)` 外形不变dispatcher 仍负责 handler 解析与
`IContextAware` 上下文注入
- 用户若需替换通知发布策略,只需在 runtime 创建前向容器显式注册 `INotificationPublisher`
- `2026-04-30` 已接受三条 worker 切片并完成一轮测试命名收口:
- 三个 worker 分别独立拥有一份 `GFramework.Cqrs.Tests/Mediator/*.cs` 文件,主线程只做集成验证与后续追踪更新
- 当前分支已不再保留 `GFramework.Cqrs.Tests/Mediator/` 目录下的生产内涵测试,相关文件均迁移到 `GFramework.Cqrs.Tests/Cqrs/`
- 本轮没有修改测试行为,只收口命名、注释、局部变量与嵌套测试类型语义
- 当前主线优先级:
- generator 覆盖面继续扩大
- dispatch/invoker 反射占比继续下降
- dispatch/invoker 反射占比继续下降,并优先评估生成前移方案
- 基于已落地 publisher seam继续评估是否需要公开配置面、并行策略或 telemetry decorator
- package / facade / 兼容层继续收口
- pipeline 分层扩展、可观测性 seam 与 benchmark baseline 进入中期候选
## 当前风险
- 当前 `dotnet build GFramework.sln -c Release` 在 WSL 环境仍会受顶层 `GFramework.csproj` 的 Windows NuGet fallback 配置影响
- 当前 `GFramework.Cqrs.Tests` 仍直接引用 `GFramework.Core`,说明测试已按模块意图拆分,但 runtime 物理迁移尚未完全切断依赖
- 当前对外替代已基本完成,但若不单独规划旧 `Command` / `Query``LegacyICqrsRuntime` 与测试命名的收口顺序,
后续仍会持续混淆“生产替代已完成”与“仓库内部收口未完成”这两个不同结论
## 活跃文档
- 历史跟踪归档:[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)
- 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)
@ -152,14 +213,61 @@ CQRS 迁移与收敛。
- `RP-046``RP-062` 的历史验证命令与阶段性结果已移入验证归档active tracking 只保留当前恢复入口需要的最新验证
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --format json --json-output /tmp/current-pr-review.json`
- 结果:通过
- 备注:确认当前分支对应 `PR #304`,并定位到仍需本地复核的 CodeRabbit / Greptile open thread
- 备注:确认当前分支对应 `PR #305`,并定位到仍需本地复核的 CodeRabbit / Greptile open thread
- `dotnet build GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release`
- 结果:通过
- 备注:`0 warning / 0 error`;本轮确认 XML 文档补齐、`NonParallelizable``_syncRoot` 命名与 `ai-plan` 收敛未引入新增编译问题
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --filter "FullyQualifiedName~CqrsGeneratedRequestInvokerProviderTests|FullyQualifiedName~CqrsArchitectureContextIntegrationTests.Handler_Can_Access_Architecture_Context|FullyQualifiedName~CqrsArchitectureContextAdvancedFeaturesTests.Request_With_Retry_Behavior_Should_Succeed_On_First_Attempt|FullyQualifiedName~CqrsArchitectureContextAdvancedFeaturesTests.Transient_Error_Request_Should_Succeed_Without_Simulated_Errors"`
- 结果:通过
- 备注:`5/5` passed覆盖 generated invoker provider、真实上下文注入与两条重命名高级行为测试
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~SendRequestAsync_Should_ResolveCqrsRuntime_OnlyOnce_When_AccessedConcurrently|FullyQualifiedName~PublishAsync_Should_ResolveCqrsRuntime_OnlyOnce_When_AccessedConcurrently|FullyQualifiedName~CreateStream_Should_ResolveCqrsRuntime_OnlyOnce_When_AccessedConcurrently"`
- 结果:通过
- 备注:`3/3` passed确认并发首次解析测试在失败路径释放调整后保持通过
- `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --filter "FullyQualifiedName~Emits_Request_Invoker_Provider_Metadata_When_Runtime_Contract_Is_Available|FullyQualifiedName~Emits_Direct_Type_Fallback_Metadata_When_All_Fallback_Handlers_Are_Referenceable_And_Runtime_Type_Contract_Is_Available|FullyQualifiedName~Emits_Mixed_Direct_Type_And_String_Fallback_Metadata_When_Runtime_Allows_Multiple_Fallback_Attributes"`
- 结果:通过
- 备注:`3/3` passed确认 provider 生成分支注释与断言顺序修正未改变生成语义
- `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release`
- 结果:通过
- 备注:构建成功;并行验证期间出现过 `MSB3026` 拷贝重试噪音,属于同时运行多个 `dotnet` 命令时的输出文件竞争,不是持久性编译 warning
- `bash scripts/validate-csharp-naming.sh`
- 结果:通过
- 备注:使用显式 `GIT_DIR` / `GIT_WORK_TREE` 绑定重跑后,`1045` 个 tracked C# 文件的命名校验全部通过;本轮 `_syncRoot` 改名未引入命名规则回归
- `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release`
- 结果:通过
- 备注:`0 warning / 0 error`;本轮确认 notification publisher seam、README 与文档更新未引入 `GFramework.Cqrs` 构建告警
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
- 结果:通过
- 备注:`0 warning / 0 error`;确认 `CqrsRuntimeModule` 接线变更未引入 `GFramework.Core` 模块构建问题
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --filter "FullyQualifiedName~CqrsNotificationPublisherTests"`
- 结果:通过
- 备注:`5/5` 通过;覆盖自定义 publisher 顺序、上下文注入、零处理器、首错即停与默认接线复用
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~MicrosoftDiContainerTests"`
- 结果:通过
- 备注:`41/41` 通过;确认 CQRS 基础设施默认接线与容器行为未回归
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~MicrosoftDiContainerTests"`
- 结果:通过
- 备注:`42/42` 通过;本轮新增 legacy alias 回填回归后,确认正式 seam 与旧命名空间 alias 仍指向同一实例
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
- 结果:通过
- 备注:`0 warning / 0 error`;确认 legacy alias helper 收敛与文档更新未引入 `GFramework.Core` 模块构建告警
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --filter "FullyQualifiedName~CqrsGeneratedRequestInvokerProviderTests|FullyQualifiedName~CqrsHandlerRegistrarTests|FullyQualifiedName~CqrsDispatcherCacheTests"`
- 结果:通过
- 备注:`22/22` 通过;确认 generated request invoker provider 的 registrar 接线、dispatcher 消费与现有 request/notification/stream cache 语义未回归
- `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --filter "FullyQualifiedName~CqrsHandlerRegistryGeneratorTests.Emits_Request_Invoker_Provider_Metadata_When_Runtime_Contract_Is_Available"`
- 结果:通过
- 备注:`1/1` 通过;锁定 generator 会在 runtime 合同可用时发射 request invoker provider 成员
- `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release`
- 结果:通过
- 备注:`0 warning / 0 error`;确认 request invoker provider seam 与 dispatcher/registrar 接线未引入新增构建告警
- `dotnet build GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release`
- 结果:通过
- 备注:`0 warning / 0 error`;确认三份 `Mediator` 命名收口后的 CQRS 测试项目构建仍然干净
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~ArchitectureContextTests"`
- 结果:通过
- 备注:`22/22` 通过;新增 `PublishAsync` / `CreateStream` 并发首次访问只解析一次 `ICqrsRuntime` 的回归
## 下一步
1. push 当前 follow-up 提交后,重新执行 `$gframework-pr-review`,确认 `PR #304` 的 latest unresolved threads 是否已刷新为已解决,或仅剩新增有效项
1. 基于已落地的 notification publisher seam评估是否需要第二阶段公开配置面、并行 publisher 或 telemetry decorator
2. 基于已落地的 request invoker provider评估是否继续把 notification / stream 的 invoker 也前移,或先补 provider 发现/诊断与文档入口
3. 单独规划旧 `Command` / `Query` API 的收口顺序;`LegacyICqrsRuntime` compatibility slice 已收口到显式 helper 与专门测试,可暂时移出最高优先级

View File

@ -2,36 +2,169 @@
## 2026-04-30
### 阶段:PR #304 剩余 review follow-up 收敛CQRS-REWRITE-RP-062
### 阶段:generated request invoker provider 最小落地CQRS-REWRITE-RP-067
- 本轮再次执行 `$gframework-pr-review`,确认当前分支 `feat/cqrs-optimization` 仍对应 `PR #304`
- 本地复核后继续收敛了上一轮遗留的 review 项:
- `GFramework.Cqrs.Tests/Cqrs/CqrsHandlerRegistrarFallbackFailureTests.cs` 已补 `NonParallelizable`
- `GFramework.Cqrs.Tests/Cqrs/DispatcherStreamContextRefreshState.cs` 已改用 `_syncRoot` 命名,并补齐缺失的 XML 文档标签
- `GFramework.Cqrs.Tests/Cqrs/CqrsDispatcherContextValidationTests.cs` 三个内部 `Handle(...)` 已补齐 XML `param` / `returns`
- `DispatcherNotificationContextRefreshNotification``DispatcherStreamContextRefreshRequest` 已补 `DispatchId` XML 参数注释
- `cqrs-rewrite` active tracking / trace 已压缩为当前恢复入口,并将已完成阶段的详细历史移入 archive
- 验证:
- `dotnet build GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release`
- 继续按 `gframework-batch-boot 50` 执行,基线仍为本地现有 `origin/main`
- 在 `RP-066` 提交后复算 branch diff相对 `origin/main` 增长到 `22 files`,仍明显低于 `50 files` stop condition因此继续下一批
- 本轮 critical path 保持在主线程,本地完成 `dispatch/invoker` 生成前移的最小 request 切片;尝试委派 source-generator 测试给 worker 时因 subagent 名额已满失败,因此主线程直接接管该测试修改
- 本轮关键设计调整:
- 不按 `requestType.Assembly` 做 provider 发现,避免“请求定义在 A、handler 与 generated registry 在 B”时漏掉 generated invoker
- generated registry 若实现 `ICqrsRequestInvokerProvider`registrar 会在激活 registry 后把 provider 注册进容器,并通过 `IEnumeratesCqrsRequestInvokerDescriptors` 把描述符写入 dispatcher 的进程级弱缓存
- dispatcher 首次创建 request dispatch binding 时只按 `requestType + responseType` 读取静态弱缓存,不依赖具体容器实例;未命中时仍走既有反射创建路径
- 已完成实现:
- `GFramework.Cqrs` 新增 `ICqrsRequestInvokerProvider``IEnumeratesCqrsRequestInvokerDescriptors`
`CqrsRequestInvokerDescriptor``CqrsRequestInvokerDescriptorEntry`
- `CqrsHandlerRegistrar` 现会识别 generated registry 的 request invoker provider 能力,并登记 provider 与 request invoker 描述符
- `CqrsDispatcher` 新增 generated request invoker 弱缓存,并在 request binding 创建时优先消费该元数据
- `CqrsHandlerRegistryGenerator` 在 runtime 合同可用时,会让 generated registry 额外实现 request invoker provider 相关接口,并发射 descriptor 列表、`TryGetDescriptor(...)``GetDescriptors()` 与 request invoker 静态方法
- 已补充测试:
- `CqrsGeneratedRequestInvokerProviderTests` 锁定 registrar 会注册 generated request invoker provider且 dispatcher 走 generated invoker 后会返回 `generated:` 前缀结果
- `CqrsHandlerRegistryGeneratorTests` 锁定 generated source 会包含 request invoker provider 接口、descriptor 条目与 `InvokeRequestHandler0(...)` 方法
### 验证RP-067
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --filter "FullyQualifiedName~CqrsGeneratedRequestInvokerProviderTests|FullyQualifiedName~CqrsHandlerRegistrarTests|FullyQualifiedName~CqrsDispatcherCacheTests"`
- 结果:通过,`22/22` passed
- `dotnet test GFramework.SourceGenerators.Tests/GFramework.SourceGenerators.Tests.csproj -c Release --filter "FullyQualifiedName~CqrsHandlerRegistryGeneratorTests.Emits_Request_Invoker_Provider_Metadata_When_Runtime_Contract_Is_Available"`
- 结果:通过,`1/1` passed
- `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release`
- 结果:通过,`0 warning / 0 error`
### 当前下一步RP-067
1. 评估 notification / stream invoker 是否值得沿同一 provider 模式继续前移,或先补 request provider 的公开说明与诊断语义
2. 继续在保持 branch diff 低于阈值的前提下推进下一批;当前相对 `origin/main` 的 branch diff 为 `22 files`
### 阶段LegacyICqrsRuntime compatibility slice 收口CQRS-REWRITE-RP-066
- 继续按 `gframework-batch-boot 50` 执行,基线仍为本地现有 `origin/main`
- 在 `RP-065` 之后复算 branch diff相对 `origin/main` 仍为 `19 files`,明显低于 `50 files` stop condition因此继续下一批
- 本轮按“关键路径本地、非冲突文档委派”的方式拆成两个切片:
- worker`GFramework.Core.Abstractions/README.md``docs/zh-CN/abstractions/core-abstractions.md``docs/zh-CN/core/cqrs.md`
- 主线程:`GFramework.Core/Services/Modules/CqrsRuntimeModule.cs``GFramework.Tests.Common/CqrsTestRuntime.cs``GFramework.Core.Tests/Ioc/MicrosoftDiContainerTests.cs`
- 接受只读 subagent 结论后,将 `LegacyICqrsRuntime` 定位为“容器兼容层”,明确本轮不删除别名、不改 dispatcher 主体、不与旧 `Command` / `Query` API 清理混做
- 主线程已完成:
- `CqrsRuntimeModule` 把 legacy alias 注册收敛到 `RegisterLegacyRuntimeAlias(...)` helper并在 XML 文档里明确新旧服务类型解析到同一 runtime 实例
- `CqrsTestRuntime.RegisterInfrastructure(...)` 现也通过同名 helper 补齐 legacy alias当容器只预注册正式 `ICqrsRuntime` seam 时,会在幂等接线时回填旧命名空间 alias
- `MicrosoftDiContainerTests` 新增 `RegisterInfrastructure_Should_Backfill_Legacy_Cqrs_Runtime_Alias_With_The_Same_Instance`,锁定“只存在正式 seam 时也会补旧 alias且两者仍指向同一实例”的兼容合同
- worker 已完成文档收口:
- `GFramework.Core.Abstractions/README.md`
- `docs/zh-CN/abstractions/core-abstractions.md`
- `docs/zh-CN/core/cqrs.md`
- 三处文档都已明确:`GFramework.Core.Abstractions.Cqrs.ICqrsRuntime` 只是旧命名空间下保留的 compatibility alias新代码应依赖 `GFramework.Cqrs.Abstractions.Cqrs.ICqrsRuntime`
### 验证RP-066
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~MicrosoftDiContainerTests"`
- 结果:通过,`42/42` passed
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
- 结果:通过,`0 warning / 0 error`
### 当前下一步RP-066
1. 在保持 branch diff 低于阈值的前提下,回到 `dispatch/invoker` 生成前移主线
2. 优先尝试只覆盖 request 路径的 generated invoker/provider 最小切片,避免一次卷入 notification / stream / pipeline executor
3. 下一次 batch 结束后继续复算 branch diff确认距 `50 files` stop condition 的剩余 headroom
### 阶段:测试命名收口与 ArchitectureContext lazy-resolution 回归CQRS-REWRITE-RP-065
- 继续按 `gframework-batch-boot 50` 执行,基线仍为本地现有 `origin/main`
- `22f608eb` 之后复算 branch diff相对 `origin/main` 已达到 `18 files`,仍明显低于 `50 files` stop condition因此继续下一批
- 本轮拆成四个互不冲突切片:
- worker 1`MediatorAdvancedFeaturesTests.cs`
- worker 2`MediatorArchitectureIntegrationTests.cs`
- worker 3`MediatorComprehensiveTests.cs`
- 主线程:`GFramework.Core.Tests/Architectures/ArchitectureContextTests.cs`
- 三个 worker 均只收口单文件命名与注释语义,并把测试文件迁移到 `GFramework.Cqrs.Tests/Cqrs/`
- 主线程新增 `ArchitectureContextTests` 并发 lazy-resolution 回归,锁定:
- `PublishAsync(...)` 在并发首次访问时只解析一次 `ICqrsRuntime`
- `CreateStream(...)` 在并发首次访问时只解析一次 `ICqrsRuntime`
- 集成后已确认三份测试文件中不再残留 `GFramework.Cqrs.Tests.Mediator` 命名空间或 `Mediator` 语义命名
### 验证RP-065
- `dotnet build GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release`
- 结果:通过,`0 warning / 0 error`
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~ArchitectureContextTests"`
- 结果:通过,`22/22` passed
### 当前下一步RP-065
1. 继续 `Phase 8` 主线,回到 `dispatch/invoker` 生成前移或 `LegacyICqrsRuntime` 收口的下一个低风险切片
2. 在下一次 batch 结束后复算 branch diff确认距 `50 files` stop condition 的剩余 headroom
### 阶段notification publisher seam 最小落地CQRS-REWRITE-RP-064
- 本轮按 `gframework-batch-boot 50` 继续 `cqrs-rewrite`,基线使用本地现有 `origin/main`
- 当前 branch diff 相对 `origin/main` 开始时仅 `3 files / 164 lines`,远低于 `50 files` stop condition因此继续推进真实代码切片
- 主线程锁定 `notification publisher seam` 为本轮最低风险高收益切片,并保持关键路径在本地实现
- 接受两条只读 subagent 结论:
- 对照 `ai-libs/Mediator` 后,只吸收 notification publisher 策略接缝,不在本轮引入并行 publisher、异常聚合或公开配置面
- 现有仓库测试需要锁定的兼容语义是:零处理器静默完成、顺序执行、首错即停、上下文逐次注入
- 已完成实现:
- `GFramework.Cqrs` 新增 `INotificationPublisher``NotificationPublishContext<TNotification>`
`DelegatingNotificationPublishContext<TNotification, TState>` 与默认 `SequentialNotificationPublisher`
- `CqrsDispatcher.PublishAsync(...)` 改为解析 handlers 后构造发布上下文,并委托给 publisher seam 执行
- `CqrsRuntimeFactory``CqrsRuntimeModule``GFramework.Tests.Common.CqrsTestRuntime` 现会在 runtime 创建前复用容器里已注册的 `INotificationPublisher`
- `GFramework.Cqrs.Tests` 新增 `CqrsNotificationPublisherTests`,覆盖自定义 publisher、上下文注入、零处理器、首错即停与默认接线复用
- `GFramework.Cqrs/README.md``docs/zh-CN/core/cqrs.md` 已同步说明默认通知语义与可替换 seam
- 中途验证曾因并行 .NET 构建产生输出文件锁噪音;已改为串行重跑并获取干净结果
### 验证RP-064
- `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release`
- 结果:通过,`0 warning / 0 error`
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
- 结果:通过,`0 warning / 0 error`
- `dotnet test GFramework.Cqrs.Tests/GFramework.Cqrs.Tests.csproj -c Release --filter "FullyQualifiedName~CqrsNotificationPublisherTests"`
- 结果:通过,`5/5` passed
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~MicrosoftDiContainerTests"`
- 结果:通过,`41/41` passed
- `GIT_DIR=/mnt/f/gewuyou/System/Documents/WorkSpace/GameDev/GFramework/.git/worktrees/GFramework-cqrs GIT_WORK_TREE=/mnt/f/gewuyou/System/Documents/WorkSpace/GameDev/GFramework-WorkTree/GFramework-cqrs bash scripts/validate-csharp-naming.sh`
- 结果:通过
### 当前下一步RP-064
1. 评估 notification publisher seam 的第二阶段是否需要公开配置面、并行 publisher 或 telemetry decorator
2. 把 `dispatch/invoker` 生成前移重新拉回 `Phase 8` 主线,作为下一个实现切片
### 阶段CQRS vs Mediator 评估归档CQRS-REWRITE-RP-063
- 本轮按用户要求使用 `gframework-boot` 启动上下文后,先完成 `cqrs-rewrite` 现状核对,再并行对照
`GFramework.Cqrs``ai-libs/Mediator`
- 只读评估结论已归档到 `ai-plan/public/cqrs-rewrite/archive/todos/cqrs-vs-mediator-assessment-rp063.md`
- 本轮关键判断:
- `GFramework.Cqrs` 已完成对外部 `Mediator` 作为生产 runtime 依赖的替代
- 当前尚未完成的是仓库内部旧 `Command` / `Query` API、兼容 seam、fallback 旧语义与测试命名的收口
- 当前已吸收 `Mediator` 的统一消息模型、generator 优先注册与热路径缓存思路
- 当前仍未完整吸收 publisher 策略抽象、细粒度 pipeline、telemetry / diagnostics / benchmark 体系与 runtime 主体生成
- 本轮把默认下一步从“继续盯 PR thread”调整为“围绕 publisher seam 与 dispatch/invoker 生成前移做下一轮设计收敛”
### 验证RP-063
- `dotnet build GFramework.Cqrs/GFramework.Cqrs.csproj -c Release`
- 结果:通过,`0 warning / 0 error`
## 活跃事实
- 当前主题仍处于 `Phase 8`
- `PR #304` 的本地 follow-up 已再次收口一轮,后续需要在 push 后重新观察 GitHub 的 unresolved thread 刷新结果
- 当前主题的主问题已从“是否完成外部依赖替代”转为“内部兼容层收口顺序与下一轮能力深化优先级”
- 已完成阶段的详细执行历史不再留在 active trace默认恢复入口只保留当前恢复点、活跃事实、风险与下一步
## 当前风险
- 当前 `dotnet build GFramework.sln -c Release` 在 WSL 环境仍会受顶层 `GFramework.csproj` 的 Windows NuGet fallback 配置影响
- 远端 review thread 在本地提交前不会自动刷新GitHub 上看到的 open 状态可能暂时滞后于当前代码
- 若不把“生产替代完成”与“仓库内部收口完成”分开记录,后续很容易重复争论当前 CQRS 迁移是否已经完成
## Archive Context
- 当前评估归档:
- `ai-plan/public/cqrs-rewrite/archive/todos/cqrs-vs-mediator-assessment-rp063.md`
- 历史 trace 归档:
- `ai-plan/public/cqrs-rewrite/archive/traces/cqrs-rewrite-history-through-rp043.md`
- `ai-plan/public/cqrs-rewrite/archive/traces/cqrs-rewrite-history-rp046-through-rp061.md`
## 当前下一步
1. push 当前 follow-up 提交后,重新执行 `$gframework-pr-review`,确认 `PR #304` 的 latest unresolved threads 是否已刷新为已解决,或仅剩新增有效项
1. 补一轮最小 Release 构建验证,确认本次 `ai-plan` 与评估文档更新未引入仓库级异常
2. 以 `notification publisher seam``dispatch/invoker` 生成前移为优先对象,形成下一轮可执行设计

View File

@ -75,7 +75,10 @@ public sealed class DiagnosticsFeature
- 架构与模块入口:`IArchitecture``IArchitectureContext``IServiceModule`
- 运行时基础设施:`IIocContainer``ILogger``IResourceManager``IConfigurationManager`
- 状态与并发能力:`IStateMachine``IStore``IAsyncKeyLockManager``ITimeProvider`
- 迁移与组合边界:`ICommandExecutor``IQueryExecutor``ICqrsRuntime`
- 迁移与组合边界:`ICommandExecutor``IQueryExecutor`,以及旧命名空间下作为 compatibility alias 暴露的 `ICqrsRuntime`
`GFramework.Core.Abstractions.Cqrs.ICqrsRuntime` 当前主要承担旧命名空间兼容入口的角色。编写新模块或新增请求处理逻辑时,
应直接引用 `GFramework.Cqrs.Abstractions.Cqrs.ICqrsRuntime`,让 runtime seam 与 CQRS 请求契约保持一致。
## 契约族阅读入口
@ -85,7 +88,7 @@ public sealed class DiagnosticsFeature
| --- | --- | --- |
| `Architectures/` | `IArchitecture``IArchitectureContext``IArchitectureServices``IServiceModule` | 架构上下文、服务访问面与模块安装 / 生命周期约束 |
| `Lifecycle/` `Registries/` | `ILifecycle``IAsyncInitializable``IRegistry<T, TR>``KeyValueRegistryBase<TKey, TValue>` | 初始化 / 销毁阶段和注册表抽象边界 |
| `Command/` `Query/` `Cqrs/` | `ICommandExecutor``IAsyncCommand<TResult>``IQueryExecutor``ICqrsRuntime` | 旧命令 / 查询接口与新请求模型之间的兼容和迁移边界 |
| `Command/` `Query/` `Cqrs/` | `ICommandExecutor``IAsyncCommand<TResult>``IQueryExecutor``ICqrsRuntime` | 旧命令 / 查询接口,以及 CQRS runtime compatibility alias 的迁移边界 |
| `Events/` `Property/` | `IEventBus``IEventFilter<T>``IBindableProperty<T>``IReadonlyBindableProperty<T>` | 事件传播、过滤、解绑对象和属性订阅语义 |
| `State/` `StateManagement/` | `IStateMachine``IAsyncState``IStore<TState>``IStoreMiddleware<TState>` | 状态机契约与 Store 的 reducer / middleware / diagnostics 边界 |
| `Coroutine/` `Time/` `Pause/` `Concurrency/` | `IYieldInstruction``ICoroutineStatistics``ITimeProvider``IPauseStackManager``IAsyncKeyLockManager` | 调度模型、时间源、暂停栈和异步锁契约 |

View File

@ -25,6 +25,25 @@ description: GFramework.Game.Abstractions 的契约边界、包关系与源码
- 运行时实现:`GFramework.Game`
- 底层基础契约:`GFramework.Core.Abstractions`
## 配置契约的采用边界
如果你只依赖 `GFramework.Game.Abstractions`,需要额外记住一件事:这里的 `Config/` 只定义“如何注册与访问配置表”的读取契约,不定义
AI-First 配置工作流的完整实现边界。
与配置相关的实际采用路径仍然要回到 `GFramework.Game`
- `YamlConfigLoader``GameConfigBootstrap``GameConfigModule` 等实现都在 `GFramework.Game`
- `GFramework.Game.SourceGenerators` 生成的配置类型,服务的是与 Runtime 对齐的共享 schema 子集
- 共享子集之外的复杂 schema 设计,不会因为你只依赖 abstractions 就自动获得额外支持
这意味着,如果你的 schema 依赖下面这些能力,就不能只停留在 abstractions 视角理解配置契约:
- `oneOf``anyOf` 这类复杂组合关键字
- 非 `false``additionalProperties`
- 其他会引入开放对象形状、联合分支或属性合并漂移的 schema 设计
这些边界由 `GFramework.Game` 与 [配置系统](../game/config-system.md) 负责说明和落地;`GFramework.Game.Abstractions` 本身不重新定义它们。
## 契约地图
| 契约族 | 作用 |
@ -105,6 +124,7 @@ public sealed class ContinueGameCommandHandler
- 使用 `SettingsModel<TRepository>``SettingsSystem``SaveRepository<TSaveData>` 等默认实现
- 使用 `YamlConfigLoader``GameConfigBootstrap``GameConfigModule`
- 继承 `SceneRouterBase``UiRouterBase` 或默认转场处理器基类
- 需要确认 AI-First 配置工作流当前支持的共享 schema 子集,以及 `oneOf` / `anyOf`、非 `false` `additionalProperties` 等不在采用路径内的边界
## 阅读顺序
@ -119,3 +139,5 @@ public sealed class ContinueGameCommandHandler
4. 需要统一入口时,回到:
- [Game 模块总览](../game/index.md)
- [入门指南](../getting-started/index.md)
如果你的关注点是配置契约,请把 [配置系统](../game/config-system.md) 当作下一跳,而不是停留在 abstractions 页面对支持边界做推断。

View File

@ -13,6 +13,8 @@ description: GFramework 的 API 阅读入口,按模块映射 README、专题
2. 再进专题页确认安装、生命周期和推荐接线方式
3. 最后回到源码中的 XML 文档核对具体契约
如果你在阅读 AI-First 配置工作流相关 API先把 `GFramework.Game` Runtime 与 `GFramework.Game.SourceGenerators` 视为正式契约入口,再把 `VS Code` 配置工具视为辅助层。当前默认采用路径围绕共享 schema 子集展开,其中 `additionalProperties: false` 表示闭合对象边界(需显式设置为 `false``oneOf` / `anyOf` 在 Runtime / Generator / Tooling 层面会被直接拒绝。更复杂的 shape 应回到 raw YAML 与 schema 设计本体处理。
## 阅读顺序
### 安装与选包入口
@ -30,7 +32,7 @@ description: GFramework 的 API 阅读入口,按模块映射 README、专题
| --- | --- | --- | --- |
| `Core` / `Core.Abstractions` | [Core 模块](../core/index.md) | [Core 抽象层说明](../abstractions/core-abstractions.md)、[快速开始](../getting-started/quick-start.md) | 架构入口、生命周期、命令 / 查询 / 事件 / 状态 / 资源 / 日志 / 配置 / 并发契约 |
| `Cqrs` / `Cqrs.Abstractions` / `Cqrs.SourceGenerators` | [CQRS 运行时](../core/cqrs.md) | [CQRS Handler Registry 生成器](../source-generators/cqrs-handler-registry-generator.md)、[协程系统](../core/coroutine.md) | request / notification / handler / pipeline / generated registry / targeted fallback contract |
| `Game` / `Game.Abstractions` / `Game.SourceGenerators` | [Game 模块总览](../game/index.md) | [Game 抽象层说明](../abstractions/game-abstractions.md)、[配置系统](../game/config-system.md) | 配置、数据、设置、场景、UI、存储、序列化契约 |
| `Game` / `Game.Abstractions` / `Game.SourceGenerators` | [Game 模块总览](../game/index.md) | [Game 抽象层说明](../abstractions/game-abstractions.md)、[配置系统](../game/config-system.md) | 配置、数据、设置、场景、UI、存储、序列化契约;其中 AI-First 配置工作流的正式支持边界以 Runtime + Generator 共享 schema 子集为准 |
| `Godot` / `Godot.SourceGenerators` | [Godot 模块总览](../godot/index.md) | [Godot 项目生成器](../source-generators/godot-project-generator.md)、[GetNode 生成器](../source-generators/get-node-generator.md)、[BindNodeSignal 生成器](../source-generators/bind-node-signal-generator.md) | 节点扩展、场景 / UI 适配、配置 / 存储 / 设置接线、Godot 生成器入口 |
| `Ecs.Arch` / `Ecs.Arch.Abstractions` | [ECS 模块总览](../ecs/index.md) | [Arch ECS 集成](../ecs/arch.md)、[Ecs.Arch 抽象层说明](../abstractions/ecs-arch-abstractions.md) | ECS 模块契约、系统适配、配置对象和运行时装配边界 |
@ -60,6 +62,9 @@ description: GFramework 的 API 阅读入口,按模块映射 README、专题
- 最佳实践:[最佳实践](../best-practices/index.md)
- 故障排查:[故障排查](../troubleshooting.md)
如果你阅读的是 AI-First 配置相关 API请直接把 [配置系统](../game/config-system.md) 视为边界说明页:
`additionalProperties: false``oneOf` / `anyOf` rejection 这类采用约束不会由 VS Code 工具或 abstractions 页面单独改写。
## 共享支撑层怎么看
- `GFramework.Core.SourceGenerators.Abstractions`

View File

@ -13,12 +13,15 @@ description: Cqrs 模块族的运行时、契约层、生成器入口,以及
如果你在写新功能,优先使用这套请求模型,而不是继续扩展 `GFramework.Core.Command` / `Query` 的兼容层。
如果你在查找 `ICqrsRuntime`,请把 `GFramework.Core.Abstractions.Cqrs.ICqrsRuntime` 理解为旧命名空间下保留的
legacy compatibility alias。新代码应直接依赖 `GFramework.Cqrs.Abstractions.Cqrs.ICqrsRuntime`
## 模块族边界
| 模块 | 角色 | 何时安装 |
| --- | --- | --- |
| `GeWuYou.GFramework.Cqrs.Abstractions` | 纯契约层,定义 request、notification、stream、handler、pipeline、runtime seam | 需要把消息契约放到更稳定的共享层,或只依赖接口做解耦 |
| `GeWuYou.GFramework.Cqrs` | 默认 runtime提供 dispatcher、handler 基类、上下文扩展和程序集注册流程 | 大多数直接消费 CQRS 的业务模块 |
| `GeWuYou.GFramework.Cqrs` | 默认 runtime提供 dispatcher、notification publisher seam、handler 基类、上下文扩展和程序集注册流程 | 大多数直接消费 CQRS 的业务模块 |
| `GeWuYou.GFramework.Cqrs.SourceGenerators` | 编译期生成 `ICqrsHandlerRegistry`,让运行时先走生成注册器,再只对剩余 handler 做定向 fallback | handler 较多,想把注册映射前移到编译期 |
## 最小接入路径
@ -109,6 +112,13 @@ var playerId = await architecture.Context.SendRequestAsync(
新代码通常不需要再分别设计“命令总线”“查询总线”和另一套通知分发语义。
当前通知分发默认仍保持顺序语义:
- 零处理器时静默完成
- 已解析处理器按容器顺序逐个执行
- 首个处理器抛出异常时立即停止后续分发
- 如果容器在 runtime 创建前已显式注册 `INotificationPublisher`,默认 runtime 会复用该策略;未注册时回退到内置顺序发布器
## Request 与流式变体
除了最常见的 `Command` / `Query` / `Notification`,当前公开面还覆盖两类容易被忽略的入口:
@ -195,6 +205,12 @@ RegisterCqrsPipelineBehavior<LoggingBehavior<,>>();
`IArchitectureContext` 仍然兼容旧入口,但新代码应优先使用 CQRS runtime。
这里有两个边界需要分开理解:
- 旧 `Command` / `Query` 入口仍可用于维护历史调用链
- 旧命名空间下的 `ICqrsRuntime` 只是为了兼容既有引用而保留的 alias面向新代码时应直接使用
`GFramework.Cqrs.Abstractions.Cqrs.ICqrsRuntime`
一个简单判断规则:
- 在维护历史代码:允许继续使用旧 Command / Query

View File

@ -17,10 +17,20 @@ 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``if` / `then` / `else`
- 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`
- Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
## 编辑器能力与 Runtime 契约
`GFramework Config Tool` 是这套配置系统的辅助层,不单独定义 Runtime 契约。
- 哪些 schema 能被正式采用,以 `GFramework.Game` Runtime 与 Source Generator 的共享支持边界为准
- VS Code 插件负责把这些已落地的边界提前暴露成浏览、表单、校验和批量编辑体验
- 工具层的可视化入口比 Runtime 契约更保守时,应该回到 raw YAML 和 schema 本体继续编辑,而不是把“当前没做成表单”误解为“运行时允许自由扩展”
因此,判断某个关键字是否可用时,应该先看这里定义的共享契约,再把工具当作帮助你按这份契约工作的编辑器入口。
对应工具说明见:[VS Code 配置工具](./config-tool.md)
## 推荐目录结构
@ -802,6 +812,14 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re
- `dependentSchemas`供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;当前只接受“已声明 sibling 字段触发 object 子 schema”的形状不改变生成类型形状并按 focused constraint block 语义允许条件子 schema 未声明的额外同级字段继续存在
- `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` 形态当前不属于共享支持子集,会在解析或生成阶段直接被拒绝
- `oneOf` / `anyOf`当前不属于共享支持子集Runtime / Generator / Tooling 会在解析或生成阶段直接拒绝,避免静默接受会改变生成类型形状的组合关键字
如果你的 schema 需要超出这些边界的复杂 shape推荐采用下面的回退顺序
1. 先在 raw YAML 与 schema 文件中直接编辑,而不是强行依赖表单入口
2. 再核对该 shape 是否仍符合这里列出的共享支持子集
3. 如果它依赖 `oneOf` / `anyOf`、非 `false``additionalProperties`、会向父对象注入新字段的 `allOf` / `dependentSchemas` / `if` 分支,或者更异构的深层数组结构,就应当把它视为当前版本之外的设计,而不是工具层遗漏的“隐藏能力”
`allOf` 的最小可工作示例如下。关键点是:字段形状先在父对象 `properties` 中声明,再用 `allOf` 叠加 `required` 或更细的字段约束;`allOf` 条目不会把新字段并回父对象。
@ -980,52 +998,10 @@ var hotReload = loader.EnableHotReload(
## VS Code 工具
完整采用说明见:[VS Code 配置工具](./config-tool.md)。
`GFramework Config Tool` 是这套配置系统的编辑器侧辅助入口,用来把 `config/``schemas/`、轻量校验、
表单预览和批量维护收敛到一条 VS Code 工作流里。
仓库中的 `tools/gframework-config-tool` 当前提供以下能力:
它不改变本页定义的运行时、生成器和 schema 语义边界,只负责把这些既有约束投射到编辑器采用路径中。
- 浏览 `config/` 目录
- 打开 raw YAML 文件
- 打开匹配的 schema 文件
- 根据 VS Code 当前界面语言在英文和简体中文之间切换主要工具界面文本
- 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验
- 对嵌套对象字段、对象数组、顶层标量字段和顶层标量数组提供轻量表单入口
- 在表单中渲染已有 YAML 注释,并允许直接编辑字段级 YAML 注释
- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口
- 对同一配置域内的多份 YAML 文件执行批量字段更新
- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-tableUI 中显示为 ref-table / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties / dependentRequired / dependentSchemas / allOf / if / then / else` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
对象数组编辑器当前支持:
- 新增和删除对象项
- 编辑对象项中的标量字段
- 编辑对象项中的标量数组
- 编辑对象项中的嵌套对象字段
如果对象数组项内部继续包含对象数组,当前仍建议回退到 raw YAML 完成。
当前批量编辑入口仍刻意限制在“同域文件统一改动顶层标量字段和顶层标量数组”,避免复杂结构批量写回时破坏人工维护的 YAML 排版。
## 适用范围
当前这套工具更适合已经定义好 schema、需要校验、轻量表单和批量改写能力的内容维护场景尤其适合由开发者或技术策划主导的游戏项目配置工作流。
以下场景目前仍建议保留 raw YAML 编辑,或由项目补充专用工具:
- 需要更完整的 JSON Schema 支持
- 需要在 VS Code 中安全编辑更深层对象数组嵌套
- 需要覆盖更复杂的数组结构和更深层 schema 关键字
## 工具形态建议
对当前仓库已经落地的工作流而言,`VS Code Extension` 形态已经可以覆盖 schema 校验、轻量表单、批量编辑和 raw YAML 回退这条采用路径。
如果你的团队出现以下需求,再评估独立 `Config Studio` 会更合适:
- 配置维护主要由非开发角色承担,希望进一步降低 VS Code 的安装和使用门槛
- 需要更重的表格视图、跨表可视化关系编辑、复杂审批流或离线发布流程
- 插件形态已经明显受限于 VS Code Webview / Extension API而不是 schema 与工作流本身
- 已经沉淀出稳定的 schema 元数据约定,足以支撑单独工具的长期维护
如果你要了解工作区约定、命令入口、表单与批量编辑边界、适用场景,以及何时应该回退到 raw YAML
完整说明见:[VS Code 配置工具](./config-tool.md)。

View File

@ -68,6 +68,138 @@ GameProject/
如果你更关心“当前 schema 和 YAML 是否仍一致”,优先使用全量校验;如果你只是定位单个字段或注释,优先使用
Explorer + 表单预览。
### 当前能力范围
仓库中的 `tools/gframework-config-tool` 当前提供以下能力:
- 浏览 `config/` 目录
- 打开 raw YAML 文件
- 打开匹配的 schema 文件
- 根据 VS Code 当前界面语言在英文和简体中文之间切换主要工具界面文本
- 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验
- 对嵌套对象字段、对象数组、顶层标量字段和顶层标量数组提供轻量表单入口
- 在表单中渲染已有 YAML 注释,并允许直接编辑字段级 YAML 注释
- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口
- 对同一配置域内的多份 YAML 文件执行批量字段更新
- 在表单入口中显示 `title / description / default / const / enum / x-gframework-ref-tableUI 中显示为 ref-table / multipleOf / pattern / format / uniqueItems / contains / minContains / maxContains / minProperties / maxProperties / dependentRequired / dependentSchemas / allOf / if / then / else` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息
- 对 `additionalProperties: false` 提供闭合对象边界校验,并在遇到 `oneOf` / `anyOf` 或其他当前未收口的组合形状时明确提示该 schema 不属于当前工具支持子集
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
对象数组编辑器当前支持:
- 新增和删除对象项
- 编辑对象项中的标量字段
- 编辑对象项中的标量数组
- 编辑对象项中的嵌套对象字段
- 编辑对象项内部继续嵌套的对象数组,只要这些内层对象数组项仍然由对象、标量字段、标量数组和嵌套对象组成
如果对象数组中混入了标量项,或者更深层结构超出当前 schema 子集,表单入口会明确提示该路径需要回退到 raw YAML。
当前批量编辑入口仍刻意限制在“同域文件统一改动顶层标量字段和顶层标量数组”,避免复杂结构批量写回时破坏人工维护的 YAML 排版。
### 工具边界与 Runtime 契约
这个扩展是编辑器侧的辅助层,不定义 `GFramework.Game` 的 Runtime 契约。
- Runtime / Source Generator 是否接受某份 schema决定了它是否属于当前配置系统的正式支持范围
- 工具里的表单、hint、校验和批量编辑只是把这套已落地契约搬到 VS Code 中帮助你更快发现问题
- 如果工具界面暂时没有把某个 shape 做成可视化编辑入口,不代表 Runtime 会自动接受更宽松的 schema同样如果 Runtime / Generator 已明确拒绝某类关键字,工具也不会把它包装成可继续编辑的“可用能力”
日常采用时,建议把它理解为“优先用工具加速已支持子集的维护;遇到边界时立刻回到 schema + raw YAML 本体确认”。
### 最小接入示例与兼容 / 迁移说明
项目里至少需要准备三类内容:
- `config/<domain>/*.yaml`:实际配置文件
- `schemas/<domain>.schema.json`:与该配置域对应的 schema
- VS Code 工作区里的 `GFramework Config Tool` 扩展,以及与 schema 保持一致的 `x-gframework-ref-table` 引用约定
最小目录可以从下面这个形态起步:
```text
GameProject/
├─ config/
│ └─ monster/
│ └─ slime.yaml
└─ schemas/
└─ monster.schema.json
```
最小 schema 示例:
```json
{
"type": "object",
"additionalProperties": false,
"required": ["id", "name", "rarity", "dropItems"],
"properties": {
"id": {
"type": "integer",
"title": "Monster Id",
"description": "Primary monster key.",
"default": 1
},
"name": {
"type": "string",
"title": "Display Name",
"minLength": 1
},
"rarity": {
"type": "string",
"enum": ["common", "elite", "boss"],
"default": "common"
},
"spawnTime": {
"type": "string",
"format": "time"
},
"dropItems": {
"type": "array",
"uniqueItems": true,
"items": {
"type": "string"
}
},
"rewardTableId": {
"type": "string",
"x-gframework-ref-table": "reward-table"
}
}
}
```
对应的 YAML 初始文件可以保持很小:
```yaml
id: 1
name: Slime
rarity: common
spawnTime: 08:30:00Z
dropItems:
- potion
rewardTableId: starter-reward
```
推荐接入顺序:
1. 在 VS Code 中打开包含 `config/``schemas/` 的工作区
2. 如果目录不是默认值,先设置 `gframeworkConfig.configPath``gframeworkConfig.schemasPath`
3. 通过 Explorer 打开目标 YAML 或 schema先跑一次全量校验
4. 对空 YAML 使用“基于 schema 的示例 YAML 初始化”,或直接从 raw YAML 开始录入
5. 需要统一改同域顶层标量字段时,再进入批量编辑
迁移自纯 raw YAML 工作流时,至少先检查下面几件事:
- `additionalProperties` 是否显式设置为 `false`;省略或 `true` 不属于当前共享支持子集
- schema 是否依赖 `oneOf` / `anyOf`;这些组合关键字会被 Runtime / Generator / Tooling 直接拒绝
- 对象数组里是否混入标量项,或是否存在更深、更异构的数组结构
- Runtime / Source Generator 是否已经接受这份 schema而不是只有编辑器里“暂时看起来能写”
当 schema 仍在共享支持子集内,但某段编辑路径已经超出轻量表单可视化边界时,优先回到 raw YAML不要把“工具暂时没有表单入口”误判成“运行时契约已放宽”。
## 推荐工作流
### 1. 浏览配置与 schema
@ -94,6 +226,9 @@ Explorer + 表单预览。
- 顶层标量数组
- 嵌套对象字段
- 对象数组
- object-focused `if` / `then` / `else``dependentRequired``dependentSchemas``allOf`
- `contains` / `minContains` / `maxContains`
- `additionalProperties: false`
如果你进入更深层对象数组嵌套,当前更稳妥的做法通常是:
@ -101,6 +236,13 @@ Explorer + 表单预览。
2. 先看表单预览确认字段结构
3. 再回到 raw YAML 完成最终编辑
以下 shape 目前也建议直接回退到 raw YAML并同时检查 schema 是否仍在当前共享支持子集内:
- 需要表达 `oneOf` / `anyOf` 这类会改变生成类型形状的组合关键字
- 需要 `additionalProperties` 的其他形态,而不是当前明确支持的 `additionalProperties: false`
- 需要在 `allOf``dependentSchemas``if` / `then` / `else` 中引入父对象未声明的新字段
- 需要比当前对象数组编辑器更深、更异构的数组结构
## 工作区设置
当前公开设置只有两个:
@ -125,12 +267,33 @@ Explorer + 表单预览。
- 校验聚焦仓库当前支持的 schema 子集
- 表单预览支持对象数组,但更深的嵌套对象数组仍可能需要回退到 raw YAML
- 批量编辑当前聚焦顶层标量和顶层标量数组字段
- 共享约束里只支持闭合对象边界 `additionalProperties: false``oneOf` / `anyOf` 等改变生成形状的组合关键字会被明确拒绝
因此,最稳妥的理解方式是:
- 用它加速“浏览、定位、轻量校验、批量维护”
- 不把它当成完整替代 YAML / schema 编辑的唯一入口
## 适用范围
当前这套工具更适合已经定义好 schema、需要校验、轻量表单和批量改写能力的内容维护场景尤其适合由开发者或技术策划主导的游戏项目配置工作流。
以下场景目前仍建议保留 raw YAML 编辑,或由项目补充专用工具:
- 需要更完整的 JSON Schema 支持
- 需要覆盖更复杂的数组结构和更深层 schema 关键字
## 工具形态建议
对当前仓库已经落地的工作流而言,`VS Code Extension` 形态已经可以覆盖 schema 校验、轻量表单、批量编辑和 raw YAML 回退这条采用路径。
如果你的团队出现以下需求,再评估独立 `Config Studio` 会更合适:
- 配置维护主要由非开发角色承担,希望进一步降低 VS Code 的安装和使用门槛
- 需要更重的表格视图、跨表可视化关系编辑、复杂审批流或离线发布流程
- 插件形态已经明显受限于 VS Code Webview / Extension API而不是 schema 与工作流本身
- 已经沉淀出稳定的 schema 元数据约定,足以支撑单独工具的长期维护
## 继续阅读
- [游戏内容配置系统](./config-system.md)

View File

@ -18,6 +18,14 @@ description: 以当前 GFramework.Game 源码与 PersistenceTests 为准,说
如果先把这三类入口分开理解,后续接入时会清晰很多。
## 与 AI-First 配置系统的边界
如果你是从 AI-First 配置工作流一路读到这里,需要先把“配置契约”和“运行时持久化”分开理解:
- 配置系统的 schema 支持边界,仍以 `GFramework.Game` Runtime 和 `GFramework.Game.SourceGenerators` 当前共享的 schema 子集为准
- `DataRepository``UnifiedSettingsDataRepository``SaveRepository<TSaveData>` 负责的是数据怎么落盘、怎么回读、怎么组织槽位,而不是放宽配置契约
- 如果配置设计依赖 `oneOf``anyOf`、非 `false``additionalProperties`(例如省略或 `true`),或其他更复杂的 schema shape应直接回到 [配置系统](./config-system.md) 与 raw YAML / schema 本体继续处理,而不是期待 repository 层自动接管这些边界
## 什么时候用哪个仓库
### `DataRepository`
@ -195,6 +203,7 @@ var saveConfiguration = new SaveConfiguration
- `UnifiedSettingsDataRepository` 不是通用万能仓库,它专门服务“多 section 聚合单文件”的场景
- `SaveRepository<TSaveData>` 不负责业务层的 autosave 策略、云同步或存档选择 UI
- `LoadAsync(...)` 返回新实例的行为适合默认启动路径;如果项目需要“缺档即报错”,应在业务层显式调用 `ExistsAsync(...)`
- 如果 AI-First 配置系统里的 schema 已经超出 Runtime / Generator 共享子集repository 也不会替你放宽这些约束;这时应优先回到 [配置系统](./config-system.md) 与 raw YAML / schema 设计本身
## 继续阅读

View File

@ -83,6 +83,13 @@ IStorage storage = new FileStorage("GameData", serializer);
- `GFramework.Game.SourceGenerators`
- `schemas/**/*.schema.json` + `config/**/*.yaml`
这条工作流的正式契约,以 `GFramework.Game` Runtime 和 `GFramework.Game.SourceGenerators` 当前共享支持的 schema
子集为准。`VS Code` 配置工具主要负责编辑期提示和表单辅助,不单独扩展运行时可接受的 schema 形状。
开始接入时,建议先把 schema 约束控制在共享子集内,并尽早确认像 `additionalProperties: false`(需显式设置为 `false`;省略或 `true` 视为非 `false`)这类已收口的对象边界,以及
`oneOf` / `anyOf` 当前会被直接拒绝,而不是在工具里看起来“可以先写”。如果你的配置模型需要更深层的嵌套数组、联合分支或其他超出共享子集的复杂
shape优先回到 raw YAML 和 schema 设计本体处理,再决定是否拆分结构或调整约束方式。
完整约定见:
- [配置系统](./config-system.md)
@ -120,7 +127,7 @@ IStorage storage = new FileStorage("GameData", serializer);
- 运行时入口主要来自 `GFramework.Game`
- 只依赖接口或拆分业务层时,补充 `GFramework.Game.Abstractions`
- 需要静态内容配置类型和表包装生成时,再追加 `GFramework.Game.SourceGenerators`
- 需要编辑器侧内容维护工作流时,再看 [VS Code 配置工具](./config-tool.md)
- 需要编辑器侧内容维护工作流时,再看 [VS Code 配置工具](./config-tool.md),并把它视为共享契约之上的辅助层
## 对应模块入口

View File

@ -254,6 +254,21 @@ await sceneRouter.PopAsync();
- 项目提供 factory、root、资源映射和具体引擎装配
- 文档中的最小示例应优先说明职责边界,而不是继续堆叠大而全教程
## 配置系统边界提示
如果你的场景路由接线同时依赖 AI-First 配置系统,本页只负责说明场景宿主、路由和生命周期接法,不负责定义配置
schema 的正式支持边界。涉及 YAML 配置契约、组合关键字或编辑器辅助能力时,请回到
[Game 配置系统](./config-system.md) 作为正式说明页。
默认采用路径之外的场景包括:
- `oneOf` / `anyOf`
- 非 `false``additionalProperties`
- 依赖开放对象形状、形状合并或更复杂嵌套数组的 schema shape
这类复杂 shape 不应从场景接线页推断支持范围。`VS Code` 工具只是辅助编辑与预览层;如果遇到这些情况,应直接回到 raw YAML
和 schema 本体设计处理。
## 推荐阅读
1. [Game 模块总览](./index.md)

View File

@ -148,11 +148,14 @@ var restored = serializer.Deserialize(json, data.GetType());
如果你的目标是静态内容配置表,而不是运行时持久化对象,请改看 [配置系统](./config-system.md)。
如果你在配置系统里进一步碰到更复杂的 schema shape也要尽快回到配置系统主文档和 raw YAML / schema 本体继续设计。当前默认采用路径面向的是与 `GFramework.Game` Runtime 和 `GFramework.Game.SourceGenerators` 对齐的共享 schema 子集,不是任意 `JSON Schema` 的全量支持。
## 当前边界
- 当前公开默认实现只有 JSON没有内建 MessagePack、Binary 或 ProtoBuf 实现
- `JsonSerializer` 负责序列化,不负责对象版本迁移;版本迁移属于 `SettingsModel<TRepository>``SaveRepository<TSaveData>`
- 序列化器共享后应视为只读配置对象,避免在运行期继续修改 settings / converters
- 如果配置设计依赖 `oneOf``anyOf`、非 `false``additionalProperties`(例如省略或 `true`),或其他需要开放对象形状与联合分支的复杂约束,请直接按配置系统主文档回到 raw YAML / schema 方案处理,而不是把这些场景归到序列化层
## 继续阅读

View File

@ -18,6 +18,14 @@ description: 以当前 SettingsModel、SettingsSystem 与相关测试为准,
而不是只靠若干 `Get<T>() / Register(...)` 辅助方法就能自动完成一切的模型。
## 与 AI-First 配置系统的边界
如果你关注的是“配置内容最后怎么变成运行时设置”,这里也需要先分清职责:
- 配置 schema 的正式支持边界,仍以 `GFramework.Game` Runtime 和 `GFramework.Game.SourceGenerators` 当前共享的 schema 子集为准
- `UnifiedSettingsDataRepository``SettingsModel<TRepository>``SettingsSystem` 负责设置数据的加载、迁移、保存与应用,不负责放宽 `oneOf``anyOf`、非 `false``additionalProperties`(例如省略或 `true`)等配置边界
- 一旦配置设计开始依赖更复杂的 schema shape应直接回到 [配置系统](./config-system.md) 与 raw YAML / schema 本体处理,再决定设置层怎么消费这些结果
## 当前公开入口
### `ISettingsData`
@ -196,6 +204,7 @@ await settingsModel.SaveAllAsync();
- `SettingsModel<TRepository>` 负责数据生命周期,`SettingsSystem` 负责系统级调用入口;两者不要混成一个巨型服务
- applicator 决定“怎么把数据应用到宿主”repository 决定“怎么保存数据”,两层职责不要互相侵入
- 设置迁移和存档迁移是两条不同管线;后者看 [数据与存档系统](./data.md) 里的 `SaveRepository<TSaveData>`
- 如果某个配置 shape 已经超出 Runtime / Generator 共享支持子集settings repository 和 `SettingsModel` 也不会替代配置系统去放宽它;应回到 [配置系统](./config-system.md) 与 raw YAML / schema 设计处理
## 继续阅读

View File

@ -165,6 +165,8 @@ var cacheStorage = new ScopedStorage(rootStorage, "runtime-cache");
- 业务层如果想保存设置,可继续阅读 [设置系统](./setting.md)
- 业务层如果只是需要底层存储实现,才直接依赖 `IStorage`
如果你是在“配置系统最终把内容保存到哪里”这个角度读到这里,需要先把边界分开:`IStorage` 负责运行时持久化,不负责定义配置 schema 的支持范围。配置工作流里只要开始出现更复杂的 schema shape仍应先回到 [配置系统](./config-system.md) 和 raw YAML / schema 本体继续设计,再决定运行时是否需要额外存储落盘策略。
## 当前边界
- `FileStorage` 已经会通过注入的 `ISerializer` 自动序列化对象;默认接法不需要先手工 `Serialize(...)` 再把字符串写回
@ -172,6 +174,7 @@ var cacheStorage = new ScopedStorage(rootStorage, "runtime-cache");
- `ScopedStorage` 只做 key 前缀,不做权限、事务或迁移控制
- 锁粒度是“当前实例内的目标路径”,不是跨进程文件锁
- 原子写入只覆盖单文件替换,不等于多文件事务
- 如果配置建模依赖 `oneOf``anyOf`、非 `false``additionalProperties`(例如省略或 `true`),或其他超出当前共享 schema 子集的复杂组合约束,这不是 `IStorage` 层能放宽的限制;应直接回到配置系统主文档与 raw YAML / schema 设计处理
## 继续阅读

View File

@ -324,6 +324,21 @@ uiRouter.Hide(modalHandle, UiLayer.Modal);
- 页面行为不仅有生命周期,还有输入、阻断、暂停契约
- router 是 UI 语义仲裁中心,项目输入层应主动接入它
## 配置系统边界提示
如果你的 UI 宿主接线还会读取 AI-First 配置或 schema 驱动的页面数据,本页只说明 UI router、root、factory 与输入语义,
不负责定义配置系统的正式边界。凡是配置契约、组合关键字或工具辅助的支持范围,都应以
[Game 配置系统](./config-system.md) 为准。
默认采用路径之外的典型场景包括:
- `oneOf` / `anyOf`
- 非 `false``additionalProperties`
- 更复杂的 schema shape例如依赖开放对象形状、形状合并或更深层异构数组
`VS Code` 工具只是辅助层,不是配置边界定义页。遇到这些复杂 shape 时,应直接回到 raw YAML 和 schema 本体设计,
而不是从 UI 接线页推断是否“已经被工具支持”。
## 推荐阅读
1. [Game 模块总览](./index.md)

View File

@ -70,12 +70,18 @@ description: 概览 GFramework 的模块组成、最小接入路径与继续阅
- Scene / UI / Routing 抽象与运行时
- 文件存储和序列化
AI-First 配置工作流的正式契约以 `GFramework.Game` Runtime 与 `GFramework.Game.SourceGenerators` 的共享 schema 子集为准,`VS Code` 配置工具只负责辅助编辑与预览。默认采用路径当前以 `additionalProperties: false` 作为闭合对象边界,`oneOf` / `anyOf` 不在默认入口范围内;如果你的 schema shape 超出这组共享边界,优先回到 raw YAML 与 schema 设计本体继续建模。
对应文档:
- [Game 模块总览](../game/index.md)
- [配置系统](../game/config-system.md)
- [安装配置](./installation.md)
如果你准备采用 AI-First 配置工作流,建议尽早确认当前采用边界:对象闭合只收口到
`additionalProperties: false`,而 `oneOf` / `anyOf` 这类会改变生成类型形状的组合关键字当前不属于默认路径。
超过这组共享子集的复杂 schema shape应回到 raw YAML 与 schema 本体设计,而不是把差异理解成工具遗漏。
### Godot 项目接入
继续叠加:
@ -104,6 +110,8 @@ description: 概览 GFramework 的模块组成、最小接入路径与继续阅
- 为 CQRS handlers 生成注册表
- 生成 Godot 节点、场景和 UI 包装代码
如果你要处理的是更复杂的 schema 设计,而不是编译期生成代码本身,先确认它是否仍在 `Game` Runtime 与生成器共享子集内;超出时应优先调整 raw YAML / schema 方案,而不是假定编辑器入口少了某个开关。
继续阅读:
- [源码生成器总览](../source-generators/index.md)

View File

@ -46,6 +46,8 @@ GFramework 采用模块化设计,不同包提供不同的功能:
- Arch ECS直接安装 `GeWuYou.GFramework.Ecs.Arch`;如果只想共享宿主循环或接口边界,可改为 `GeWuYou.GFramework.Ecs.Arch.Abstractions`
如果你准备采用 AI-First 配置工作流,可以继续阅读 [游戏内容配置系统](../game/config-system.md) 与 [VS Code 配置工具](../game/config-tool.md)。
接入时建议先按 Runtime + Source Generator 的共享 schema 子集设计配置模型,再把 `VS Code` 工具当作编辑辅助层来使用,而不是反过来以工具界面可编辑的 shape 作为正式契约。
尤其需要尽早知道两个当前边界:对象闭合只收口到 `additionalProperties: false`,而 `oneOf` / `anyOf` 会被直接拒绝。若配置模型超出这组共享边界,优先回到 raw YAML 与 schema 本体调整结构,而不是把差异理解成工具遗漏能力。
## 安装方式

View File

@ -116,6 +116,11 @@ architecture.RegisterUtility<ISaveRepository<GameSaveData>>(new SaveRepository<G
- 生成器表元数据
- 热重载可用性边界
如果这里涉及 schema 采用边界,也应以 [Game 配置系统](../game/config-system.md) 为正式说明页,而不是把
`GodotFileStorage` 所在的宿主接线页理解成配置边界定义。默认采用路径之外的典型场景包括 `oneOf` / `anyOf`
`false``additionalProperties`,以及其他更复杂的 schema shape。`VS Code` 工具只是辅助编辑与预览层;
遇到这些情况时,应直接回到 raw YAML 和 schema 本体设计处理。
### 通用存储契约
宿主无关的 `IStorage``ScopedStorage``FileStorage` 和统一数据仓库语义,可继续阅读

View File

@ -38,8 +38,10 @@ features:
details: 在保持 Core / Game 运行时边界的前提下,补齐节点扩展、场景与 UI 接线、协程桥接和生成器辅助。
- title: 🧩 AI-First 配置工作流
details: 通过 YAML + JSON Schema + Source Generator + VS Code 工具,把静态内容配置、校验、表单预览和批量编辑串成一条链路。
details: 通过 YAML + JSON Schema + Source Generator + VS Code 工具,把静态内容配置、校验、表单预览和批量编辑串成一条链路;正式契约来自 `GFramework.Game` Runtime 与 `GFramework.Game.SourceGenerators` 的共享 schema 子集,编辑器工具只负责辅助编辑与预览
- title: ⚡ Roslyn 源码生成器
details: 自动生成日志、上下文注入、配置类型、CQRS registry 和 Godot 辅助代码,并复用共享 diagnostics 约束生成行为。
---
AI-First 配置工作流的默认采用路径以闭合对象模型为前提:当前使用 `additionalProperties: false` 作为对象边界,`oneOf` / `anyOf` 不在默认入口范围内。遇到超出共享 schema 子集的复杂 shape请直接回到 raw YAML 与 schema 设计本体处理,而不是把它当成编辑器能力遗漏。

View File

@ -42,6 +42,30 @@ GFramework 当前发布的生成器包是:
| `GFramework.Cqrs.SourceGenerators` | `GFramework.Cqrs` |
| `GFramework.Godot.SourceGenerators` | `GFramework.Godot` |
`GFramework.Game.SourceGenerators` 而言,这个“服务 `GFramework.Game`”的关系还包含一个采用前提:
- 它面向的是与 `GFramework.Game` Runtime 对齐的共享 schema 子集
- 它的目标是把当前运行时已经明确支持的配置契约生成成类型与表包装,而不是承诺任意 JSON Schema 都能直接生成
- 读者在评估配置工作流时,应始终把 [配置系统](../game/config-system.md) 视为实际采用边界的说明页
## Game 配置生成器的采用边界
如果你选择的是 `GFramework.Game.SourceGenerators`请先按“共享子集”来理解它而不是按“JSON Schema 全量实现”来理解它。
当前 reader-facing 的采用路径是:
- Runtime、Source Generator 与 Tooling 共同对齐一组共享关键字与对象形状约束
- 生成器只为这组已经收口的契约生成 C# 配置类型、表包装和相关注册入口
- 一旦 schema 超出这组共享边界,就应该回到 schema 本体与运行时专题页重新判断,而不是假设生成器会替你兜底
当前不属于默认采用路径的典型情况包括:
- `oneOf``anyOf` 这类会改变生成类型形状的组合关键字
- 非 `false``additionalProperties`(例如省略或 `true`
- 其他需要开放对象形状、联合分支或更自由属性合并的 schema 设计
这些场景当前不应被理解为“文档还没写到的隐藏支持”,而应被理解为:它们不在 `GFramework.Game` 现阶段共享配置契约内。
安装时通常保持生成器包与对应运行时包版本一致,并将生成器声明为:
```xml
@ -85,6 +109,7 @@ GFramework 当前发布的生成器包是:
- 配置 schema 生成与运行时接法:
- [配置系统](../game/config-system.md)
- 读者若需要确认共享 schema 子集、关闭对象边界或复杂组合关键字的限制,应以该页为准,而不是只从本页推断支持范围
- CQRS handler registry 生成器:
- [CQRS Handler Registry 生成器](./cqrs-handler-registry-generator.md)
- CQRS 模块族采用入口:

View File

@ -1,6 +1,6 @@
# GFramework Config Tool
VS Code extension for the GFramework AI-First config workflow.
VS Code extension for browsing, validating, and lightweight editing in the GFramework AI-First config workflow.
## Purpose
@ -34,7 +34,7 @@ GameProject/
### Explorer View
- Browse config files from the workspace `config/` directory
- Browse config files from the first workspace folder's `config/` directory
- Group files by config domain
- Open matching schema files from `schemas/`
@ -43,11 +43,12 @@ GameProject/
- Open raw YAML
- Open the matching schema
- Open a lightweight form preview
- Revalidate saved config files automatically when they change
### Domain-Level Actions
- Batch edit one config domain across multiple files for top-level scalar and scalar-array fields
- Run validation across the current workspace config surface
- Validate all discovered config files from the explorer view
### Form / Validation Support
@ -56,6 +57,8 @@ GameProject/
- Jump from reference fields to the referenced schema, config domain, or direct config file when a reference value is
present
- Initialize empty config files from schema-derived example YAML
- Edit nested object fields recursively inside the form preview
- Edit arrays of objects in the form preview, including nested object fields inside each item
- Surface schema metadata such as `title`, `description`, `default`, `enum`, and `x-gframework-ref-table` in the
lightweight editors
@ -69,6 +72,23 @@ The extension currently validates the repository's current schema subset:
- scalar arrays with scalar item type checks
- arrays of objects whose items use the same supported subset recursively
- scalar `enum` constraints and scalar-array item `enum` constraints
- scalar `const` constraints
- numeric range constraints such as `minimum`, `exclusiveMinimum`, `maximum`, `exclusiveMaximum`, and `multipleOf`
- string constraints such as `minLength`, `maxLength`, and `pattern`
- array constraints such as `minItems`, `maxItems`, `contains`, `minContains`, `maxContains`, and `uniqueItems`
- object constraints such as `minProperties`, `maxProperties`, `dependentRequired`, `dependentSchemas`, `allOf`, and
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
## Contract Boundary
This extension is an editor-side helper. It does not define the runtime contract for `GFramework.Game`.
- The runtime and source generator remain the source of truth for which schema shapes are formally supported
- The VS Code experience mirrors that shared subset so unsupported shapes fail early during browsing or validation
- If a shape is too complex for the lightweight editors, fall back to raw YAML and the schema file first; do not assume
the runtime accepts a broader contract just because the editor has no custom form for it
## Workspace Settings
@ -83,12 +103,29 @@ The extension currently validates the repository's current schema subset:
1. Install the extension in VS Code and open the workspace that contains your `config/` and `schemas/` directories.
2. Keep the default workspace layout, or set `gframeworkConfig.configPath` and `gframeworkConfig.schemasPath` to your
project-specific paths.
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.
Minimal adoption checklist:
- Keep one workspace folder that contains both `config/` and `schemas/`
- Place each config domain under `config/<domain>/*.yaml`
- Place the matching schema at `schemas/<domain>.schema.json`
- 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 or setting
it to `true` is outside the supported subset
Use raw YAML directly when you need:
- deeper or more heterogeneous array shapes
- supported object rules such as `allOf`, `dependentSchemas`, or object-focused `if` / `then` / `else` only when they
push the edit path beyond the lightweight form boundary
- `contains` / `minContains` / `maxContains` when the structure is easier to reason about directly in YAML
- schema designs outside the current shared subset, including `oneOf`, `anyOf`, or non-`false` `additionalProperties`
## Documentation
- Chinese adoption guide: [Game 配置工具](../../docs/zh-CN/game/config-tool.md)
@ -98,8 +135,11 @@ The extension currently validates the repository's current schema subset:
- Multi-root workspaces use the first workspace folder
- Validation only covers the repository's current schema subset
- Form preview supports object-array editing, but nested object arrays inside array items still fall back to raw YAML
- Form preview supports nested objects and object-array editing, but deeper nested object arrays inside array items still
fall back to raw YAML
- Batch editing remains limited to top-level scalar fields and top-level scalar arrays
- Closed-object support is limited to `additionalProperties: false`, and unsupported combinators such as `oneOf` /
`anyOf` are rejected on purpose
## Local Testing

View File

@ -1,14 +1,14 @@
{
"extension.displayName": "GFramework Config Tool",
"extension.description": "VS Code tooling for browsing, validating, and editing AI-First config files in GFramework projects.",
"extension.description": "VS Code tooling for browsing, validating, form-preview editing, and domain batch updates for AI-First config files in GFramework projects.",
"view.gframeworkConfig.name": "GFramework Config",
"command.refresh.title": "GFramework Config: Refresh",
"command.openRaw.title": "GFramework Config: Open Raw File",
"command.openRaw.title": "GFramework Config: Open Raw YAML",
"command.openSchema.title": "GFramework Config: Open Schema",
"command.openFormPreview.title": "GFramework Config: Open Form Preview",
"command.batchEditDomain.title": "GFramework Config: Batch Edit Domain",
"command.validateAll.title": "GFramework Config: Validate All",
"configuration.title": "GFramework Config",
"configuration.configPath.description": "Relative path from the workspace root to the config directory.",
"configuration.schemasPath.description": "Relative path from the workspace root to the schema directory."
"configuration.configPath.description": "Relative path from the first workspace folder to the config directory.",
"configuration.schemasPath.description": "Relative path from the first workspace folder to the schema directory."
}

View File

@ -1,14 +1,14 @@
{
"extension.displayName": "GFramework 配置工具",
"extension.description": "为 GFramework 项目中的 AI-First 配置文件提供浏览、校验和编辑能力的 VS Code 扩展。",
"extension.description": "为 GFramework 项目中的 AI-First 配置文件提供浏览、校验、表单预览编辑和配置域批量更新能力的 VS Code 扩展。",
"view.gframeworkConfig.name": "GFramework 配置",
"command.refresh.title": "GFramework 配置:刷新",
"command.openRaw.title": "GFramework 配置:打开原始文件",
"command.openRaw.title": "GFramework 配置:打开原始 YAML",
"command.openSchema.title": "GFramework 配置:打开 Schema",
"command.openFormPreview.title": "GFramework 配置:打开表单预览",
"command.batchEditDomain.title": "GFramework 配置:批量编辑配置域",
"command.validateAll.title": "GFramework 配置:校验全部",
"configuration.title": "GFramework 配置",
"configuration.configPath.description": "从工作区目录到配置目录的相对路径。",
"configuration.schemasPath.description": "从工作区目录到 Schema 目录的相对路径。"
"configuration.configPath.description": "从第一个工作区目录到配置目录的相对路径。",
"configuration.schemasPath.description": "从第一个工作区目录到 Schema 目录的相对路径。"
}

View File

@ -19,6 +19,7 @@ const DurationFormatPattern =
const TimeFormatPattern =
/^(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(?<fraction>\.\d+)?(?<offset>Z|[+-]\d{2}:\d{2})$/u;
const SupportedStringFormats = new Set(["date", "date-time", "duration", "email", "time", "uri", "uuid"]);
const SupportedSchemaTypes = new Set(["object", "array", "string", "integer", "number", "boolean"]);
/**
* Compare two strings using the same UTF-16 code-unit ordering as C#'s
@ -1095,7 +1096,16 @@ function unquoteScalar(value) {
*/
function parseSchemaNode(rawNode, displayPath) {
const value = rawNode && typeof rawNode === "object" ? rawNode : {};
const type = typeof value.type === "string" ? value.type : "object";
const unsupportedCombinatorKeyword = getUnsupportedCombinatorKeywordName(value);
if (unsupportedCombinatorKeyword) {
throw new Error(
`Schema property '${displayPath}' declares unsupported combinator keyword '${unsupportedCombinatorKeyword}'. ` +
"The current config schema subset does not support combinators that can change generated type shape.");
}
validateUnsupportedOpenObjectKeyword(value, displayPath);
const type = resolveSupportedSchemaType(value.type, displayPath);
const patternMetadata = normalizeSchemaPattern(value.pattern, displayPath);
const stringFormat = normalizeSchemaStringFormat(value.format, type, displayPath);
const negatedSchemaNode = parseNegatedSchemaNode(value.not, displayPath);
@ -1168,15 +1178,19 @@ function parseSchemaNode(rawNode, displayPath) {
}
if (type === "array") {
const itemNode = parseSchemaNode(value.items || {}, joinArrayTemplatePath(displayPath));
const containsNode = value.contains && typeof value.contains === "object"
? parseSchemaNode(value.contains, joinArrayTemplatePath(displayPath))
: undefined;
const itemNode = parseRequiredArrayChildSchema(value.items, displayPath, "items");
const containsNode = value.contains === undefined
? undefined
: parseOptionalArrayChildSchema(value.contains, displayPath, "contains");
if (!containsNode &&
(typeof metadata.minContains === "number" || typeof metadata.maxContains === "number")) {
throw new Error(`Schema property '${displayPath}' declares 'minContains' or 'maxContains' without 'contains'.`);
}
if (itemNode.type === "array") {
throw new Error(`Schema property '${displayPath}' uses unsupported nested array items.`);
}
if (containsNode && containsNode.type === "array") {
throw new Error(`Schema property '${displayPath}' uses unsupported nested array 'contains' schemas.`);
}
@ -1253,6 +1267,116 @@ function parseSchemaNode(rawNode, displayPath) {
}, value.const, displayPath), value.enum, displayPath);
}
/**
* Reject open-object keyword forms that would drift away from the Runtime and
* Source Generator contracts. The current shared subset keeps object fields
* closed and only accepts an explicit `additionalProperties: false` reminder.
*
* @param {Record<string, unknown>} schemaNode Raw schema object.
* @param {string} displayPath Logical property path.
*/
function validateUnsupportedOpenObjectKeyword(schemaNode, displayPath) {
if (!Object.prototype.hasOwnProperty.call(schemaNode, "additionalProperties")) {
return;
}
if (schemaNode.additionalProperties === false) {
return;
}
throw new Error(
`Schema property '${displayPath}' uses unsupported 'additionalProperties' metadata. ` +
"The current config schema subset only accepts 'additionalProperties: false' so object fields remain closed and strongly typed.");
}
/**
* Parse one required array child schema while keeping tooling errors aligned
* with the Runtime and Source Generator contracts.
*
* @param {unknown} rawChild Raw child schema node.
* @param {string} displayPath Logical parent array path.
* @param {"items" | "contains"} keywordName Child schema keyword.
* @returns {SchemaNode} Parsed child schema node.
*/
function parseRequiredArrayChildSchema(rawChild, displayPath, keywordName) {
return parseArrayChildSchema(rawChild, displayPath, keywordName);
}
/**
* Parse one optional array child schema when it is present.
*
* @param {unknown} rawChild Raw child schema node.
* @param {string} displayPath Logical parent array path.
* @param {"items" | "contains"} keywordName Child schema keyword.
* @returns {SchemaNode | undefined} Parsed child schema node.
*/
function parseOptionalArrayChildSchema(rawChild, displayPath, keywordName) {
return parseArrayChildSchema(rawChild, displayPath, keywordName);
}
/**
* Parse one array child schema only when it is object-shaped and explicitly
* typed. This avoids silently treating tuple arrays or malformed child
* schemas as empty object nodes.
*
* @param {unknown} rawChild Raw child schema node.
* @param {string} displayPath Logical parent array path.
* @param {"items" | "contains"} keywordName Child schema keyword.
* @returns {SchemaNode} Parsed child schema node.
*/
function parseArrayChildSchema(rawChild, displayPath, keywordName) {
if (!rawChild || typeof rawChild !== "object" || Array.isArray(rawChild)) {
throw new Error(
`Schema property '${displayPath}' must declare '${keywordName}' as an object-valued schema with an explicit 'type'.`);
}
if (typeof rawChild.type !== "string") {
throw new Error(
`Schema property '${displayPath}' must declare '${keywordName}' as an object-valued schema with an explicit 'type'.`);
}
return parseSchemaNode(rawChild, joinArrayTemplatePath(displayPath));
}
/**
* Resolve one schema type while rejecting explicit strings that the shared
* subset does not support.
*
* @param {unknown} rawType Raw schema type value.
* @param {string} displayPath Logical property path.
* @returns {"object" | "array" | "string" | "integer" | "number" | "boolean"} Supported schema type.
*/
function resolveSupportedSchemaType(rawType, displayPath) {
if (typeof rawType !== "string") {
return "object";
}
if (!SupportedSchemaTypes.has(rawType)) {
throw new Error(`Schema property '${displayPath}' declares unsupported type '${rawType}'.`);
}
return rawType;
}
/**
* Return the first combinator keyword that the current shared schema subset
* intentionally rejects to keep Runtime / Generator / Tooling behavior aligned.
*
* @param {Record<string, unknown>} schemaNode Raw schema object.
* @returns {string | undefined} Unsupported keyword name when present.
*/
function getUnsupportedCombinatorKeywordName(schemaNode) {
if (Object.prototype.hasOwnProperty.call(schemaNode, "oneOf")) {
return "oneOf";
}
if (Object.prototype.hasOwnProperty.call(schemaNode, "anyOf")) {
return "anyOf";
}
return undefined;
}
/**
* Parse one optional `not` sub-schema and keep path formatting aligned with
* the runtime/generator diagnostics.

View File

@ -39,7 +39,7 @@ function describeContainsSchema(containsSchema, localizer) {
/**
* Build localized contains-related hint lines for array fields.
*
* @param {{contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}, minContains?: number}} propertySchema Array property schema metadata.
* @param {{contains?: {type?: string, enumValues?: string[], constValue?: string, constDisplayValue?: string, pattern?: string, refTable?: string}, minContains?: number, maxContains?: number}} propertySchema Array property schema metadata.
* @param {{t: (key: string, params?: Record<string, string | number>) => string}} localizer Runtime localizer.
* @returns {string[]} Localized contains hint lines.
*/
@ -51,7 +51,7 @@ function buildContainsHintLines(propertySchema, localizer) {
const effectiveMinContains = typeof propertySchema.minContains === "number"
? propertySchema.minContains
: 1;
return [
const lines = [
localizer.t("webview.hint.contains", {
summary: describeContainsSchema(propertySchema.contains, localizer)
}),
@ -59,6 +59,14 @@ function buildContainsHintLines(propertySchema, localizer) {
value: effectiveMinContains
})
];
if (typeof propertySchema.maxContains === "number") {
lines.push(localizer.t("webview.hint.maxContains", {
value: propertySchema.maxContains
}));
}
return lines;
}
module.exports = {

View File

@ -1,6 +1,43 @@
const fs = require("fs");
const path = require("path");
const vscode = require("vscode");
let vscode;
try {
vscode = require("vscode");
} catch {
// Tests load pure helpers from this module without the VS Code host.
vscode = {
env: {
language: "en"
},
EventEmitter: class EventEmitter {
constructor() {
this.event = () => undefined;
}
fire() {
}
},
TreeItem: class TreeItem {
constructor(label, collapsibleState) {
this.label = label;
this.collapsibleState = collapsibleState;
}
},
TreeItemCollapsibleState: {
None: 0,
Collapsed: 1,
Expanded: 2
},
Uri: {
joinPath() {
return undefined;
}
},
window: {},
workspace: {},
languages: {}
};
}
const {
applyFormUpdates,
createSampleConfigYaml,
@ -972,8 +1009,14 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml, options) {
current = current[segment];
}
}
function getDirectObjectArrayItems(editor) {
const itemsHost = editor.querySelector(":scope > [data-object-array-items]");
return itemsHost
? Array.from(itemsHost.querySelectorAll(":scope > [data-object-array-item]"))
: [];
}
function renumberObjectArrayItems(editor) {
const items = editor.querySelectorAll("[data-object-array-item]");
const items = getDirectObjectArrayItems(editor);
items.forEach((item, index) => {
const title = item.querySelector(".object-array-item-title");
if (title) {
@ -981,6 +1024,52 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml, options) {
}
});
}
function shouldIncludeNestedControl(control, ownerItem) {
return control.closest("[data-object-array-item]") === ownerItem;
}
function collectObjectArrayEditorItems(editor) {
const items = [];
for (const item of getDirectObjectArrayItems(editor)) {
items.push(collectObjectArrayItemValue(item));
}
return items;
}
function collectObjectArrayItemValue(item) {
const itemValue = {};
for (const control of item.querySelectorAll("[data-item-local-path]")) {
if (!shouldIncludeNestedControl(control, item)) {
continue;
}
setNestedObjectValue(itemValue, control.dataset.itemLocalPath, control.value);
}
for (const textarea of item.querySelectorAll("textarea[data-item-array-path]")) {
if (!shouldIncludeNestedControl(textarea, item)) {
continue;
}
setNestedObjectValue(
itemValue,
textarea.dataset.itemArrayPath,
parseArrayEditorValue(textarea.value));
}
for (const nestedEditor of item.querySelectorAll("[data-item-object-array-path]")) {
if (!shouldIncludeNestedControl(nestedEditor, item)) {
continue;
}
setNestedObjectValue(
itemValue,
nestedEditor.dataset.itemObjectArrayPath,
collectObjectArrayEditorItems(nestedEditor));
}
return itemValue;
}
document.addEventListener("click", (event) => {
const schemaButton = event.target.closest("[data-open-ref-schema]");
if (schemaButton) {
@ -1048,23 +1137,9 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml, options) {
for (const textarea of document.querySelectorAll("textarea[data-comment-path]")) {
comments[textarea.dataset.commentPath] = textarea.value;
}
for (const editor of document.querySelectorAll("[data-object-array-editor]")) {
for (const editor of document.querySelectorAll("[data-object-array-editor][data-object-array-path]")) {
const path = editor.dataset.objectArrayPath;
const items = [];
for (const item of editor.querySelectorAll("[data-object-array-items] > [data-object-array-item]")) {
const itemValue = {};
for (const control of item.querySelectorAll("[data-item-local-path]")) {
setNestedObjectValue(itemValue, control.dataset.itemLocalPath, control.value);
}
for (const textarea of item.querySelectorAll("textarea[data-item-array-path]")) {
setNestedObjectValue(
itemValue,
textarea.dataset.itemArrayPath,
parseArrayEditorValue(textarea.value));
}
items.push(itemValue);
}
objectArrays[path] = items;
objectArrays[path] = collectObjectArrayEditorItems(editor);
}
vscode.postMessage({ type: "save", scalars, arrays, objectArrays, comments });
});
@ -1110,8 +1185,11 @@ function renderFormField(field) {
title: localizer.t("webview.objectArray.item"),
fields: field.templateFields
});
const pathAttribute = field.itemMode
? `data-item-object-array-path="${escapeHtml(field.path)}"`
: `data-object-array-path="${escapeHtml(field.path)}"`;
return `
<div class="object-array depth-${field.depth}" data-object-array-editor data-object-array-path="${escapeHtml(field.path)}">
<div class="object-array depth-${field.depth}" data-object-array-editor ${pathAttribute}>
<div class="label">${escapeHtml(field.label)} ${field.required ? `<span class="badge">${escapeHtml(localizer.t("webview.badge.required"))}</span>` : ""}</div>
<div class="meta-key">${escapeHtml(field.displayPath || field.path)}</div>
${renderYamlCommentBlock(field)}
@ -1507,6 +1585,41 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa
continue;
}
if (propertySchema.type === "array" &&
propertySchema.items &&
propertySchema.items.type === "object") {
const templateFields = [];
collectObjectArrayItemFields(
propertySchema.items,
undefined,
"",
joinArrayTemplatePath(itemDisplayPath),
depth + 1,
templateFields,
unsupported,
commentLookup);
fields.push({
kind: "objectArray",
path: itemLocalPath,
displayPath: itemDisplayPath,
label,
required: requiredSet.has(key),
depth,
schema: propertySchema,
itemMode: true,
comment: commentLookup[itemDisplayPath] || "",
items: buildObjectArrayItemModels(
propertySchema.items,
propertyValue,
itemDisplayPath,
depth + 1,
unsupported,
commentLookup),
templateFields
});
continue;
}
if (["string", "integer", "number", "boolean"].includes(propertySchema.type)) {
fields.push({
kind: "scalar",
@ -2096,5 +2209,8 @@ function parseArrayFieldPayload(arrays) {
module.exports = {
activate,
deactivate
deactivate,
__test: {
buildFormModel
}
};

View File

@ -247,9 +247,9 @@ const zhCnMessages = {
"webview.hint.format": "格式:{value}",
"webview.hint.minItems": "最少元素数:{value}",
"webview.hint.maxItems": "最多元素数:{value}",
"webview.hint.contains": "Contains 约束{summary}",
"webview.hint.minContains": "最少 contains 匹配数:{value}",
"webview.hint.maxContains": "最多 contains 匹配数:{value}",
"webview.hint.contains": "contains 条件{summary}",
"webview.hint.minContains": "最少匹配数:{value}",
"webview.hint.maxContains": "最多匹配数:{value}",
"webview.hint.uniqueItems": "元素必须唯一",
"webview.hint.required": "必填字段:{properties}",
"webview.hint.itemMinimum": "元素最小值:{value}",
@ -265,7 +265,7 @@ const zhCnMessages = {
"webview.hint.minProperties": "最少属性数:{value}",
"webview.hint.maxProperties": "最多属性数:{value}",
"webview.hint.dependentRequired": "当 {trigger} 出现时:还必须声明 {dependencies}",
"webview.hint.dependentSchemas": "当 {trigger} 出现时:还必须满足 {schema}",
"webview.hint.dependentSchemas": "当 {trigger} 出现时:还必须满足以下条件:{schema}",
"webview.hint.allOf": "还必须满足:{schema}",
"webview.hint.ifThen": "当满足 {condition} 时:还必须满足 {schema}",
"webview.hint.ifElse": "否则(当 {condition} 不匹配时):还必须满足 {schema}",
@ -277,7 +277,7 @@ const zhCnMessages = {
[ValidationMessageKeys.allOfViolation]: "对象“{displayPath}”必须满足全部 `allOf` schema第 {index} 项未匹配。",
[ValidationMessageKeys.constMismatch]: "属性“{displayPath}”必须匹配固定值 {value}。",
[ValidationMessageKeys.dependentRequiredViolation]: "属性“{triggerProperty}”存在时,必须同时声明属性“{displayPath}”。",
[ValidationMessageKeys.dependentSchemasViolation]: "对象“{displayPath}”在属性“{triggerProperty}”存在时,必须满足对应的 dependent schema。",
[ValidationMessageKeys.dependentSchemasViolation]: "对象“{displayPath}”在属性“{triggerProperty}”存在时,必须满足对应的依赖 schema。",
[ValidationMessageKeys.elseViolation]: "对象“{displayPath}”在内联 `if` 条件未命中时,必须满足对应的 `else` schema。",
[ValidationMessageKeys.exclusiveMaximumViolation]: "属性“{displayPath}”必须小于 {value}。",
[ValidationMessageKeys.exclusiveMinimumViolation]: "属性“{displayPath}”必须大于 {value}。",

View File

@ -1,6 +1,7 @@
const ValidationMessageKeys = Object.freeze({
allOfViolation: "validation.allOfViolation",
constMismatch: "validation.constMismatch",
dependentRequiredViolation: "validation.dependentRequiredViolation",
dependentSchemasViolation: "validation.dependentSchemasViolation",
elseViolation: "validation.elseViolation",
enumMismatch: "validation.enumMismatch",

View File

@ -1,5 +1,6 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {__test: extensionTest} = require("../src/extension");
const {
applyFormUpdates,
applyScalarUpdates,
@ -178,6 +179,67 @@ test("parseSchemaContent should preserve empty-string const raw and display meta
assert.equal(schema.properties.name.constDisplayValue, "\"\"");
});
test("parseSchemaContent should reject unsupported oneOf combinators", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"properties": {
"itemCount": { "type": "integer" }
},
"oneOf": [
{
"type": "object",
"required": ["itemCount"],
"properties": {
"itemCount": { "type": "integer" }
}
}
]
}
}
}
`),
/unsupported combinator keyword 'oneOf'/u);
});
test("parseSchemaContent should reject unsupported additionalProperties forms", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"additionalProperties": true,
"properties": {
"itemCount": { "type": "integer" }
}
}
}
}
`),
/unsupported 'additionalProperties' metadata/u);
});
test("parseSchemaContent should reject unsupported explicit schema types", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "bogus"
}
}
}
`),
/declares unsupported type 'bogus'/u);
});
test("parseSchemaContent should build object const comparable keys with ordinal property ordering", () => {
const schema = parseSchemaContent(`
{
@ -1516,6 +1578,45 @@ test("parseSchemaContent should reject nested-array contains schemas", () => {
/unsupported nested array 'contains' schemas/u);
});
test("parseSchemaContent should reject array items without an explicit typed object schema", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"dropRates": {
"type": "array",
"items": [
{ "type": "integer" }
]
}
}
}
`),
/must declare 'items' as an object-valued schema with an explicit 'type'/u);
});
test("parseSchemaContent should reject contains without an explicit typed object schema", () => {
assert.throws(
() => parseSchemaContent(`
{
"type": "object",
"properties": {
"dropRates": {
"type": "array",
"contains": {
"const": 5
},
"items": {
"type": "integer"
}
}
}
}
`),
/must declare 'contains' as an object-valued schema with an explicit 'type'/u);
});
test("parseSchemaContent should reject minContains and maxContains without contains", () => {
assert.throws(
() => parseSchemaContent(`
@ -2498,6 +2599,166 @@ test("applyFormUpdates should rewrite object-array items from structured form pa
assert.match(updated, /^ monsterId: goblin$/mu);
});
test("buildFormModel should expose nested object-array editors inside object-array items", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"phases": {
"type": "array",
"items": {
"type": "object",
"properties": {
"wave": { "type": "integer" },
"spawns": {
"type": "array",
"items": {
"type": "object",
"properties": {
"monsterId": { "type": "string" },
"tags": {
"type": "array",
"items": { "type": "string" }
}
}
}
}
}
}
}
}
}
`);
const yaml = parseTopLevelYaml(`
phases:
-
wave: 1
spawns:
-
monsterId: slime
tags:
- starter
`);
const formModel = extensionTest.buildFormModel(schema, yaml, {});
const phasesField = formModel.fields.find((field) => field.path === "phases");
assert.ok(phasesField);
assert.equal(phasesField.kind, "objectArray");
assert.equal(phasesField.items.length, 1);
assert.deepEqual(formModel.unsupported, []);
const nestedSpawnField = phasesField.items[0].fields.find((field) => field.path === "spawns");
assert.ok(nestedSpawnField);
assert.equal(nestedSpawnField.kind, "objectArray");
assert.equal(nestedSpawnField.itemMode, true);
assert.equal(nestedSpawnField.items.length, 1);
const spawnMonsterField = nestedSpawnField.items[0].fields.find((field) => field.path === "monsterId");
assert.ok(spawnMonsterField);
assert.equal(spawnMonsterField.kind, "scalar");
const spawnTagsField = nestedSpawnField.items[0].fields.find((field) => field.path === "tags");
assert.ok(spawnTagsField);
assert.equal(spawnTagsField.kind, "array");
});
test("applyFormUpdates should rewrite nested object arrays from structured form payloads", () => {
const updated = applyFormUpdates(
[
"phases:",
" -",
" wave: 1"
].join("\n"),
{
objectArrays: {
phases: [
{
wave: "1",
spawns: [
{
monsterId: "slime",
tags: ["starter", "melee"],
reward: {
gold: "10"
}
},
{
monsterId: "goblin",
conditions: [
{
type: "night",
value: "true"
}
]
}
]
}
]
}
});
assert.match(updated, /^phases:$/mu);
assert.match(updated, /^ -$/mu);
assert.match(updated, /^ wave: 1$/mu);
assert.match(updated, /^ spawns:$/mu);
assert.match(updated, /^ -$/mu);
assert.match(updated, /^ monsterId: slime$/mu);
assert.match(updated, /^ tags:$/mu);
assert.match(updated, /^ - starter$/mu);
assert.match(updated, /^ - melee$/mu);
assert.match(updated, /^ reward:$/mu);
assert.match(updated, /^ gold: 10$/mu);
assert.match(updated, /^ monsterId: goblin$/mu);
assert.match(updated, /^ conditions:$/mu);
assert.match(updated, /^ -$/mu);
assert.match(updated, /^ type: night$/mu);
assert.match(updated, /^ value: true$/mu);
});
test("applyFormUpdates should not mix nested object-array items into the parent array", () => {
const updated = applyFormUpdates(
[
"phases:",
" -",
" wave: 1"
].join("\n"),
{
objectArrays: {
phases: [
{
wave: "1",
spawns: [
{
monsterId: "slime"
},
{
monsterId: "goblin"
}
]
},
{
wave: "2",
spawns: [
{
monsterId: "bat"
}
]
}
]
}
});
assert.equal((updated.match(/^ -$/gmu) || []).length, 2);
assert.equal((updated.match(/^ -$/gmu) || []).length, 3);
assert.doesNotMatch(updated, /^ monsterId: slime$/mu);
assert.doesNotMatch(updated, /^ monsterId: goblin$/mu);
assert.match(updated, /^ -$/mu);
assert.match(updated, /^ monsterId: slime$/mu);
assert.match(updated, /^ monsterId: goblin$/mu);
assert.match(updated, /^ monsterId: bat$/mu);
});
test("applyFormUpdates should clear object arrays when the form removes all items", () => {
const updated = applyFormUpdates(
[

View File

@ -51,6 +51,7 @@ test("buildContainsHintLines should use explicit minContains when provided", ()
const lines = buildContainsHintLines(
{
minContains: 2,
maxContains: 3,
contains: {
type: "string",
constValue: "\"potion\"",
@ -62,7 +63,8 @@ test("buildContainsHintLines should use explicit minContains when provided", ()
assert.deepEqual(lines, [
"Contains: string, Const: \"potion\", Ref table: item",
"Min contains: 2"
"Min contains: 2",
"Max contains: 3"
]);
});
@ -93,3 +95,24 @@ test("describeContainsSchema should format pattern-based contains schema in Chin
assert.equal(summary, "string, 正则模式:^potion-, 引用表item");
});
test("buildContainsHintLines should use updated Chinese contains hint wording", () => {
const localizer = createLocalizer("zh-cn");
const lines = buildContainsHintLines(
{
minContains: 1,
maxContains: 2,
contains: {
type: "string",
enumValues: ["potion", "elixir"]
}
},
localizer);
assert.deepEqual(lines, [
"contains 条件string, 允许值potion, elixir",
"最少匹配数1",
"最多匹配数2"
]);
});

View File

@ -68,6 +68,22 @@ test("createLocalizer should expose contains-count validation keys", () => {
assert.equal(
chineseLocalizer.t(ValidationMessageKeys.maxContainsViolation, {displayPath: "dropRates", value: 1}),
"属性“dropRates”最多只能包含 1 个匹配 contains 条件的元素。");
assert.equal(
chineseLocalizer.t("webview.hint.contains", {summary: "object, Required: itemCount"}),
"contains 条件object, Required: itemCount");
});
test("createLocalizer should resolve dependentRequired through the explicit validation key", () => {
const localizer = createLocalizer("en");
assert.equal(ValidationMessageKeys.dependentRequiredViolation, "validation.dependentRequiredViolation");
assert.equal(
localizer.t(ValidationMessageKeys.dependentRequiredViolation, {
displayPath: "reward.itemCount",
triggerProperty: "reward.itemId"
}),
"Property 'reward.itemCount' is required when sibling property 'reward.itemId' is present.");
assert.equal(localizer.t("undefined"), "undefined");
});
test("createLocalizer should expose not validation keys", () => {
@ -132,7 +148,7 @@ test("createLocalizer should expose dependentSchemas validation keys", () => {
trigger: "reward.itemId",
schema: "object, 必填字段itemCount"
}),
"当 reward.itemId 出现时:还必须满足 object, 必填字段itemCount");
"当 reward.itemId 出现时:还必须满足以下条件:object, 必填字段itemCount");
assert.equal(
englishLocalizer.t(ValidationMessageKeys.dependentSchemasViolation, {
displayPath: "reward",
@ -144,7 +160,7 @@ test("createLocalizer should expose dependentSchemas validation keys", () => {
displayPath: "reward",
triggerProperty: "reward.itemId"
}),
"对象“reward”在属性“reward.itemId”存在时必须满足对应的 dependent schema。");
"对象“reward”在属性“reward.itemId”存在时必须满足对应的依赖 schema。");
});
test("createLocalizer should expose allOf validation keys", () => {