系列教程一目录:.netcore+vue 前后端分离
系列教程二目录:DDD领域驱动设计
本文梯子
缘起
前言
零、今天要实现右下角蓝色的部分
一、领域事件驱动设计 —— EDA
1、什么是领域事件
2、领域事件包含了哪些内容
3、为什么需要领域事件
4、领域事件驱动是如何运行的呢?
二、创建事件总线
1、定义领域事件标识基类
2、定义事件总线接口
3、实现总线分发接口
三、事件模型的处理与使用
1、定义添加Student 的事件模型
2、定义领域事件的处理程序Handler
3、在事件总线EventBus中引发事件
4、整体事件驱动执行过程
5、依赖注入事件模型和处理程序
四、事件分发的另一个用途 —— 领域通知
1、领域通知模型 DomainNotification
2、领域通知处理程序 DomainNotification...
3、在命令处理程序中发布通知
4、在视图组件中获取通知信息
5、StudentController 判断是否有通知信息
五、结语
六、GitHub & Gitee
正文
缘起
哈喽大家好,又是周二了,时间很快,我的第二个系列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,应该需要FQ)
一、领域事件驱动设计 —— 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
{
///
/// 事件模型 抽象基类,继承 INotification
/// 也就是说,拥有中介者模式中的 发布/订阅模式
///
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
{
///
/// 中介处理程序接口
/// 可以定义多个处理程序
/// 是异步的
///
public interface IMediatorHandler
{
///
/// 发送命令,将我们的命令模型发布到中介者模块
///
///
泛型
///
命令模型,比如RegisterStudentCommand
///
Task SendCommand
(T command) where T : Command;
///
/// 引发事件,通过总线,发布事件
///
/// 泛型 继承 Event:INotification
/// 事件模型,比如StudentRegisteredEvent,
/// 请注意一个细节:这个命名方法和Command不一样,一个是RegisterStudentCommand注册学生命令之前,一个是StudentRegisteredEvent学生被注册事件之后
///
Task RaiseEvent(T @event) where T : Event;
}
}
复制代码
3、实现总线分发接口
在基层设施总线层 Christ3D.Infra.Bus 的记忆总线 InMemoryBus.cs 中,实现我们上边的事件分发总线接口:
复制代码
///
/// 引发事件的实现方法
///
/// 泛型 继承 Event:INotification
/// 事件模型,比如StudentRegisteredEvent
///
public Task RaiseEvent(T @event) where T : Event
{
// MediatR中介者模式中的第二种方法,发布/订阅模式
return _mediator.Publish(@event);
}
复制代码
注意这里使用的是中介模式的第二种——发布/订阅模式,想必这个时候就不用给大家解释为什么要使用这个模式了吧(提示:不需要对请求进行必要的响应,与请求/响应模式做对比思考)。现在我们把事件总线定义(是一个发布者)好了,下一步就是如何定义事件模型和处理程序了也就是订阅者,如果上边的都看懂了,请继续往下走。
三、事件模型的处理与使用
可能这句话不是很好理解,那说人话就是:我们之前每一个领域模型都会有不同的命令,那每一个命令执行完成,都会有对应的后续事件(比如注册和删除用户肯定是不一样的),当然这个是看具体的业务而定,就比如我们的订单领域模型,主要的有下单、取消订单、删除订单等。
我个人感觉,每一个命令模型都会有对应的事件模型,而且一个命令处理方法可能有多个事件方法。具体的请看:
1、定义添加Student 的事件模型
当然还会有删除和更新的事件模型,这里就用添加作为栗子,在领域层 Christ3D.Domain 中,新建 Events 文件夹,用来存放我们所有的事件模型,
因为是 Student 模型,所以我们在 Events 文件夹下,新建 Student 文件夹,并新建 StudentRegisteredEvent.cs 学生添加事件类:
复制代码
namespace Christ3D.Domain.Events
{
///
/// Student被添加后引发事件
/// 继承事件基类标识
///
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
{
///
/// Student事件处理程序
/// 继承INotificationHandler,可以同时处理多个不同的事件模型
///
public class StudentEventHandler :
INotificationHandler,
INotificationHandler,
INotificationHandler
{
// 学习被注册成功后的事件处理方法
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 项目中,进行注入。关于触发过程下边我简单说一下。
4、整体事件驱动执行过程
说到了这里,你可能发现和命令总线很相似,也可能不是很懂,简单来说,整体流程是这样的:
1、首先我们在命令处理程序中调用事件总线来引发事件 Bus.RaiseEvent(........);
2、然后在Bus中,将我们的事件模型进行包装成固定的格式 _mediator.Publish(@event);
3、然后通过注入的方法,将包装后的事件模型与事件处理程序进行匹配,系统执行事件模型,就自动实例化事件处理程序 StudentEventHandler;
4、最后执行我们Handler 中各自的处理方法 Task Handle(StudentRegisteredEvent message)。
希望正好也温习下命令总线的执行过程。
5、依赖注入事件模型和处理程序
复制代码
// Domain - Events
// 将事件模型和事件处理程序匹配注入
services.AddScoped, StudentEventHandler>();
services.AddScoped, StudentEventHandler>();
services.AddScoped, StudentEventHandler>();
复制代码
这个时候,我们DDD领域驱动设计核心篇的第一部分就是这样了,还剩下最后的,事件驱动的事件源和事件存储/回溯,我们下一讲再说。
接下来咱们说说领域通知,为什么要说领域通知呢,大家应该还记得我们之前将错误信息放到了内存中,无论是操作还是业务上都很严重的问题,肯定是不可取的。那我们应该采用什么办法呢,欸?!没错,你会发现,通过上边的事件驱动设计,发现领域通知我们也可以采用这个方法,首先是多个模型之间相互通讯,但又不相互引用;而且也在命令处理程序中,对信息进行分发,和发邮件很类似,那具体如何操作呢,请往下看。
四、事件分发的另一个用途 —— 领域通知
1、领域通知模型 DomainNotification
这个通知模型,就像是一个消息队列一样,在我们的内存中,通过通知处理程序进行发布和使用,有自己的生命周期,当被访问并调用完成的时候,会手动对其进行回收,以保证数据的完整性和一致性,这个就很好的解决了咱们之前用Memory缓存通知信息的弊端。
在我们的核心领域层 Christ3D.Domain.Core 中,新建文件夹 Notifications ,然后添加领域通知模型 DomainNotification.cs:
复制代码
namespace Christ3D.Domain.Core.Notifications
{
///
/// 领域通知模型,用来获取当前总线中出现的通知信息
/// 继承自领域事件和 INotification(也就意味着可以拥有中介的发布/订阅模式)
///
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
{
///
/// 领域通知处理程序,把所有的通知信息放到事件总线中
/// 继承 INotificationHandler
///
public class DomainNotificationHandler : INotificationHandler
{
// 通知信息列表
private List _notifications;
// 每次访问该处理程序的时候,实例化一个空集合
public DomainNotificationHandler()
{
_notifications = new List();
}
// 处理方法,把全部的通知信息,添加到内存里
public Task Handle(DomainNotification message, CancellationToken cancellationToken)
{
_notifications.Add(message);
return Task.CompletedTask;
}
// 获取当前生命周期内的全部通知信息
public virtual List GetNotifications()
{
return _notifications;
}
// 判断在当前总线对象周期中,是否存在通知信息
public virtual bool HasNotifications()
{
return GetNotifications().Any();
}
// 手动回收(清空通知)
public void Dispose()
{
_notifications = new List();
}
}
}
复制代码
到了目前为止,我们的DDD领域驱动设计中的核心领域层部分,已经基本完成了(还剩下下一篇的事件源、事件回溯):
3、在命令处理程序中发布通知
我们定义好了领域通知的处理程序,我们就可以像上边的发布事件一样,来发布我们的通知信息了。这里用一个栗子来试试:
在学习命令处理程序 StudentCommandHandler.cs 中的 RegisterStudentCommand 处理方法中,完善:
复制代码
// 判断邮箱是否存在
// 这些业务逻辑,当然要在领域层中(领域命令处理程序中)进行处理
if (_studentRepository.GetByEmail(customer.Email) != null)
{
////这里对错误信息进行发布,目前采用缓存形式
//List errorInfo = new List() { "该邮箱已经被使用!" };
//Cache.Set("ErrorData", errorInfo);
//引发错误事件
Bus.RaiseEvent(new DomainNotification("", "该邮箱已经被使用!"));
return Task.FromResult(new Unit());
}
复制代码
这个时候,我们把错误通知信息在事件总线中发布出去,剩下的就是需要在别的任何地方订阅即可,还记得哪里么,没错就是我们的自定义视图组件中,我们需要订阅通知信息,展示在页面里。
注意:我们还要修改一下之前我们的命令处理程序基类 CommandHandler.cs 的验证信息收集方法,因为之前是用缓存来实现的,我们这里也用发布事件来实现:
复制代码
//将领域命令中的验证错误信息收集
//目前用的是缓存方法(以后通