超越 CRUD: 命令、事件和总线
转帖:
软件编写有时候难以在预算内按时完成的其中一个原因是,缺少对领域专家所说的对商业语言的关注。大多数时候,确认需求意味着将理解的需求映射到某种关系数据模型。然后,构建业务逻辑以在持久性层和表示层之间隧道传输数据,并在此过程中进行必要的调整。虽然并不完美,但该模式仍然使用了很长时间,而日渐增加的复杂性使得这种CRUD增删改查模式已经无法适应需求变化,无论如何,将其引入 DDD 的公式仍然是当今处理任何软件项目最有效的方法。来自微软Msdn网站一文:领先技术 - 超越 CRUD: 命令、事件和总线讨论了如何使用命令事件和总线替代传统的CRUD系统。转贴并调整如下:事件在上述情况体现了用途,因为它们强制进行不同形式的领域分析,更以任务为导向,而且也没有必须赶快找出保存数据所用的完美关系数据模型的紧迫性。比如旅馆房间预订案例中,无论房间何时被预订,系统都会记录涉及给定预订 ID 的预订创建的事件。若要检索聚合(也就是预订)的所有事件,只需查询指定预订 ID 的事件数据存储,就足以获得所有信息。这确实有效,但它是一个很简单的方案。聚合和对象事件/聚合关联是以业务域的通用语言编写的。不管怎样,与更简单的一对一关联相比,一对多关联更可能发生。具体来说,事件和聚合之间的一对多关联意味着事件有时候与多聚合相关,且可能不止一个聚合关注处理该事件,并可能由于该事件更改其状态。例如,设想一个方案:在系统中将发票注册为正在进行的工作订单的成本。这意味着,在你的域模型中可能有两个聚合 - 发票和工作订单。注册的事件发票会因为新的发票进入到系统中而捕获发票聚合的关注,但如果发票涉及与订单相关的一些活动,它可能还会捕获 JobOrder 聚合的关注。很明显,只有在完全理解业务域后,才能确定发票是否与工作订单相关。在这里可能有发票独立存在的域模型(和应用程序)和发票可能在工作订单的记帐中注册并随后更改当前余额的域模型(和应用程序)。但是,知道事件可能与很多聚合相关这一点完全改变了解决方案的体系结构和可行技术的前景。调度事件分解复杂性事件被绑定到单个聚合的重大约束是 CRUD 和 H-CRUD 的基础所在。当业务事件涉及多个聚合时,你编写业务逻辑代码以确保状态被适当地更改和跟踪。当聚合和事件的数量超过严重阈值时,业务逻辑代码的复杂性可能变得难以处理和演变。在此上下文中,CQRS 模式代表了在正确方向上迈出的第一步,因为它基本上建议你对系统当前状态的“仅读取”或“仅修改”操作进行单独推断。事件源是另一种流行的模式,它建议你将所有发生在系统中的操作记录为事件。跟踪系统的整个状态,并将系统中聚合的实际状态构建为事件的投影。换句话说,你将事件的内容映射到其他属性,它们全部一起组成了软件中可用的对象状态。事件源围绕知道如何保存和检索事件的框架构建。事件源机制为仅追加,支持事件流的重播,并知道如何保存可能具有截然不同布局的相关数据。诸如 EventStore (bit.ly/1UPxEUP) 和 NEventStore (bit.ly/1UdHcfz) 的事件存储框架抽象出真正的持久性框架,并提供高级 API 以在代码中直接使用事件进行处理。从本质上而言,你所看到的事件流具有一定相关性,对于这些事件进行关注的目的是聚合。这将会顺利运行。但是,当某个事件对多个聚合具有影响时,你应该找到一种方法,使每个聚合都能够跟踪其关注的所有事件。此外,你应当设法构建一个软件基础结构,该结构不仅关注事件持久性,还允许向所有运行中的聚合通知所关注的事件。要实现将事件正确调度到聚合和适当的事件持久性,H-CRUD 是不够的。必须再次讨论业务逻辑背后的模式和用于保存事件相关数据的技术。定义聚合聚合的概念来自 DDD,简单地说,是指组合到一起以匹配事务一致性的域对象群集。事务一致性仅意味着,保证在聚合内组成的任何事务在业务操作结尾均保持一致且处于最新状态。下面的代码片段演示了总结任何聚合类的主要方面的界面。可能还有更多,但我敢说这绝对是最少的值:public interface IAggregate{Guid ID { get; }bool HasPendingChanges { get; }IList<DomainEvent> OccurredEvents { get; set; }IEnumerable<DomainEvent> GetUncommittedEvents();}
无论何时,聚合均包含所发生事件的列表,并可区分已提交的事件和未提交的事件(导致挂起更改)。实现 IAggregate 界面的基类需要非公共成员设置 ID 并实施已提交和未提交事件的列表。此外,聚合基类也有一些 RaiseEvent 方法,用于向未提交事件的内部列表添加事件。有趣的是,事件是如何供内部使用以更改聚合状态的呢?假设你有一个客户聚合,并想要更新客户的公共名称。在 CRUD 方案中,只需进行以下简单的分配:customer.DisplayName = "new value";
如果使用事件,将会是一个更复杂的路线:public void Handle(ChangeCustomerNameCommand command){var customer = _customerRepository.GetById(command.CompanyId);customer.ChangeName(command.DisplayName);customerRepository.Save(customer);}
此刻,让我们先跳过 Handle 方法和运行此方法的人员,而将注意力集中在实现上。起初,ChangeName 看起来似乎仅仅是先前检查的 CRUD 样式代码的包装器。其实并不完全是这样:public void ChangeName(string newDisplayName){var evt = new CustomerNameChangedEvent(this.Id, newDisplayName);RaiseEvent(e);}
在聚合基类上定义的 RaiseEvent 方法将在未提交事件的内部列表中追加事件。未提交的事件最终会在聚合保存时处理。通过事件保存状态随着对事件更深入的了解,可将存储库类的结构设为泛型。目前描述的设计用于使用聚合类运行的存储库的 Save 方法仅遍历聚合未提交事件的列表,并调用聚合必须提供的新方法 - ApplyEvent 方法:public void ApplyEvent(CustomerNameChangedEvent evt){this.DisplayName = evt.DisplayName;}
聚合类将拥有对每个所关注事件的 ApplyEvent 方法的一个重载。过去考虑的 CRUD 样式代码会在此找到它的位置。还有一个缺少的链接: 如何安排前端用例、具有多聚合的最终用户操作、业务工作流和持久性? 你需要一个总线组件。介绍总线组件总线组件可定义为运行在已知业务流程的实例间的共享路径。最终用户通过表示层执行操作,并为系统设置要处理的指令。应用程序层接收这些输入并将其转换为具体的业务操作。在 CRUD 方案中,应用程序层将直接调用对请求的操作负责的业务流程(即工作流)。当聚合和业务规则数量过多时,总线将大大简化整体设计。应用程序层将命令或事件推送至总线,以便侦听器正确地作出响应。侦听器是常被称为“sagas”的组件,它是已知业务流程的最终实例。Saga 知道如何对大量命令和事件做出响应。Saga 具有持久性层的访问权限,并可将命令和事件推送回总线。Saga 是上述 Handle 方法所属的类。通常,每个工作流或用案都有一个 saga 类,它可以通过其可处理的事件和命令完全识别。整体生成的体系结构如图 1 所示。
最后请注意,事件也必须保存并回到其源进行查询。而这引出了另一要点: 经典关系数据库是否是存储事件的理想之选? 不同事件可以在开发过程中及后期生产过程中随时添加。此外,每个事件都有其各自的架构。在此上下文中,非关系数据存储是适合的(尽管使用关系数据库仍然是一种可选方案 - 至少是依照充分证据考虑和排除的方案)。总结我敢说,对软件复杂性的大部分看法是来自以下事实:尽管基于缩写词(创建、读取、更新、删除)中的基本四项操作不再像读取和编写单个表或聚合那样简单,但我们仍继续考虑对系统使用 CRUD 方法。本文是对模式和工具更深入分析的预告,下个月我会继续对此讨论,到时我将演示试图使此类开发更快和可持续的框架。