asp.net core系列 62 CQRS架构下Equinox开源项目分析
一.ddd分层架构介绍
本篇分析cqrs架构下的equinox开源项目。该项目在github上star占有2.4k。便决定分析equinox项目来学习下cqrs架构。再讲cqrs架构时,先简述下ddd风格,在ddd分层架构中,一般包含表现层、应用程序层(应用服务层)、领域层(领域服务层)、基础设施层。在ddd中讲到服务这个术语时,比如领域服务,应用层服务等,这个服务是指业务逻辑,而不是指任何技术如wcf,web服务。
下图是从经典三层构架演变为ddd下的分层架构图:
1.表现层
表现层前端往后端post的数据称"输入模型(inputmodel)",后端控制器传给前端要显示的数据称"视图模型(viewmodel)",大多时候视图模型与输入模型是重合的,所在在下面要介绍的开源项目中,作者在应用服务层只定义了viewmodels文件夹。例如在mvc中,控制器里只是编排任务,调用应用程序层。在控制器中代码块应该尽可能轻薄,主要作用是找出层与层之间的分离,控制器只是业务逻辑占位符。
在表现层中与运行环境密切相连,表现层需要关注的是http上下文、会话状态等。
2. 应用服务层
可以在应用服务层引用领域层和基础设施层,是在领域层之上编排业务用例的服务。该层对业务规则一无所知,不会包含任何与业务有关的状态信息。该层关键特点:
(1) 该层是针对不同的前端。该层与表现层有关,是为表现层服务。不同的表现层(移动,webapi, web)都有自己的应用服务层。该层与表现层属于系统的前端。
(2) 应用服务层可能是有状态的,至少就ui任务进度而言。
(3) 它从表现层获取输入模型,然后把视图模型返回去。
3. 领域层
领域层是最重要和最复杂的一层。在ddd的领域模型架构下。该层包含了所有针对一个或多个用例业务逻辑,领域层包含一个领域模型和一组可能的服务。
领域模型大多时候是一个实体关系模型,可以由方法组成。是拥有数据和行为。如果缺少重要行为,那就是一个数据结构,称为贫血模型。领域模型是实现统一语言和表达业务流程所需的操作。
领域层包含的服务是领域服务,是涉及多个领域模型而无法放个单个领域模型中的领域逻辑。领域服务是一个类,包含了多个领域模型实体的行为。领域服务通常也需要访问基础设施层。
在ddd的cqrs架构下,使用二个不同的领域层,而不是一个(在equinox项目中混合成一个)。这种分离把查询操作放在一层(查询领域层),把命令操作放在另一层(命令领域层)。在cqrs里,查询栈仅仅基于sql查询,可以完全没有模型、应用程序层和领域层。查询领域层只需要贫血模型类dto来做传输对象。
4. 基础设施层
这层使用具体技术有关的任何东西:o/rm工具的数据访问持久层、ioc容器的实现(unity)、以及很多其它横切关注点的实现,如安全(oauth2)、日志记录、跟踪、缓存等。最突出的组件是持久层。
二.cqrs概述
1.简介
cqrs是ddd开发风格下对领域模型架构的一种简化改进。任何业务系统基本都是查询与写入,对应cqrs是指命令/查询责任分离,查询不以任何方式修改系统状态,只返回数据。另一方面,命令(写入)则修改系统的的状态,但不返回数据,除了状态代码或确认信息。在cqrs里,查询栈仅基于sql查询,可以完全没有模型,应用程序层和领域层。cqrs方案还可以为命令栈和查询栈准备不同的数据库(读与写)。
2.cqrs的好处
(1)是简化设计降低复杂性,对于查询来说,可以直接读取基础设施层的仓储。
(2)是增强可伸缩性的潜能。比如读取是主导操作,可以引入某种程序的缓存,极大减少访问数据库的次数。比如写入在高峰期减慢系统,可以考虑从经典的同步写入模型换到异步写入甚至命令队列。分离了查询和命令,可以完全隔离处理这两个部分的可伸缩性。
3.cqrs实现全局图
在全局图中,右图通过虚线表示双重分层架构,分开了命令通道和查询通道,每个通道都有独立架构。在命令通道里,任何来自表现层的请求都会变成一个命令,并加入到处理器队列。每个命令都携带信息。每个命令都是一个逻辑单元,可以充分地验证相关对象的状态,智能的决定执行哪些更新以及拒绝哪些更新。处理命令可能会产生事件(事件通常是记录命令发生的事情),这些事件会被其它注册组件处理。
三. equinox开源项目总览
1.准备环境
(1) github开源地址下载。full asp.net core 2.2 application with ddd, cqrs and event sourcing
(2) 在sqlserver里执行sql文件generatedatabase.sql。
(3) 修改appsettings.json中的connectionstrings的数据库连接地址。
2.项目分层说明
表现层:equinox.ui.web、equinox.services.api
应用服务层: equinox.application
领域层: equinox.domain、equinox.domain.core
基础设施层: equinox.infra.data(ef持久化)
基础设施层下的横切关注点:
equinox.infra.crosscutting.bus(事件和命令总线)
equinox.infra.crosscutting.identity(用户管理如登录、注册、授权)
equinox.infra.crosscutting.ioc(控制反转的服务注入)
3. 项目架构流程梳理图
四.表现层分析
在表现层是equinox.ui.web和equinox.services.api 服务。在equinox.ui.web下主要是用控制器中的customercontroller来演示cqrs框架的实现,以及accountcontroller和managecontroller的用户登录、注册、退出和用户信息管理。
对于accountcontroller和managecontroller两个控制器关联着equinox.infra.crosscutting.identity项目。identity项目包括了需要用的视图模型、对系统的授权、自定义用户表数据、用户数据同步到数据库的迁移版本管理、邮件和sms。对于授权方案通过equinox.infra.crosscutting.ioc来注入服务。如下所示:
// asp.net authorization polices services.addsingleton<iauthorizationhandler, claimsrequirementhandler>();
equinox.services.api项目实现的功能与web站点差不多,是通过暴露web api来实现。下面是表现层的二个项目:
五. 应用服务层分析
equinox.application应用服务层包括对automapper的配置管理,通过automapper实现视图模型和领域模型的实体互转。定义icustomerappservice服务接口供表现层调用,由customerappservice类来实现该接口。项目包含了customer需要的视图模型。还有事件源eventsource。
由customerappservice类来实现表现层的查询、命令、获取事件源。项目结构如下:
六.领域层domain.core分析
领域层是项目分层架构中,最重要的一层,也是相对复杂的一层。该层作者用了二个项目包括:domain.core和domain。domain.core项目结构如下所示:
对于domain.core项目主要是定义命令和事件的基类。源头是定义的抽象类message。对于命令和事件,任何前端都会发送消息给应用程序层, message消息就是数据传输对象,通常消息定义为一个message基类开始,作为数据容器。
这里使用mediatr中间件作为命令和事件的实现。mediatr支持两种消息类型:request/response和notification。先看下message消息基类定义:
//注入服务 services.addmediatr(typeof(startup));
/// <summary> /// message消息 /// 放入通用属性,甚至是普通标记,没有属性 /// </summary> public abstract class message : irequest<bool> { /// <summary> /// 消息类型:实现message的命令或事件类型 /// </summary> public string messagetype { get; protected set; } /// <summary> /// 聚合id /// </summary> public guid aggregateid { get; protected set; } protected message() { messagetype = gettype().name; } }
消息有二种:命令和事件。两种消息都包含了数据传输对象。命令和事件有些微妙差别,命令和事件都是message派生类。
/// <summary> /// event 领域消息 /// 事件类是不可变的,它表示已经发生的事情,意味着只有私有set,没有写入方法。 /// 事件存放通用属性,例如事件触发时间,触发的用户,数据版本号。 /// </summary> public abstract class event : message, inotification { public datetime timestamp { get; private set; } protected event() { //事件时间 timestamp = datetime.now; } }
/// <summary> /// command领域命令(增删改),不返回任何结果(void),但会改变数据对象的状态。 /// </summary> public abstract class command : message { public datetime timestamp { get; private set; } //dto绑定验证,使用fluent api来实现 public validationresult validationresult { get; set; } protected command() { //命令时间 timestamp = datetime.now; } //实现command抽象类的dto数据验证 public abstract bool isvalid(); }
domain.core项目还定义了领域实体和领域值对象的基类实现。例如:在领域实体基类中实现了相等性、运算符重载、重写hashcode。对于实体和值对象主要区别是:实体有明确的身份标识如主键id,guid。
public abstract class entity public abstract class valueobject<t> where t : valueobject<t>
domain.core项目中的notifications消息文件夹,用来确认消息发送后的处理状态。下面是表现层发送更新命令后,isvalidoperation()确认消息处理的状态情况。
[httppost] [authorize(policy = "canwritecustomerdata")] [route("customer-management/edit-customer/{id:guid}")] [validateantiforgerytoken] public iactionresult edit(customerviewmodel customerviewmodel) { if (!modelstate.isvalid) return view(customerviewmodel); _customerappservice.update(customerviewmodel); if (isvalidoperation()) viewbag.sucesso = "customer updated!"; return view(customerviewmodel); }
domain.core项目中的bus文件夹,用来做命令总线和事件总线的发送接口,由equinox.infra.crosscutting.bus项目来实现总线接口的发送。
七.领域层domain分析
下面是domain项目结构如下:
在上面结构中,commands和events文件夹分别用来存储命令和事件的数据传输对象,是贫血的dto类,也可以理解为领域实体。例如commands文件夹下命令数据传输对象定义:
/// <summary> /// customer数据转输对象抽象类,放customer通过属性 /// </summary> public abstract class customercommand : command { public guid id { get; protected set; } public string name { get; protected set; } public string email { get; protected set; } public datetime birthdate { get; protected set; } }
/// <summary> /// customer注册命令消息参数 /// </summary> public class registernewcustomercommand : customercommand { public registernewcustomercommand(string name, string email, datetime birthdate) { name = name; email = email; birthdate = birthdate; } /// <summary> /// 命令信息参数验证 /// </summary> /// <returns></returns> public override bool isvalid() { validationresult = new registernewcustomercommandvalidation().validate(this); return validationresult.isvalid; } }
当在应用服务层发送命令(bus.sendcommand)后,由领域层的commandhandlers文件夹下的类来处理命令,再调用ef持久层来改变实体状态。下面梳理下命令的执行流程,由表现层开始一个customer新增如下所示:
当在表现层点击create后,调用应用服务层register方法,触发一个新增事件,代码如下:
/// <summary> /// 新增 /// </summary> /// <param name="customerviewmodel">视图模型</param> public void register(customerviewmodel customerviewmodel) { //将视图模型 映射到 registernewcustomercommand 新增命令实体 var registercommand = _mapper.map<registernewcustomercommand>(customerviewmodel); bus.sendcommand(registercommand); }
当sendcommand发送命令后,由领域层customercommandhandler类中的handle来处理该命令,如下所示:
/// <summary> /// customer注册命令处理 /// </summary> /// <param name="message"></param> /// <param name="cancellationtoken"></param> /// <returns></returns> public task<bool> handle(registernewcustomercommand message, cancellationtoken cancellationtoken) { //对实体属性进行验证 if (!message.isvalid()) { notifyvalidationerrors(message); return task.fromresult(false); } //将命令消息转成领域实体 var customer = new customer(guid.newguid(), message.name, message.email, message.birthdate); //如果注册用户邮件已存在,发起一个事件 if (_customerrepository.getbyemail(customer.email) != null) { bus.raiseevent(new domainnotification(message.messagetype, "the customer e-mail has already been taken.")); return task.fromresult(false); } //由equinox.infra.data.repository来实现数据持久化。事件是过去在系统中发生的事情。该事件通常是命令的结果. _customerrepository.add(customer); //新增成功后,使用事件记录这次命令。 if (commit()) { bus.raiseevent(new customerregisteredevent(customer.id, customer.name, customer.email, customer.birthdate)); } return task.fromresult(true); }
下面是注册customer的信息,以及注册产生的事件数据,如下所示:
在领域层的interfaces文件夹中,最重要的包括irepository<tentity>接口,是通过equinox.infra.data.repository来实现接口,来进行数据持久化。下面是领域层仓储接口:
/// <summary> /// 领域层仓储接口,定义了通用的方法 /// </summary> /// <typeparam name="tentity"></typeparam> public interface irepository<tentity> : idisposable where tentity : class { void add(tentity obj); tentity getbyid(guid id); iqueryable<tentity> getall(); void update(tentity obj); void remove(guid id); int savechanges(); }
/// <summary> /// customer仓储接口,在基数仓储上扩展 /// </summary> public interface icustomerrepository : irepository<customer> { customer getbyemail(string email); }
interfaces文件夹中还定义了iuser和iunitofwork接口类,也是需要equinox.infra.data.repository来实现。
八. 基础设施层分析
equinox.infra.data项目是ef用来持久化命令和事件,以及查询数据的仓储,结构如下:
其中uow文件夹下的unitofwork类用来实现领域层的iunitofwork,使用commit保存数据。
public bool commit() { return _context.savechanges() > 0; }
repository文件夹下的类用来实现领域层的irepository接口,使用ef的dbset来操作ef tentity对象,再调用commit提交到数据库。
public virtual void add(tentity obj) { dbset.add(obj); }
repository文件夹下还包含eventsourcing事件源,存储到storedevent表中。
九.命令总线分析
equinox.infra.crosscutting.bus项目中使用了中间件mediatr,定义了inmemorybus类来实现领域层的imediatorhandler命令总线接口发送,使用sendcommand (t)和raiseevent (t)方法发送命令和事件。
mediatr是用于消息发送和消息处理的解耦,mediatr是一种进程内消息传递机制。 支持以同步或异步的形式进行请求/响应,命令,查询,通知和事件的消息传递,并通过c#泛型支持消息的智能调度。 其中irequest和inotification分别对应单播和多播消息的抽象。
例如:在领域层中,message消息实现irequest,代码如下:
/// <summary> /// message消息 /// 放入通用属性,甚至是普通标记,没有属性。irequest<t> - 有返回值 /// </summary> public abstract class message : irequest<bool>
最后equinox.infra.crosscutting.identity主要做用户管理,授权,迁移管理。equinox.infra.crosscutting.ioc做整个解决方案下项目需要的服务注入。
参考文献:
microsoft.net企业级应用架构设计 第二版