一.DDD分层架构介绍

  本篇分析CQRS架构下的Equinox开源项目。该项目在github上star占有2.4k。便决定分析Equinox项目来学习下CQRS架构。再讲CQRS架构时,先简述下DDD风格,在DDD分层架构中,一般包含表现层、应用程序层(应用服务层)、领域层(领域服务层)、基础设施层。在DDD中讲到服务这个术语时,比如领域服务,应用层服务等,这个服务是指业务逻辑,而不是指任何技术如wcf,web服务。

  下图是从经典三层构架演变为DDD下的分层架构图:

SRE实战 互联网时代守护先锋,助力企业售后服务体系运筹帷幄!一键直达领取阿里云限量特价优惠。

asp.net core系列 62 CQRS架构下Equinox开源项目分析 随笔 第1张

 

  1.表现层

    表现层前端往后端post的数据称"输入模型(InputModel)",后端控制器传给前端要显示的数据称"视图模型(ViewModel)",大多时候视图模型与输入模型是重合的,所在在下面要介绍的开源项目中,作者在应用服务层只定义了ViewModels文件夹。例如在MVC中,控制器里只是编排任务,调用应用程序层。在控制器中代码块应该尽可能轻薄,主要作用是找出层与层之间的分离,控制器只是业务逻辑占位符

    在表现层中与运行环境密切相连,表现层需要关注的是http上下文、会话状态等。

asp.net core系列 62 CQRS架构下Equinox开源项目分析 随笔 第2张

 

  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实现全局图

    在全局图中,右图通过虚线表示双重分层架构,分开了命令通道和查询通道,每个通道都有独立架构。在命令通道里,任何来自表现层的请求都会变成一个命令,并加入到处理器队列。每个命令都携带信息。每个命令都是一个逻辑单元,可以充分地验证相关对象的状态,智能的决定执行哪些更新以及拒绝哪些更新。处理命令可能会产生事件(事件通常是记录命令发生的事情),这些事件会被其它注册组件处理

asp.net core系列 62 CQRS架构下Equinox开源项目分析 随笔 第3张

 

三. 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的数据库连接地址。

asp.net core系列 62 CQRS架构下Equinox开源项目分析 随笔 第4张

  

  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. 项目架构流程梳理图

asp.net core系列 62 CQRS架构下Equinox开源项目分析 随笔 第5张

四.表现层分析

  在表现层是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来实现。下面是表现层的二个项目:

asp.net core系列 62 CQRS架构下Equinox开源项目分析 随笔 第6张

  

五. 应用服务层分析

  Equinox.Application应用服务层包括对AutoMapper的配置管理,通过AutoMapper实现视图模型和领域模型的实体互转。定义ICustomerAppService服务接口供表现层调用,由CustomerAppService类来实现该接口。项目包含了Customer需要的视图模型。还有事件源EventSource。

  由CustomerAppService类来实现表现层的查询、命令、获取事件源。项目结构如下:

asp.net core系列 62 CQRS架构下Equinox开源项目分析 随笔 第7张

 

六.领域层Domain.Core分析

  领域层是项目分层架构中,最重要的一层,也是相对复杂的一层。该层作者用了二个项目包括:Domain.Core和Domain。Domain.Core项目结构如下所示:

asp.net core系列 62 CQRS架构下Equinox开源项目分析 随笔 第8张

  对于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项目结构如下:

asp.net core系列 62 CQRS架构下Equinox开源项目分析 随笔 第9张

  在上面结构中,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新增如下所示

asp.net core系列 62 CQRS架构下Equinox开源项目分析 随笔 第10张

    当在表现层点击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的信息,以及注册产生的事件数据,如下所示:

asp.net core系列 62 CQRS架构下Equinox开源项目分析 随笔 第11张

asp.net core系列 62 CQRS架构下Equinox开源项目分析 随笔 第12张

 

  在领域层的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用来持久化命令和事件,以及查询数据的仓储,结构如下:

asp.net core系列 62 CQRS架构下Equinox开源项目分析 随笔 第13张

  其中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做整个解决方案下项目需要的服务注入。

 

 

参考文献:

  Introduction-to-CQRS

  Microsoft.NET企业级应用架构设计 第二版

 

扫码关注我们
微信号:SRE实战
拒绝背锅 运筹帷幄