ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Modular Monolith 中的 CQRS 实践:MyMeetings 模块化架构读写分离决策全解析

Modular Monolith 中的 CQRS 实践:MyMeetings 模块化架构读写分离决策全解析 Modular Monolith 中的 CQRS 实践MyMeetings 模块化架构读写分离决策全解析【免费下载链接】modular-monolith-with-dddFull Modular Monolith application with Domain-Driven Design approach.项目地址: https://gitcode.com/GitHub_Trending/mo/modular-monolith-with-dddMyMeetingsmodular-monolith-with-ddd是一个采用 Domain-Driven DesignDDD方法的完整模块化单体Modular Monolith应用由 Meetings、Administration、Payments、User Access 四个业务模块构成。本文围绕架构决策记录 0007-use-cqrs-architectural-style.md 展开结合仓库源码与后续关联 ADR系统讲解 MyMeetings 如何在每个业务模块内部落地 CQRSCommand Query Responsibility Segregation架构风格。读完本文你将掌握CQRS 决策的完整背景与理由、模块门面Façade只接收 Command/Query 的契约设计、读模型两层架构与写模型 Clean Architecture 的源码级实现以及命令与查询可差异化处理、可序列化两大架构红利在项目中的真实用法。一、ADR-0007 决策背景读写请求的两种天然形态1.1 决策记录原文要义ADR-0007 于 2019-07-01 记录、2019-11-04 归档状态为Accepted已接受。其提出的核心问题非常朴素却直击本质一个应用要处理两类请求——读取reading与写入writing而这两类请求对数据模型的需求是截然不同的对于读取我们需要关系型relational形式的数据模型以便以表格化/扁平化tabular/flattened的方式返回数据——例如表格、列表、字典。对于写入我们需要**对象图graph of objects**来完成更复杂的工作——例如校验validations、业务规则检查business rules checks、计算calculations。这一观察与仓库源码完全吻合。以 Meetings 模块为例读取侧GetAllCountriesQueryHandler返回的是扁平的ListCountryDto直接对应一张 SQL 视图写入侧ProposeMeetingGroupCommandHandler操作的是MeetingGroupProposal聚合根及其值对象MeetingGroupLocation整个过程涉及业务规则校验与聚合内状态变更。也就是说一份模型不可能同时完美服务扁平化快照读取与复杂对象图写入两个目标这是 CQRS 决策最根本的动机。1.2 从同源问题看后续 ADR 的呼应值得说明的是这个 Context 并非孤立存在它直接孵化出两条后续决策形成完整的读写分离决策链条0009-use-2-layered-architectural-style-for-reads.md决定读请求采用两层架构API 层 Application Service 层0010-use-clean-architecture-for-writes.md决定写请求采用 Clean Architecture 四层架构API、Application Service、Infrastructure、Domain。本 ADR 是这条链条的总开关后续两篇 ADR 均以我们已采用 CQRS 风格见 ADR #7作为前提展开。因此理解本 ADR 是理解整个模块读写架构的入口。二、决策内容每个业务模块内部分离读写模型2.1 决策原文核心ADR-0007 的 Decision 部分明确了三点在每个业务模块内应用 CQRS 架构风格/模式——CQRS 不是系统级全局方案而是per module的局部决策每个模块拥有独立的读模型与写模型separate model for reading and writing采用最简单的 CQRS 实现读模型是即时一致的immediate consistent——不引入事件溯源、不引入读写分离的独立存储读写共用同一数据库。最后一点尤其关键MyMeetings 刻意选择了CQRS 的最小可行形态。即时一致意味着查询看到的永远是最近一次命令提交后的数据没有异步投影带来的滞后窗口实现成本最低、心智负担最小。这种克制与 0015-use-in-memory-events-bus.md 中选择最简单方案必要时再演进的思路一脉相承。2.2 连简单模块也适用User Access 的佐证决策原文特别指出这种分离即使在像 User Access 这样的简单模块中也是有用的This kind of separation is useful even in simple modules like User Access。查看仓库源码可以验证这一点User Access 模块同样拥有完整的读写契约体系——ICommand.cs含ICommand与ICommandTResult、IQuery.cs、ICommandHandlersrc/Modules/UserAccess/Application/Configuration/Commands/ICommandHandler.cs以及ICommandsScheduler。即使模块体量小、业务简单依然按照 CQRS 的标准姿势搭建了读写分离的骨架这印证了决策中分离本身就有价值的判断——它统一了所有模块的开发范式让新加入的开发者面对任何模块都有相同的认知模型。2.3 模块化的前提4 个 Bounded Context每个业务模块的模块划分来自更早的 0004-divide-the-system-into-4-modules.mdMyMeetings 域包含 4 个子域——Meetings核心域、Administration支撑子域、Payments支撑子域、User Access通用域并按 Bounded Context 1:1 映射为 4 个自治模块。CQRS 决策正是在这 4 个模块内部各自生效的。三、CQRS 落地骨架之一模块门面只接收 Command 或 Query3.1 门面契约Consequence 1 的落地ADR-0007 的 Consequences 第一条每个模块的门面方法只应接收 Command 或 Query 对象作为参数Façade method of each module should take as parameter only Command or Query object。这条后果直接呼应 0006-create-facade-between-api-and-business-module.md 中定义的门面接口。以 Meetings 模块为例门面契约 IMeetingsModule.cs 只暴露 3 个方法public interface IMeetingsModule { TaskTResult ExecuteCommandAsyncTResult(ICommandTResult command); Task ExecuteCommandAsync(ICommand command); TaskTResult ExecuteQueryAsyncTResult(IQueryTResult query); }API 层只能通过这 3 个门面方法与业务模块通信且参数类型被严格限定为ICommand/IQuery及其泛型变体——不允许出现直接传 DTO、直接调领域方法的旁路通道。这保证了模块封装性API 看不见模块内部的领域模型、仓储、DbContext模块内部实现如何演进换 ORM、换存储都不会波及 API 层。其实现类 MeetingsModule.cs 展示了门面背后的调度逻辑public async TaskTResult ExecuteQueryAsyncTResult(IQueryTResult query) { using (var scope MeetingsCompositionRoot.BeginLifetimeScope()) { var mediator scope.ResolveIMediator(); return await mediator.Send(query); } }注意这里MeetingsCompositionRootAutofac 组合根见 MeetingsCompositionRoot.cs为每次调用开启独立 LifetimeScope配合 ADR-0016每模块独立 IoC 容器的思想实现请求级依赖隔离。3.2 从 Controller 到门面的完整链路以 Meetings 模块的会议组提议功能为例MeetingGroupProposalsController.cs 是 CQRS 门面用法的典型样本[HttpGet()] [HasPermission(MeetingsPermissions.GetMeetingGroupProposals)] public async TaskIActionResult GetMemberMeetingGroupProposals() { var meetingGroupProposals await _meetingsModule.ExecuteQueryAsync( new GetMemberMeetingGroupProposalsQuery()); return Ok(meetingGroupProposals); } [HttpPost()] [HasPermission(MeetingsPermissions.ProposeMeetingGroup)] public async TaskIActionResult ProposeMeetingGroup(ProposeMeetingGroupRequest request) { await _meetingsModule.ExecuteCommandAsync( new ProposeMeetingGroupCommand( request.Name, request.Description, request.LocationCity, request.LocationCountryCode)); return Ok(); }同一个 Controller 中读请求走ExecuteQueryAsync、写请求走ExecuteCommandAsync路径清晰分离HasPermission特性见 src/API/CompanyName.MyMeetings.API/Configuration/Authorization则在门面之前完成基于权限的授权过滤。四、CQRS 落地骨架之二读写契约体系4.1 ICommand / IQuery基于 MediatR 的请求契约所有 Command 与 Query 都继承自 MediatR 的IRequest这意味着门面内部统一通过 MediatR 分发命令/查询处理器即 MediatR 的IRequestHandler。以 Meetings 模块契约为例ICommand.cs、IQuery.cspublic interface ICommandout TResult : IRequestTResult { Guid Id { get; } } public interface ICommand : IRequest { Guid Id { get; } } public interface IQueryout TResult : IRequestTResult { }设计要点Command 必须携带IdGuid这是为命令可追溯、可调度、可持久化见第六节埋下的伏笔Query 则没有 Id——查询是幂等、无副作用的不需要被追踪。ICommandTResult允许命令返回结果如ProposeMeetingGroupCommand返回新聚合的Guid这对应后续 [0008-allow-return-result-after-command-processing.md 决策思路]仓库中对应契约即ICommandout TResult实际落地文件为 CommandBase.cs。4.2 CommandBase / QueryBase统一行为基类实际业务命令/查询继承自抽象基类CommandBase.cs、QueryBase.cspublic abstract class CommandBaseTResult : ICommandTResult { protected CommandBase() { Id Guid.NewGuid(); } protected CommandBase(Guid id) { Id id; } // 支持反序列化/定时任务重放时指定 Id public Guid Id { get; } } public abstract class QueryBaseTResult : IQueryTResult { public Guid Id { get; } protected QueryBase() { Id Guid.NewGuid(); } protected QueryBase(Guid id) { Id id; } }值得注意的细节CommandBase/QueryBase都提供了显式指定 Id的受保护构造函数。这个看似不起眼的入口实际服务于内部命令Internal Command调度——当 Quartz 定时任务从数据库恢复并重放一个已持久化的命令时需要保持其原始 Id 以便回写处理状态见第六节UnitOfWorkCommandHandlerDecorator对InternalCommandBase的判定逻辑。具体业务命令示例 ProposeMeetingGroupCommand.cspublic class ProposeMeetingGroupCommand : CommandBaseGuid { public ProposeMeetingGroupCommand(string name, string description, string locationCity, string locationCountryCode) { Name name; Description description; LocationCity locationCity; LocationCountryCode locationCountryCode; } public string Name { get; } public string Description { get; } public string LocationCity { get; } public string LocationCountryCode { get; } }命令对象是纯数据载体只读属性 构造函数注入无行为。这种命令即数据的设计是 CQRS 可序列化见第六节能够成立的前提。五、Consequence 2读写模型各自优化SRP 原则的源码证据ADR-0007 的 Consequences 第二条我们为写入和读取分别优化了模型SRP 原则。这条原则在仓库中的落地方式需要结合 ADR-0009 与 ADR-0010 才能真正看懂——读模型与写模型不仅概念分离连架构层数、技术栈、代码路径都完全分离。5.1 读模型两层架构 Dapper SQL 视图依据 0009-use-2-layered-architectural-style-for-reads.md查询处理只有两层API 层负责基于 HTTP 请求创建 Query模块 Application 层负责处理 Query。其取舍直白不抽象数据库、不做对象映射、查询几乎即时到达数据库性能更好、方案简单易懂。查询处理器直接依赖ISqlConnectionFactory见 ISqlConnectionFactory.cs并用 Dapper 执行原生 SQL。以 GetAllCountriesQueryHandler.cs 为例internal class GetAllCountriesQueryHandler : IQueryHandlerGetAllCountriesQuery, ListCountryDto { private readonly ISqlConnectionFactory _sqlConnectionFactory; public async TaskListCountryDto Handle(GetAllCountriesQuery query, CancellationToken cancellationToken) { var connection _sqlConnectionFactory.GetOpenConnection(); const string sql $ SELECT [Country].[Code] AS [{nameof(CountryDto.Code)}], [Country].[Name] AS [{nameof(CountryDto.Name)}] FROM [meetings].[v_Countries] AS [Country] ; return (await connection.QueryAsyncCountryDto(sql)).AsList(); } }三个值得强调的实现事实查询直接打 SQL 视图FROM [meetings].[v_Countries]。视图定义在 v_Countries.sql数据库层把底层表结构封装成面向查询的扁平化形态这正是读模型即 SQL 视图的体现。读模型数据库独立 schemaMeetings 模块的查询视图全部位于meetingsschema 下共 11 个v_Countries、v_Meetings、v_MeetingDetails、v_MeetingAttendees、v_MeetingComments、v_MeetingGroupProposals、v_MeetingGroups、v_Members、v_MemberMeetings等见 src/Database/CompanyName.MyMeetings.Database/Structure/meetings/Views与写模型的物理表在结构层面就做了区隔。跨模块同样成立Payments 模块的 GetMeetingFeesQueryHandler.cs 也是同款姿势——ISqlConnectionFactory Dapper WHERE [MeetingFee].MeetingId MeetingId参数化查询直接读[payments].[MeetingFees]表并映射为扁平 DTO。说明这套读模型两层架构是所有模块的统一惯例。对应地查询处理器接口 IQueryHandler.cs 只是一个标记性约束接口没有额外装饰器——查询路径零横切关注点直来直去。5.2 写模型Clean Architecture DDD 聚合依据 0010-use-clean-architecture-for-writes.md命令处理需要 4 层API → Application Service → Infrastructure → Domain。新增 Domain 层的理由是领域逻辑会复杂需要把它与基础设施、API 隔离以支撑可测试性、可维护性与可读性。命令处理器依赖领域抽象仓储接口、上下文接口而非具体设施。以 ProposeMeetingGroupCommandHandler.cs 为例internal class ProposeMeetingGroupCommandHandler : ICommandHandlerProposeMeetingGroupCommand, Guid { private readonly IMeetingGroupProposalRepository _meetingGroupProposalRepository; private readonly IMemberContext _memberContext; public async TaskGuid Handle(ProposeMeetingGroupCommand request, CancellationToken cancellationToken) { var meetingGroupProposal MeetingGroupProposal.ProposeNew( request.Name, request.Description, MeetingGroupLocation.CreateNew(request.LocationCity, request.LocationCountryCode), _memberContext.MemberId); await _meetingGroupProposalRepository.AddAsync(meetingGroupProposal); return meetingGroupProposal.Id.Value; } }这条命令处理链路完整呈现了Clean Architecture for writes的形态处理器只依赖IMeetingGroupProposalRepository领域层接口与IMemberContext成员上下文抽象见 IMemberContext.cs不直接触碰 DbContext 或 SQL业务动作通过聚合根的领域方法MeetingGroupProposal.ProposeNew(...)触发聚合内部执行业务规则检查、抛出BusinessRuleValidationException见 BusinessRuleValidationException.cs、产生领域事件返回meetingGroupProposal.Id.Value满足ICommandTResult的命令返回结果能力。读模型没有领域层、写模型才有领域层——这正是读写模型各自优化最直接的架构证据读模型薄、快、直连数据库写模型厚、稳、承载全部业务复杂度。六、Consequence 3命令与查询的差异化处理机制ADR-0007 的 Consequences 第三条我们可以用不同的方式处理 Command 和 Query。在 MyMeetings 中这种差异化通过命令管线装饰器实现而查询管线则刻意保持裸奔。命令分发入口 CommandsExecutor.cs 如下internal static async Task Execute(ICommand command) { using (var scope MeetingsCompositionRoot.BeginLifetimeScope()) { var mediator scope.ResolveIMediator(); await mediator.Send(command); } } internal static async TaskTResult ExecuteTResult(ICommandTResult command) { using (var scope MeetingsCompositionRoot.BeginLifetimeScope()) { var mediator scope.ResolveIMediator(); return await mediator.Send(command); } }命令在 MediatR 管道上被注册了一组装饰器decorator其中两个可直接看到横切逻辑① 工作单元装饰器UnitOfWorkCommandHandlerDecorator.cs命令执行成功后统一CommitAsync若命令是InternalCommandBase内部命令还会同步把数据库内命令记录的ProcessedDate置为当前时间形成命令执行 内部命令状态回写 事务提交的原子单元。② 领域事件分发装饰器DomainEventsDispatcherNotificationHandlerDecorator.cs命令触发的领域事件在命令边界内被收集并经IDomainEventsDispatcher分发随后通过 Outbox 模式与 In-Memory Events Bus 传播为模块间集成事件详见 0014-event-driven-communication-between-modules.md 与 0015-use-in-memory-events-bus.md。而在同一模块中查询路径 ExecuteQueryAsync 直接mediator.Send(query)不挂任何装饰器——读操作不需要事务、不需要领域事件分发、不需要审计。写重、读轻两者处理方式的差异在管线上得到了最直白的体现。七、Consequence 4Command/Query 对象化带来的可序列化能力ADR-0007 的 Consequences 第四条由于 Command 或 Query 是对象我们可以轻松地序列化它们并保存/记录它们。这是本 ADR 最具前瞻性的一条后果仓库中有两处直接证据。7.1 内部命令调度命令被序列化进数据库Meetings 模块的 CommandsScheduler.cs 将命令序列化为 JSON 后写入InternalCommands表供 Quartz 后台任务定时取回执行command.Id, EnqueueDate DateTime.UtcNow, Type command.GetType().FullName, Data JsonConvert.SerializeObject(command, new JsonSerializerSettings { ContractResolver new AllPropertiesContractResolver() })这里有两个关键实现细节Type command.GetType().FullName序列化时保存命令类型的完整名称反序列化时据此还原具体命令类型AllPropertiesContractResolver见 AllPropertiesContractResolver.cs自定义的契约解析器确保包括只读属性在内的全部属性都能被序列化——这正是第四节中命令是纯数据载体、只有只读属性这一设计能落地的配套基础。配合ICommandsScheduler接口src/Modules/Meetings/Application/Configuration/Commands/ICommandsScheduler.cs以及CommandBase(Guid id)受保护构造函数命令可持久化、可重放、可追溯的定时任务机制得以成立。7.2 Outbox 模式命令/事件的序列化中转同样的序列化思路也用于模块间通信。Outbox 消息OutboxMessage.cs以TypeData形式保存集成事件IntegrationEventGenericHandler.cs 在消费集成事件时同样使用JsonConvert.SerializeObjectAllPropertiesContractResolver组合。命令对象、集成事件对象因此成为可序列化的统一消息载体。可以说命令/查询是对象这一看似朴素的设计实际上为整个项目的异步化、持久化、可观测性能力提供了最底层的数据结构支撑。八、即时一致性读模型与边界澄清最后有必要澄清一个容易混淆的边界以准确理解本 ADR 的适用范围。ADR-0007 明确选择读模型即时一致的最简实现这意味着模块内部的读写关系是同步、即时的命令提交成功后同一个模块内的查询立刻能看到最新数据。这一点在模块内部的集成测试中大量使用——例如 Meetings 模块的集成测试src/Modules/Meetings/Tests/IntegrationTests与 Payments 模块的集成测试均采用执行命令 → 轮询/查询断言的模式其前提正是读模型的即时一致性。而模块之间的通信则走事件驱动0014-event-driven-communication-between-modules.md与 In-Memory Events Bus0015-use-in-memory-events-bus.md是最终一致的。两类一致性的边界以模块为界模块内即时一致CQRS 最简实现模块间最终一致事件驱动。理解这条边界是阅读本项目其余 ADR 与源码的关键前提。九、总结一份 ADR 背后的完整架构拼图ADR-0007 全文不过一页但它撬动了 MyMeetings 架构中几乎所有的读写相关机制。回顾整个决策的价值链条决策要点落地位置仓库证据每个业务模块应用 CQRS4 个模块各自维护独立的 Contracts、Application、Infrastructure 层门面只接收 Command/QueryIMeetingsModule.cs MeetingsModule.cs读写模型分离SRP读两层 Dapper SQL 视图GetAllCountriesQueryHandler.cs写Clean Architecture DDD 聚合ProposeMeetingGroupCommandHandler.cs命令/查询差异化处理命令挂工作单元、领域事件分发等装饰器CommandsExecutor.cs查询裸跑命令/查询可序列化内部命令调度CommandsScheduler.cs与 OutboxOutboxMessage.cs这份 ADR 也充分体现了 MyMeetings 项目架构决策可追溯的工程文化每一条 Consequences 都能在 docs/architecture-decision-log 目录下的后续 ADR 与源码中找到对应实现。对于希望在自己的模块化单体或单体应用中引入 CQRS 的团队本文梳理的门面契约 读写契约体系 读两层/写四层 命令装饰器管线 命令可序列化五个落地点可以作为一套可直接对照的检查清单。【免费下载链接】modular-monolith-with-dddFull Modular Monolith application with Domain-Driven Design approach.项目地址: https://gitcode.com/GitHub_Trending/mo/modular-monolith-with-ddd创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表