引言
领域驱动设计(Domain Driven Design),使用统一的建模语言、专注业务领域分析、采取化整为零并反复迭代的方式,以业务领域模型为圆心,向外辐射到系统轮廓的勾勒、具体模块的实现,为我们展现了一种表达更为自然、沟通更为顺畅的面向对象软件分析与设计方法。
在应用DDD的实践中,它与测试驱动开发(Test Driven Development)、行为驱动开发(Behaviour Driven Development)、敏捷软件开发(Agile Software Development)等一些软件方法学自然契合,与面向服务的架构(Service Oriented Architecture)、REST(Representational State Transfer)、六边形架构(Hexagonal Architecture)等一些架构紧密联系,形成了一个较为完整的知识生态圈。除了DDD Community以外,在Google Groups、GitHub、StackOverflow等一些网站上,围绕DDD的讨论、以DDD实现的开源项目日趋活跃,成为时下分析和架构领域的热点之一。
尽管接触DDD已有些时日,却难免因上了年纪而生安逸之心。在学习ES+CQRS的道路上,便是如此。终于,在NetFocus(汤雪华)、文野、Ivan等人的点拨之下,我直到最近才对其有了更为全面的认识和更为深入的理解。不敢独享这份领悟,遂以MS提供的《CQRS Journey》为蓝本撰下此文。唯求抛砖引玉,兼避烂尾之嫌。
注:本文既不是DDD的科普读物,也不是ES+CQRS的全面指南,只是我个人学习ES+CQRS的心得体会。有关DDD/ES/CQRS更为全面完整的内容,请参考相关书籍和文献。本文中的代码只具有伪码意义,无法编译使用。
Event Sourcing基本原理
ES与传统DDD最直观的区别,是在聚合Aggregate的持久化形式上。
图:传统持久化方式与ES的区别
传统的DDD中,每个聚合实例无论变化了多少次,都将被持久化为数据库表中的一行,保存的是聚合实例当前的完整状态。聚合实例与其持久化形态之间是1:1的关系。而在Event Sourcing中,每个聚合实例在其生存期内,经历的从创建到消亡的每一次状态变化,都将被持久化为数据库表中的一行。这样的每一行,表达的是聚合实例的每一次细微变化。若干次这样的变化按其发生顺序演化后,才能表达聚合实例的当前状态。在ES中,聚合实例与其持久化形态之间是1:M的关系。在ES里,这样的每一次变化即被称为事件Event,其持久化实现即为Event Store。
注:Event Sourcing是一种模式的概念。而本文以实现细节为主,所以除非特别说明,以下的缩写ES均指更为具体的Event Store。
有了以上认识,我们便可以进一步讨论如何从数据库中读取一个聚合的实例。这样的过程,被称为回放Replay。做事件回放时,需要要明确三个前提:
-
Event是由聚合产生的,是聚合告诉外部“我发生改变了”。当然事件在少数情况下,也可由其他主体产生。
-
事件Event是“过去时”的,代表着发生在聚合上的已经变化的结果。比如OrderConfirmed、PaymentReceived。
-
事件Event总是顺序发生的,在回放时也要遵循事件原本发生的顺序。为了保证这个顺序,我们最容易想到的就是引入时间戳,通过时间戳的比较保证回放顺序的正确性。但我们也可以给聚合引入另一个更直观的概念—版本Version,新建的聚合版本为0,之后每经历一个事件、每发生一次改变就递增一个版本。版本号除了比时间戳比较更加高效外,它也能更直接地应用于并发冲突的控制。
基于此,我们得到了Event应具备的基本元素,更加具体的领域事件则自该类派生,并添附事件相关的其他信息。
class Event {
//产生事件的聚合ID
Guid SourceId;
//事件对应的聚合版本
int Version;
}
自然地,我们可以想到聚合也可以有一个对应于事件的版本号,并据此得到这样的一个重塑聚合的途径:
public Order(Guid id, IEnumerable<Event> history) : this(id) {
this.Replay(history);
}
public void Replay(IEnumerable<Event> history) {
foreach(var event in history) {
this.Handle(event);
this.Version = event.Version;
}
}
还需要一个在上述foreach中调用的方法Handle,以改变Order的属性。
private void Handle(OrderConfirmed event) {
this.State = OrderStates.Confirmed;
}
回过头来,聚合的本职在于实现业务操作,并更新自身的状态。于是,我们想到这样的一个方法:
public void Confirm() {
if (this.State == OrderStates.Placed)
this.State = OrderStates.Confirmed;
}
但是这个方法让我们闻到了一丝的臭味道:它不仅与上面的Handle方法重复,而且没办法让外部知道聚合发生了变化。导致Event Store想保存变化,却只能是“巧妇难为无米之炊”。所以,我们需要由事件建立彼此的联系。 于是,我们又想到象下面这样去做,并利用了用于向ES提交的事件列表Changes和保证事件顺序的Version:
public void Confirm() {
if (this.State == OrderStates.Placed)
this.Raise(new OrderConfirmed());
}
public IList<Event> Changes = new List<Event>();
private void Raise(Event event) {
this.Handle(event);
this.Version++;
event.SourceId = this.ID;
event.Version = this.Version;
this.Changes.Add(event);
}
到这里,我们已经基本实现了一个基于Event Sourcing的聚合内部设计 。从中可以看出,我们采取了一种“业务方法 – Raise – Handle”的方式。这一方面是因为当业务方法被调用后,聚合实例将在业务方法内进行业务逻辑判断,并据此触发一个领域事件。该事件经转交聚合自带的事件处理方法Handle处理,实际地改变聚合本身的状态后,还需要同时存入待提交的事件列表,稍后再提交给ES完成持久化。另一方面,这样的方式也是由回放方法决定的。如果没有这个Handle方法,我们将无法避免代码重复和方法重入。
回过头,我们可以发现在重塑聚合实例时,聚合只是依次调用事件对应的处理方法Handle,而不再做业务逻辑判断。这可能会让人产生疑惑,这样能保证聚合状态的正确性吗?其实这就好比录音和放音——你录音录成什么样,无论倒带多少次,放出来的还会是同样的声音。所以只要能保证事件的顺序性,那么回放过程就不需要考虑业务逻辑。
知道事件在聚合内部的产生和处理后,我们开始向聚合外延伸。一方面要考虑是谁来调用聚合的业务方法,另一方面要考虑聚合产生的事件如何被存入ES。在这一点上,ES和传统DDD并没有太大区别。通常情况下皆由UI层发起请求,调用应用服务Application Service,应用服务转而请求仓储Repository提供需要的聚合实例,再调用聚合实例的业务方法完成操作,最后提交给持久层保存聚合的变化。这个过程可以表述为“Locate – Call – Save”,就象下面这样:
class UI {
private void Click_Confirm(object sender, EventArgs arg) {
OrderService.Confirm(arg.OrderId);
}
}
class OrderService {
private OrderRepository _repository;
public void Confirm(Guid orderId) {
var order = _repository.GetById(orderId);
order.Confirm();
_repository.Save(Order);
}
}
class Repository {
public Order GetById(Guid id) {
using (var context = new Context()) {
var history = context.Set<OrderEvents>
.Where(w=>w.ID.Equals(id))
.OrderBy(o=>o.Version);
return new Order(id, history);
}
}
public void Save(Order order) {
using(var context = new Context()) {
context.Set<OrderEvents>.AddRange(order.Changes);
context.SaveChanges();
}
}
}
至此,Event Sourcing就基本实现了。
图:ES调用序列
从中我们可以发现以下一些优点:
-
持久化的事件读取是顺次的、写入是追加式的,相比传统模式下更新数据行的方式,持久化操作的实现更加简单、性能提升明显。
-
ES可以完整反映聚合变化的整个轨迹。通过对变化轨迹的研究和跟踪审计,可以挖掘出更深层次的业务价值。
-
事件的结构简单(DTO式的),适合于不同限定上下文(Bounded Context)之间的交互。
-
通过事件回溯和修正,可以很方便地定位系统错误发生的位置、测试系统的行为。
而其缺点也很明显:
-
聚合实例的重塑要提取所有历史事件并顺次进行处理,对查询性能影响明显。
-
随着版本升级,聚合及其事件都有可能发生改变,Event Sourcing需要妥善解决新旧事件共存的问题。
Aggregate In Memory
聚合常驻内存,将可以避免每一次都从ES重塑聚合,从而保证系统性能。实现Aggregate In Memory,在传统DDD里已不鲜见。其基本思路类似Cache,将Repository看作一个聚合的集合Collection,由该Collection保持对已加载聚合实例的引用。当Collection中缺少某个实例时,再从ES中提取事件重塑该聚合。
图:Repository与Event Store
码:Repository
Snapshot
引入快照Snapshot,是提升Event Sourcing性能的另一个主要方法。基本方法是根据聚合改写的频率确定一个版本更替的阈值,然后在每次到达阈值时生成一个聚合在该版本的临时快照,使聚合的重塑从加载所有事件改为加载最新快照+快照后续事件,从而减少事件加载的数量,实现性能提升。由于快照和Event Store互不干涉,因此Snapshot的生成的频率和时机完全可以另行决定,而不影响Event Store的正常工作。
码:Snapshot
CQRS与Eventual Consistency
CQRS(Command-Query Responsibility Segregation),命令与查询职责分离,这是一种将系统分割为读模型Read Model和写模型Write Model两部分,再分别由读模型响应查询请求、由写模型响应修改请求,并利用事件在二者间进行协调的一种应用模式。其中,命令Command是指仅改变聚合的状态而不返回任何数据的操作;查询Query则是指仅返回聚合的特定状态而不改变对象状态的操作。
图:CQRS典型结构
从图中可以看出,R/W两端各有各的存储、各有各的模型,两者经由事件实现数据的一致性。从UI等请求者的角度看,读模型向请求者反馈DTO,写模型则接受请求者发出的Command。这些Command最终传递到聚合实例,由聚合实例完成改变自身状态的操作,再经由事件将发生的变化反馈给读模型,读模型根据事件对用以呈现的数据进行重组和规范,最终再反馈给UI等,从而实现命令与查询的分离。
CQRS模式的读写分离将为我们带来了以下便利:
-
两个模型可以根据不同的关注点,分别进行优化。
-
写模型富含业务逻辑,其一致性要求远甚于读模型,二者分离后维护将更容易定位错误、解决性能瓶颈。
-
读写模型可以采用不同的方式存储自己关心的数据,比如用传统关系数据库实现Event Store,用NoSQL组织用于读模型呈现的数据。
在这个过程中,正因事件成为了读写模型联系的纽带,整个系统亦将以事件为核心,所以CQRS与Event Sourcing的搭配显得非常自然,这也是ES+CQRS经常被相提并论的原因。另一方面,由于读模型的数据总是由写模型来推送或更新的,所以也可以认为读模型持有的数据都是“过时的(Stale)”,于是又引入了数据最终一致性(Eventual Consistency)的问题。由于习惯了利用数据库事务等实现强一致性的传统方法,所以最终一致性成为了理解ES+CQRS模式最难拐的那个弯。
再来看看ES+CQRS的典型结构,如下图:
图:ES+CQRS典型结构
首先,是采取类似DTO的方式,实现Command的扁平化。在减弱命令调用两端耦合的同时,也使Command与Event一样,成为在系统内部流动的Message。这样做,利于实现命令的异步执行等功能,有效提升系统性能,也使消息队列以及更多的分布式设计得以有用武之地。尽管分布式并非必然,然而即便只是引入Command Queue,实现生产者-消费者同步模型也成为必须。
其次,是将Command实际交付执行。在这里,我们引入了Command Service和Command Handler,由Command Service从Command Queue取出Command,根据Command对应具体类型发给预告注册好的Command Handler,由Command Handler解释传来的Command,再采取Locate – Call – Save的方式,找到对应的聚合实例完成命令所需的操作。在此过程中,由Command Queue等基础设施负责解决Command的冥等性问题,即保证同一个Command只能被执行一次。Command ID将可以作为此处唯一性判断的重要依据。
接着,当聚合完成操作,需要将产生的事件进行持久化并推送给读模型时,我们还要面对推送的数据一致性问题。将聚合状态变化时发生的事件存入Event Store,同时将变化推送给读模型端,这是典型的“两步操作”。其中任何一步的失败,都将导致读写两端的数据不一致。
读写一致性的保证
事务Transaction是我们解决读写数据一致性问题最常见的工具。但在读写分离的条件下,这样的事务将可能因为模型部署原因而演化为分布式事务,其代价将会是巨大的。
下图是由写模型利用一个事务,完成更新ES和直接向读模型推送变化两步操作。这种方式下,事务跨越读写两端,其性能和可用性是比较差的。
图:跨边界事务
进一步的,我们引入Message Queue后,改由写模型利用一个事务完成更新ES和向消息队列推送消息。这种方式下,由于事务的两步操作都改在写端实现,因此相比前一种方法有了明显的进步。
图:写端两步事务
更进一步的,当我们改由ES本身实现将消息压入消息队列后,写模型将只需要一个事务完成ES更新即可。这种方式下,事务的边界进一步缩小,写模型原本要负担的“两步操作”被简化为“一步操作”,性能得到更大幅的提升。但是ES的推送能力,将成为对ES设计者的考验之一。
图:写端单步事务
以上的讨论再次印证了,在CQRS模式下的数据采取最终一致性方式是必然的。
聚合的并发控制
由于Command Queue和Command Handler的介入,每一种类型的Command都将与特定的Command Handler绑定。当我们再将Command Queue根据聚合实例的ID进行分组时,由于一个Command只能有一个接收者,所以单一的聚合实例+Command Queue,可以实现任一时刻只有一条命令修改一个聚合,从而保证聚合修改的线程安全(汤雪华的ENode便是基于这一原则实现的)。
而在特定情形下,比如为提高吞吐量而将多个同一ID的聚合实例分布于不同结点时,或者因结点切换导致发生同一聚合实例被同时修改时,可能发生并发冲突。此时聚合的版本号,将成为并发控制的有力武器之一。主要策略不外乎乐观或者悲观两种方式:
-
乐观策略:仅当聚合当前版本与ES中的最新版本一致,才证明聚合是最新的,可以提交对聚合的修改,否则进行重试。
-
悲观策略:每一次都从ES重塑整个聚合,并利用同步锁等机制,保证排他性地修改聚合状态。
图:乐观与悲观
选择何种策略,取决于对性能等代价的考量。即便说乐观的并发控制是最常见的选择,但当重试的代价超过锁定时,不妨选择悲观策略。
有了以上内容作为铺垫,我们开始着手改造并丰富之前的那个Event Sourcing实现。
码:ES+CQRS代码示例
Saga/Process Manager实现
Saga,是指利用Event和Command协调不同的BC和聚合,以共同完成一项需要长时间、多对象协作参与的处理过程。在《CQRS Journey》一书中,使用了Process Manager这个概念以取代之。Saga的实现,简言之,可以理解为一台由Event驱动、以分发Command为己任的状态机。既然是状态机,那么在Saga的内部则只有根据传入的Event而切换其当前状态,并根据业务流程发出相应Command的简单逻辑。
Saga的实现以Event传入为驱动,所以在外部它需要配合一个专司从Event Queue取出Event并派送给相应Process Manager的Event Router,内部则需要若干个Event Handler对Event进行处理,以完成Process Manager状态切换,生成相应的Command,进而将Command推送入Command Queue。如下图:
图:Process Manager
Process Manager的Event Handler与聚合内嵌的Event Handler的区别,在于前者将发出Command,而后者只是改变聚合本身的属性。根据状态机是否持有自身的当前状态以备恢复执行,可以将Saga的实现分为有无状态两种方式。在CQRS Journey的实现中,开发团队选择了有状态的实现,而Enode则选择了无状态的实现。在有状态方式下,Process Manager的Event Handler类似Command Handler的工作方式,都是从Repository中取出Process Manager的实例,然后将Event传给该PM实例,由PM的Event Handler处理完成后,又交由Repository完成PM的状态持久化。如下所示:
码:有状态的PM
而在无状态方式下,Process Manager并不持有本身的状态,而改借解释聚合的当前状态来表达PM的状态。每一次的Event处理,都将根据聚合状态来判断下一步的操作。如下所示:
码:无状态的PM