CQRS实践(4): 领域事件
前几篇随笔中讨论了CQRS中的Command,本篇随笔中将讨论CQRS中的领域事件(Domain Event)。
概念
先回顾下CQRS中一个UI操作的执行过程:
首先,用户在UI中点击一个按钮,继而UI层构造了一个相应的Command对象并放到CommandBus中执行,在Command的执行过程中,领域模型中的类和方法得到调用,而领域事件,正是在此时产生的,之所以称之为“领域”事件,也正是因为它产生于领域模型。这可以用下面这张图来说明(先忽略UnitOfWorkContext):
从上图也可以看出,领域模型的调用被“包裹”在Command的执行上下文中,所以,UI层的所有操作都只是创建Command,再把Command丢给CommandBus,而不会直接调用领域模型中的类和方法。
基本实现
领域事件的实现和Command的实现多少有些类似,但需要注意的是,每个Command只能对应一个CommandExecutor,而一个领域事件却可以绑定多个事件处理器(EventHandler)。下面是一个初始版本的实现代码:
/// <summary> /// 标记接口(Marker Interface),所有领域事件都要实现该接口。 /// </summary> public interface IEvent { } /// <summary> /// 事件处理器接口,所有事件处理器都要实现该接口。 /// </summary> public interface IEventHandler<in TEvent> where TEvent : IEvent { /// <summary> /// 处理事件。 /// </summary> void Handle(TEvent evnt); } /// <summary> /// /// </summary> public static class EventBus { public static void Publish<TEvent>(TEvent evnt) where TEvent : IEvent { // 获取所有绑定到传入事件类型的事件处理器,遍历执行 } }
假设现在有个需求:一个网上书店新进了一本书,我们希望在将这本书添加到数据库中后,发送该新书的促销邮件给注册用户。
那我们在实现时就会有一个AddBookCommand,这个Command执行时,会触发BookAddedEvent,而系统中会有一个EventHandler绑定到这个事件,它会将新书的信息通过邮件发送给注册用户(可以有其它的Event Handler,比如用于更新网站的统计信息的Event Handler)。
AddBookCommand就不再赘述,详情可参考《Command的实现》一文,BookAddedEvent的代码如下所示,它实现了IEvent接口:
public class BookAddedEvent : IEvent { public string BookISBN { get; set; } public decimal Price { get; set; } }
对应的BookAddedEventHandler的代码如下:
public class BookAddedEventHandler : IEventHandler<BookAddedEvent> { public void Handle(BookAddedEvent evnt) { var msg = "新书到!ISBN: " + evnt.BookISBN + ", 价格: " + evnt.Price; // 这里发送邮件到各用户 } }
事件和命令不同,命令表达的是一个将要执行的操作,而事件表达的是一个发生过的事情,所以事件类的命名采用过去式。和命令一样的是,领域事件的类名有很清晰的语义,所以《Command的实现》一文中的“千万不要随意复用"的原则同样适用于领域事件。
改进实现
上面的实现忽略了一个很重要的问题:在BookAddedEventHandler中向用户发送了邮件,而领域事件是在领域模型的调用过程中产生的,这也就意味着在发送邮件的时候数据库事务还没有提交。如果数据库事务提交失败了呢?新书没有添加进来,但邮件却发送出去了,这是无法接受的。所以,对于发送邮件之类的Event Handler,我们要保证它们在事务提交成功后才被执行。
但我们又不能把所有的Event Handler放到事件提交成功后执行,比如添加一本新书后,我们要将网站统计信息中的图书总数加一(假设统计信息用一张表来存放,这个统计信息可以认为是CQRS中的ReadModel),这时候图书总数的增加则要和书的添加处于同一个数据库事务中,对于这种EventHandler又需要在领域事件触发时马上执行。
因此我们可以对上面的领域事件实现做一点改造:把EventHandler分为两种,一种是普通的直接执行的EventHandler,一种是数据库事务提交成功后才执行的PostCommitEventHandler。我们再引入UnitOfWorkContext,在Command开始执行时,我们创建一个新的UnitOfWorkContext对象,它在Command执行过程中会一直存在。在Command执行过程中,一旦有领域事件被触发,我们就马上执行所有绑定到该事件的普通EventHandler,再将该事件添加到当前的UnitOfWorkContext中,在UnitOfWork提交成功后,遍历所有当前UnitOfWorkContext中所有添加进来的领域事件,逐一执行相应的PostCommitEventHandler,Command执行结束后关闭当前的UnitOfWorkContext。相关代码如下(完整代码见文末中提到的Taro项目):
首先我们要把EventHandler接口分为两个:IEventHandler<TEvent>和IPostCommitEventHandler<TEvent>:
public interface IEventHandler<in TEvent> where TEvent : IEvent { /// <summary> /// 处理事件。 /// </summary> void Handle(TEvent evnt); } public interface IPostCommitEventHandler<in TEvent> where TEvent : IEvent { void Handle(TEvent evnt); }
然后是UnitOfWork(已去除了不重要的代码):
public abstract class AbstractUnitOfWork : IUnitOfWork { // IEventHandlerFinder用于获取所有绑定到事件的EventHandler private IEventHandlerFinder _eventHandlerFinder; public ICollection<IEvent> UncommittedEvents { get; private set; } public void Commit() { CommitChanges(); // 在提交数据库事务后,执行所有IPostCommitEventHandler InvokePostCommitHandlers(); } protected abstract void CommitChanges(); protected virtual void InvokePostCommitHandlers() { // 遍历领域事件,执行相应的IPostCommitEventHandler foreach (var evnt in UncommittedEvents) { foreach (var handler in _eventHandlerFinder.FindPostCommitHandlers(evnt)) { EventHandlerInvoker.Invoke(handler, evnt); } } UncommittedEvents.Clear(); } }
然后是UnitOfWorkContext,它很简单,采用ThreadStatic实现:
public static class UnitOfWorkContext { [ThreadStatic] private static IUnitOfWork _current; public static IUnitOfWork Current { get { return _current; } } public static void Open(IUnitOfWork unitOfWork) { _current = unitOfWork; } public static void Close() { _current = null; } }
我们还需要一个AbstractCommandExecutor抽象基类来控制UnitOfWorkContext的开启和关闭(所有CommandExecutor都要继承AbstractCommandExecutor):
public abstract class AbstractCommandExecutor<TCommand> : ICommandExecutor<TCommand> where TCommand : ICommand { protected Func<IUnitOfWork> GetUnitOfWork { get; private set; } protected AbstractCommandExecutor(Func<IUnitOfWork> getUnitOfWork) { Require.NotNull(getUnitOfWork, "getUnitOfWork"); GetUnitOfWork = getUnitOfWork; } public void Execute(TCommand cmd) { using (var uow = GetUnitOfWork()) { UnitOfWorkContext.Open(uow); try { Execute(uow, cmd); } finally { UnitOfWorkContext.Close(); } } } protected abstract void Execute(IUnitOfWork unitOfWork, TCommand cmd); }
为了让领域模型方便触发领域事件,我们添加一个DomainEvent的静态类,它的Apply方法中先执行普通的EventHandler,再将事件添加到当前的UnitOfWorkContext中:
public static class DomainEvent { public static void Apply<TEvent>(TEvent evnt) where TEvent : IEvent { var handlerFinder = EventHandlerFinders.Current; // 找到所有绑定到该事件的普通EventHandler并执行 foreach (var handler in handlerFinder.FindPreCommitHandlers(evnt)) { EventHandlerInvoker.Invoke(handler, evnt); } var unitOfWork = UnitOfWorkContext.Current; if (unitOfWork == null) throw new InvalidOperationException("Current unit of work context is null. Domain events can only be applied inside a unit of work context."); // 将领域事件添加到当前UnitOfWorkContext中 unitOfWork.UncommittedEvents.Add(evnt); } }
最后在领域模型中,我们可以如下调用:
// 图书仓库,伪代码 public class BookWarehouse { public IList<Book> Books { get; private set; } public void AddBook(Book book) { Books.Add(book); // 触发领域事件 DomainEvent.Apply(new BookAddedEvent(book)); } }
总结
本文讨论了领域事件的基本概念及其一个基本实现,在其之上又做了进一步的改进以解决部分EventHandler需要在数据库事务提交成功后才可以执行的问题。
领域事件的引入有着极其重要的意义,它使领域模型变得更加纯净,并将不同的逻辑进行了解耦,如果没有领域事件,那新书入库的代码和发送促销邮件以及更改网站统计信息的代码都要耦合在一起。领域事件并非CQRS的私有品,它可以脱离CQRS,作为轻量级的组件而独立存在。
到此为止,CQRS中的主要组成部分已讨论完毕,至于其它的进阶主题,如Event Sourcing、事件的异步分发等将不在本系列中讨论。在接下来的随笔中,我们将以一个迷你的CQRS框架(Taro)和一个可运行的网上书店(BookStore)示例项目来展示如何在实际项目中应用CQRS。
欢迎讨论。