缘起
哈喽大家好,又是周二了,时间很快,我的第二个系列DDD领域驱动设计讲解已经接近尾声了,除了今天的时间驱动EDA(也有可能是两篇),然后就是下一篇的事件回溯,就剩下最后的权限验证了,然后就完结了,这两个月我也是一直在自学,然后再想栗子,个人感觉收获还是很大的,比如DDD领域分层设计、CQRS读写分离、CommandBus命令总线、EDA事件驱动、四色原理等等,如果大家真的能踏踏实实的看完,或者说多看看书,对个人的思想提高有很大的帮助,这里要说两点,可能会有一些小伙伴不开心,但是还是要说说:
1、很多小伙伴一直问我看什么书,我个人感觉,只要是书看就对了,与其纠结哪本,还不如踏踏实实先看一本。
2、还有小伙伴问,为啥还没有看到微服务的内容?
我想说,其实微服务是一个很宽泛的领域,比如.net core的深入学习,依赖注入的使用,仓储契约、DDD+事件总线的学习、中介者模式、Docker的学习、容器化设计等等等等,这些都属于微服务的范畴,如果这些基础知识不会的话,可能是学不好微服务的。
周末的时候,我又好好的整理了下我的Github上的代码,然后新建了一些分支(如果你不会使用Git命令,可以看我的一个文章:https://www.jianshu.com/p/2b666a08a3b5,会一直更新),主要是这样的(这个数字是对应的文章,比如今天的是第 12 ):
其实我这个系列所说的 DDD领域驱动设计,是一个很丰富的概念,里边包含了DDD的多层设计思想、CQRS、Bus、EDA、ES等等,所以如果你只想要其中的一部分,可以对应的分支进行Clone,比如你单纯想要一个干净的基于DDD四层设计的模板,可以克隆 Framework_DDD_8 这个分支,如果你想带有读写分离,可以克隆 CQRS_DDD_9 这个分支等等,也方便好好研究。
关于CQRS读写分离概念,请注意,分离不一定是分库,一个数据库也能实现读写分离,最简单的就是从Code上来区分。
前言
好啦,上边说了一些周末的思考,现在马上进入正文,不知道大家对上周的内容还有没有印象,主要用两篇文章来说明了命令总线的设计思想和执行过程《十 ║领域驱动【实战篇·中】:命令总线Bus分发(一)》、《十一 ║ 基于源码分析,命令分发的过程(二)》,咱们很好的实现了多个复杂模型间的解耦,成功的简化了API接口层和 Application应用服务层,把重心真正的转义到了领域层。
当然其中也有一些新的问题出现了,这个也可以当作今天的每篇一问:
首先,对领域通知的处理上,目前用的是通过一个 ErrorData 的key 来把错误通知放到了内存里,然后去读取,这样有一个很危险的问题,就是生命周期的问题,如果在当前实例中,没有及时删除,可能会出现错误通知的混乱,这是致命的,当然还有 key 的问题,因为几乎每一个 Command 都会有不同的信息,我们不能通过简简单单的人为取名字来实现这个逻辑,这是荒唐的。
其次,如果我们 Command 执行完成,是如何发布通知的,比如注册成功的邮件,短信分发,站内推送等等。
最后,不知道大家有没有深入的去学习,去了解 MediatR 中介者的两个模式:请求/响应模式 与 发布/订阅模式的区别和联系(详细的下边会说到)。
你会说,很简单呀,我们直接在 CommandHandler 命令处理程序中处理不就行了,一步一步往下走就可以了呀,如果你现在还有这样的思维,那DDD可真的好好再学习了,为什么呢?很简单,我们当时为什么要把 contrller 的业务逻辑剥离到领域模型,就是为了业务独立化,不让多个不相干的业务缠绕(比如我们之前是把model 验证、错误返回、发邮件等,都是写在 controller 里的),那如果我们再把过多的业务逻辑写到命令处理程序中的话,那命令处理模型不就成为了第二个 controller 了么?我们为业务把 controller 剥离了一次,那今天咱们就继续从 命令处理程序中,再优化一次。
零、今天要实现右下角蓝色的部分
(周末有一个小伙伴问这个软件的地址:https://www.mindmeister.com,应该需要翻墙)
一、领域事件驱动设计 —— EDA
1、什么是领域事件
我们先看看官网,在《实现领域驱动设计》一书中对领域事件的定义如下:
领域专家所关心的发生在领域中的一些事件。
将领域中所发生的活动建模成一系列的离散事件。
每个事件都用领域对象来表示,领域事件是领域模型的组成部分,表示领域中所发生的事情。
领域事件:Domain Event,是针对某个业务来说的,或者说针对某个聚合的业务来说的,例如订单生成这种业务,它可以同时对应一种事件,比如叫做OrderGeneratorEvent,而你的零散业务可能随时会变,加一些业务,减一些业务,而对于订单生成这个事件来说,它是唯一不变的,而我们需要把这些由产生订单而发生变化的事情拿出来,而拿出来的这些业务就叫做"领域事件".其中的领域指的就是订单生成这个聚合;而事件指的就是那些零散业务的统称.
2、领域事件包含了哪些内容
如果你对上一篇命令总线很熟悉,这里就特别简单,几乎是一个模式,只不过总线发布的方式不一样罢了,如果你比较熟悉命令驱动,这里正好温习。如果不了解,这里就一起看吧,千万记得再回去看前两篇内容哟。
在面向对象的编程世界里,做这种事情我们需要几个抽象:
领域对象事件标示:标示接口,接口的一种,用来约束一批对象,IEvent(当前也可以使用抽象类,本文即是)
领域对象的处理方法行为:比如 StudentEventHandler。(我们的命令处理程序也是如此)
事件总线:事件处理核心类,承载了事件的发布,订阅与取消订阅的逻辑,EventBus(这个和我们的命令总线CommandBus很类似)
某个领域对象的事件:它是一个事件处理类,它实现了 EventHandler,它所处理的事情需要在Handle里去完成。
一个领域事件可以理解为是发生在一个特定领域中的事件,是你希望在同一个领域中其他部分知道并产生后续动作的事件。一个领域事件必须对业务有价值,有助于形成完整的业务闭环,也即一个领域事件将导致进一步的业务操作。就比如我们今天说到的领域通知,就应该是一个事件,我们从命令中产生的错误提示,通过处理程序,引发到事件总线内,并返回到前台。
3、为什么需要领域事件
领域事件也是一种基于事件的架构(EDA)。事件架构的好处可以把处理的流程解耦,实现系统可扩展性,提高主业务流程的内聚性。
在咱们文章的开头,可说到了这个问题,不知道大家是否还记得,咱们再分析一下:
我们提交了一个添加Student 的申请,系统在完成保存后,可能还需要发送一个通知(当然这里错误信息,也有成功的),当然肯定还会会一些其他的后台服务的活动。如果把这一系列的动作放入一个处理过程中,会产生几个的明显问题:
1、一个是命令提交的的事务比较长,性能会有问题,甚至在极端情况下容易引发数据库的严重故障(服务器方面);
2、另外提交的服务内聚性差,可维护性差,在业务流程发生变更时候,需要频繁修改主程序(程序员方面)。
3、我们有时候只关心核心的流程,就比如添加Student,我们只关心是否添加成功,而且我们需要对这个成功有反馈,但是发邮件的功能,我们却不用放在主业务中,甚至发送成功与否,不影响 Student 的正常添加,这样我们就把后续的这些活动事件,从主业务中剥离开,实现了高内聚和低耦合(业务方面)。
还记得 MediatR 有两个中介者模式么:请求/响应 和 发布/订阅。在我们的系统中,添加一个学生命令,就是用到的请求/响应 IRequest 模式,因为我们需要等待当前操作完成,我们需要总线对我们的请求做出响应。
但是有时候我们不需要在同一请求/响应中立即执行一个动作的结果,只要异步执行这个动作,比如发送电子邮件。在这种情况下,我们使用发布/订阅模式,以异步方式发送电子邮件,并避免让用户等待发送电子邮件。
4、领域事件驱动是如何运行的呢?
这个时候,就用到之前我画的图了,中介者模式下,上半部的命令总线已经说完,今天说另一半事件总线:
当然这里也有一个网上的栗子,很不错:
从图中我们也可以看到,事件驱动的工作流程呢,在命令模式下,主要是在我们的命令处理程序中出现,在我们对数据进行持久化操作的时候,作为一个后续活动事件来存在,比如我们今天要实现的两个处理工作:
1、通知信息的收集(之前我们是采用的缓存 Memory 来实现的);
2、领域通知处理程序(比如发邮件等);
这个时候,如果你对事件驱动有了一定的理解的话,你就会问,那我们在项目中具体的应该使用呢,请往下看。
二、创建事件总线
这个整体流程其实和命令总线分发很像,所以原理就不分析了,相信你如果看了之前的两篇文章的话,一定能看懂今天的内容的。
1、定义领域事件标识基类
就如上边我们说到的,我们可以定义一个接口,也可以定义一个抽象类,我比较习惯用抽象类,在核心领域层 Christ3D.Domain.Core 中的Events 文件夹中,新建Event.cs 事件基类:
namespace Christ3D.Domain.Core.Events { /// <summary> /// 事件模型 抽象基类,继承 INotification /// 也就是说,拥有中介者模式中的 发布/订阅模式 /// </summary> public abstract class Event : INotification { // 时间戳 public DateTime Timestamp { get; private set; } // 每一个事件都是有状态的 protected Event() { Timestamp = DateTime.Now; } } }
2、定义事件总线接口
在中介处理接口IMediatorHandler中,定义引发事件接口,作为发布者,完整的 IMediatorHandler.cs 应该是这样的
namespace Christ3D.Domain.Core.Bus { /// <summary> /// 中介处理程序接口 /// 可以定义多个处理程序 /// 是异步的 /// </summary> public interface IMediatorHandler { /// <summary> /// 发送命令,将我们的命令模型发布到中介者模块 /// </summary> /// <typeparam name="T"> 泛型 </typeparam> /// <param name="command"> 命令模型,比如RegisterStudentCommand </param> /// <returns></returns> Task SendCommand<T>(T command) where T : Command; /// <summary> /// 引发事件,通过总线,发布事件 /// </summary> /// <typeparam name="T"> 泛型 继承 Event:INotification</typeparam> /// <param name="event"> 事件模型,比如StudentRegisteredEvent,</param> /// 请注意一个细节:这个命名方法和Command不一样,一个是RegisterStudentCommand注册学生命令之前,一个是StudentRegisteredEvent学生被注册事件之后 /// <returns></returns> Task RaiseEvent<T>(T @event) where T : Event; } }
3、实现总线分发接口
在基层设施总线层的记忆总线 InMemoryBus.cs 中,实现我们上边的事件分发总线接口:
/// <summary> /// 引发事件的实现方法 /// </summary> /// <typeparam name="T">泛型 继承 Event:INotification</typeparam> /// <param name="event">事件模型,比如StudentRegisteredEvent</param> /// <returns></returns> public Task RaiseEvent<T>(T @event) where T : Event { // MediatR中介者模式中的第二种方法,发布/订阅模式 return _mediator.Publish(@event); }
注意这里使用的是中介模式的第二种——发布/订阅模式,想必这个时候就不用给大家解释为什么要使用这个模式了吧(提示:不需要对请求进行必要的响应,与请求/响应模式做对比思考)。现在我们把事件总线定义(是一个发布者)好了,下一步就是如何定义事件模型和处理程序了也就是订阅者,如果上边的都看懂了,请继续往下走。
三、事件模型的处理与使用
可能这句话不是很好理解,那说人话就是:我们之前每一个领域模型都会有不同的命令,那每一个命令执行完成,都会有对应的后续事件(比如注册和删除用户肯定是不一样的),当然这个是看具体的业务而定,就比如我们的订单领域模型,主要的有下单、取消订单、删除订单等。
我个人感觉,每一个命令模型都会有对应的事件模型,而且一个命令处理方法可能有多个事件方法。具体的请看:
1、定义添加Student 的事件模型
当然还会有删除和更新的事件模型,这里就用添加作为栗子,在领域层 Christ3D.Domain 中,新建 Events 文件夹,用来存放我们所有的事件模型,
因为是 Student 模型,所以我们在 Events 文件夹下,新建 Student 文件夹,并新建 StudentRegisteredEvent.cs 学生添加事件类:
namespace Christ3D.Domain.Events { /// <summary> /// Student被添加后引发事件 /// 继承事件基类标识 /// </summary> public class StudentRegisteredEvent : Event { // 构造函数初始化,整体事件是一个值对象 public StudentRegisteredEvent(Guid id, string name, string email, DateTime birthDate, string phone) { Id = id; Name = name; Email = email; BirthDate = birthDate; Phone = phone; } public Guid Id { get; set; } public string Name { get; private set; } public string Email { get; private set; } public DateTime BirthDate { get; private set; } public string Phone { get; private set; } } }
2、定义领域事件的处理程序Handler
这个和我们的命令处理程序一样,只不过我们的命令处理程序是总线在应用服务层分发的,而事件处理程序是在领域层的命令处理程序中被总线引发的,可能有点儿拗口,看看下边代码就清楚了,就是一个引用场景的顺序问题。
在领域层Chirst3D.Domain 中,新建 EventHandlers 文件夹,用来存放我们的事件处理程序,然后新建 Student事件模型的处理程序 StudentEventHandler.cs:
namespace Christ3D.Domain.EventHandlers { /// <summary> /// Student事件处理程序 /// 继承INotificationHandler<T>,可以同时处理多个不同的事件模型 /// </summary> public class StudentEventHandler : INotificationHandler<StudentRegisteredEvent>, INotificationHandler<StudentUpdatedEvent>, INotificationHandler<StudentRemovedEvent> { // 学习被注册成功后的事件处理方法 public Task Handle(StudentRegisteredEvent message, CancellationToken cancellationToken) { // 恭喜您,注册成功,欢迎加入我们。 return Task.CompletedTask; } // 学生被修改成功后的事件处理方法 public Task Handle(StudentUpdatedEvent message, CancellationToken cancellationToken) { // 恭喜您,更新成功,请牢记修改后的信息。 return Task.CompletedTask; } // 学习被删除后的事件处理方法 public Task Handle(StudentRemovedEvent message, CancellationToken cancellationToken) { // 您已经删除成功啦,记得以后常来看看。 return Task.CompletedTask; } } }
相信大家应该都能看的明白,在上边的注释已经很清晰的表达了响应的作用,如果有看不懂,咱们可以一起交流。
好啦,现在第二步已经完成,剩下最后一步:如何通过事件总线分发我们的事件模型了。
3、在事件总线EventBus中引发事件
这个使用起来很简单,主要是我们在命令处理程序中,处理完了持久化以后,接下来调用我们的事件总线,对不同的事件模型进行分发,就比如我们的 添加Student 命令处理程序方法中,我们通过工作单元添加成功后,需要做下一步,比如发邮件,那我们就需要这么做。
在命令处理程序 StudentCommandHandler.cs 中,完善我们的提交成功的处理:
// 持久化 _studentRepository.Add(customer); // 统一提交 if (Commit()) { // 提交成功后,这里需要发布领域事件 // 比如欢迎用户注册邮件呀,短信呀等 Bus.RaiseEvent(new StudentRegisteredEvent(customer.Id, customer.Name, customer.Email, customer.BirthDate,customer.Phone)); }
这样就很简单的将我们的事件模型分发到了事件总线中去了,这个时候记得要在 IoC 原生注入类NativeInjectorBootStrapper中,进行注入。关于触发过程下边我简单说一下。
4、整体事件驱动执行过程
说到了这里,你可能发现和命令总线很相似,也可能不是很懂,简单来说,整体流程是这样的:
1、首先我们在命令处理程序中调用事件总线来引发事件 Bus.RaiseEvent(........);
2、然后在Bus中,将我们的事件模型进行包装成固定的格式 _mediator.Publish(@event);
3、然后通过注入的方法,将包装后的事件模型与事件处理程序进行匹配,系统执行事件模型,就自动实例化事件处理程序 StudentEventHandler;
4、最后执行我们Handler 中各自的处理方法 Task Handle(StudentRegisteredEvent message)。
希望正好也温习下命令总线的执行过程。
5、依赖注入事件模型和处理程序
// Domain - Events // 将事件模型和事件处理程序匹配注入 services.AddScoped<INotificationHandler<StudentRegisteredEvent>, StudentEventHandler>(); services.AddScoped<INotificationHandler<StudentUpdatedEvent>, StudentEventHandler>(); services.AddScoped<INotificationHandler<StudentRemovedEvent>, StudentEventHandler>();
这个时候,我们DDD领域驱动设计核心篇的第一部分就是这样了,还剩下最后的,事件驱动的事件源和事件存储/回溯,我们下一讲再说。
接下来咱们说说领域通知,为什么要说领域通知呢,大家应该还记得我们之前将错误信息放到了内存中,无论是操作还是业务上都很严重的问题,肯定是不可取的。那我们应该采用什么办法呢,欸?!没错,你会发现,通过上边的事件驱动设计,发现领域通知我们也可以采用这个方法,首先是多个模型之间相互通讯,但又不相互引用;而且也在命令处理程序中,对信息进行分发,和发邮件很类似,那具体如何操作呢,请往下看。
四、事件分发的另一个用途 —— 领域通知
1、领域通知模型 DomainNotification
这个通知模型,就像是一个消息队列一样,在我们的内存中,通过通知处理程序进行发布和使用,有自己的生命周期,当被访问并调用完成的时候,会手动对其进行回收,以保证数据的完整性和一致性,这个就很好的解决了咱们之前用Memory缓存通知信息的弊端。
在我们的核心领域层 Christ3D.Domain.Core 中,新建文件夹 Notifications ,然后添加领域通知模型 DomainNotification.cs:
namespace Christ3D.Domain.Core.Notifications { /// <summary> /// 领域通知模型,用来获取当前总线中出现的通知信息 /// 继承自领域事件和 INotification(也就意味着可以拥有中介的发布/订阅模式) /// </summary> public class DomainNotification : Event { // 标识 public Guid DomainNotificationId { get; private set; } // 键(可以根据这个key,获取当前key下的全部通知信息) // 这个我们在事件源和事件回溯的时候会用到,伏笔 public string Key { get; private set; } // 值(与key对应) public string Value { get; private set; } // 版本信息 public int Version { get; private set; } public DomainNotification(string key, string value) { DomainNotificationId = Guid.NewGuid(); Version = 1; Key = key; Value = value; } } }
2、领域通知处理程序 DomainNotificationHandler
该处理程序,可以理解成,就像一个类的管理工具,在每次对象生命周期内 ,对领域通知进行实例化,获取值,手动回收,这样保证了每次访问的都是当前实例的数据。
还是在文件夹 Notifications 下,新建处理程序 DomainNotificationHandler.cs:
namespace Christ3D.Domain.Core.Notifications { /// <summary> /// 领域通知处理程序,把所有的通知信息放到事件总线中 /// 继承 INotificationHandler<T> /// </summary> public class DomainNotificationHandler : INotificationHandler<DomainNotification> { // 通知信息列表 private List<DomainNotification> _notifications; // 每次访问该处理程序的时候,实例化一个空集合 public DomainNotificationHandler() { _notifications = new List<DomainNotification>(); } // 处理方法,把全部的通知信息,添加到内存里 public Task Handle(DomainNotification message, CancellationToken cancellationToken) { _notifications.Add(message); return Task.CompletedTask; } // 获取当前生命周期内的全部通知信息 public virtual List<DomainNotification> GetNotifications() { return _notifications; } // 判断在当前总线对象周期中,是否存在通知信息 public virtual bool HasNotifications() { return GetNotifications().Any(); } // 手动回收(清空通知) public void Dispose() { _notifications = new List<DomainNotification>(); } } }
到了目前为止,我们的DDD领域驱动设计中的核心领域层部分,已经基本完成了(还剩下下一篇的事件源、事件回溯):
3、在命令处理程序中发布通知
我们定义好了领域通知的处理程序,我们就可以像上边的发布事件一样,来发布我们的通知信息了。这里用一个栗子来试试:
在学习命令处理程序 StudentCommandHandler.cs 中的 RegisterStudentCommand 处理方法中,完善:
// 判断邮箱是否存在 // 这些业务逻辑,当然要在领域层中(领域命令处理程序中)进行处理 if (_studentRepository.GetByEmail(customer.Email) != null) { ////这里对错误信息进行发布,目前采用缓存形式 //List<string> errorInfo = new List<string>() { "该邮箱已经被使用!" }; //Cache.Set("ErrorData", errorInfo); //引发错误事件 Bus.RaiseEvent(new DomainNotification("", "该邮箱已经被使用!")); return Task.FromResult(new Unit()); }
这个时候,我们把错误通知信息在事件总线中发布出去,剩下的就是需要在别的任何地方订阅即可,还记得哪里么,没错就是我们的自定义视图组件中,我们需要订阅通知信息,展示在页面里。
注意:我们还要修改一下之前我们的命令处理程序基类 CommandHandler.cs 的验证信息收集方法,因为之前是用缓存来实现的,我们这里也用发布事件来实现:
//将领域命令中的验证错误信息收集 //目前用的是缓存方法(以后通过领域通知替换) protected void NotifyValidationErrors(Command message) { List<string> errorInfo = new List<string>(); foreach (var error in message.ValidationResult.Errors) { //errorInfo.Add(error.ErrorMessage); //将错误信息提交到事件总线,派发出去 _bus.RaiseEvent(new DomainNotification("", error.ErrorMessage)); } //将错误信息收集一:缓存方法(错误示范) //_cache.Set("ErrorData", errorInfo); }
4、在视图组件中获取通知信息
这个很简单,之前我们用的是注入 IMemory 的方式,在缓存中获取,现在我们通过注入领域通知处理程序来实现,在视图组件 AlertsViewComponent.cs 中:
public class AlertsViewComponent : ViewComponent { // 缓存注入,为了收录信息(错误方法,以后会用通知,通过领域事件来替换) // private IMemoryCache _cache; // 领域通知处理程序 private readonly DomainNotificationHandler _notifications; // 构造函数注入 public AlertsViewComponent(INotificationHandler<DomainNotification> notifications) { _notifications = (DomainNotificationHandler)notifications; } /// <summary> /// Alerts 视图组件 /// 可以异步,也可以同步,注意方法名称,同步的时候是Invoke /// 我写异步是为了为以后做准备 /// </summary> /// <returns></returns> public async Task<IViewComponentResult> InvokeAsync() { // 从通知处理程序中,获取全部通知信息,并返回给前台 var notificacoes = await Task.FromResult((_notifications.GetNotifications())); notificacoes.ForEach(c => ViewData.ModelState.AddModelError(string.Empty, c.Value)); return View(); } }
5、StudentController 判断是否有通知信息
通过注入的方式,把 INotificationHandler<DomainNotification> 注入控制器,然后因为这个接口可以实例化多个对象,那我们就强类型转换成 DomainNotificationHandler:
这里要说明下,记得要对事件处理程序注入,才能使用:
// 将事件模型和事件处理程序匹配注入 services.AddScoped<INotificationHandler<DomainNotification>, DomainNotificationHandler>();
五、结语
好啦,今天的讲解基本就到这里了,今天重点说明了,我们如何使用事件总线,已经事件驱动模型下如何定义事件模型和事件处理程序,如果你都看懂了呢,这里可以简单回想一下以下几个问题:
1、为什么要定义事件驱动呢?(提示词:业务分离)
2、我们是在哪里发布这些事件的呢?(提示词:.publish()方法)
3、事件驱动中的生命周期是从哪里开始到哪里接受的?(提示:处理程序Handler)
如果你对以上的内容还是比较困惑呢,这里有两个文章可以参考,当然,多沟通才是关键!
https://www.cnblogs.com/lori/p/4080426.html
https://blog.csdn.net/sD7O95O/article/details/79609305
六、GitHub & Gitee
https://github.com/anjoy8/ChristDDD
https://gitee.com/laozhangIsPhi/ChristDDD
--END