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来做传输对象。

  1. 基础设施层

这层使用具体技术有关的任何东西: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(控制反转的服务注入)
  1. 项目架构流程梳理图

    流程图更正:领域层Equinox.Domain不需要 引用 基础设施层事件总线Equinox.Infra.CrossCutting.Bus。在DDD风格下领域层是独立的,原则上不依赖于其它层。

四.表现层分析

在表现层是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项目主要是定义命令和事件的基类。源头是定义的抽象类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新增如下所示:


image

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


image

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


image

 其中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企业级应用架构设计 第二版

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容