一个领域事件可以理解为是发生在一个特定领域中的事件, 是你希望在同一个领域中其他部分知道并产生后续动作的事件. 一个领域事件必须对业务有价值, 有助于形成完整的业务闭环, 也即一个领域事件将导致进一步的业务操作. 就比如我们今天说到的领域通知, 就应该是一个事件, 我们从命令中产生的错误提示, 通过处理程序, 引发到事件总线内, 并返回到前台.
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, 实现总线分发接口
在基层设施总线层 Christ3D.Infra.Bus 的记忆总线 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
这个和我们的命令处理程序一样, 只不过我们的命令处理程序是总线在应用服务层分发的, 而事件处理程序是在领域层的命令处理程序中被总线引发的, 可能有点儿拗口, 看看下边代码就清楚了, 就是一个引用场景的顺序问题.
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 项目中, 进行注入. 关于触发过程下边我简单说一下.
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()); }
这个时候, 我们把错误通知信息在事件总线中发布出去, 剩下的就是需要在别的任何地方订阅即可, 还记得哪里么, 没错就是我们的自定义视图组件中, 我们需要订阅通知信息, 展示在页面里.
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
六, GitHub & Gitee
https://github.com/anjoy8/ChristDDD https://gitee.com/laozhangIsPhi/ChristDDD --END
来源: https://www.cnblogs.com/laozhang-is-phi/p/10059878.html