ES与CQRS之旅

引言

领域驱动设计(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。做事件回放时,需要要明确三个前提:

  1. Event是由聚合产生的,是聚合告诉外部“我发生改变了”。当然事件在少数情况下,也可由其他主体产生。

  2. 事件Event是“过去时”的,代表着发生在聚合上的已经变化的结果。比如OrderConfirmed、PaymentReceived。

  3. 事件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调用序列

从中我们可以发现以下一些优点:

  1. 持久化的事件读取是顺次的、写入是追加式的,相比传统模式下更新数据行的方式,持久化操作的实现更加简单、性能提升明显。

  2. ES可以完整反映聚合变化的整个轨迹。通过对变化轨迹的研究和跟踪审计,可以挖掘出更深层次的业务价值。

  3. 事件的结构简单(DTO式的),适合于不同限定上下文(Bounded Context)之间的交互。

  4. 通过事件回溯和修正,可以很方便地定位系统错误发生的位置、测试系统的行为。

而其缺点也很明显:

  1. 聚合实例的重塑要提取所有历史事件并顺次进行处理,对查询性能影响明显。

  2. 随着版本升级,聚合及其事件都有可能发生改变,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模式的读写分离将为我们带来了以下便利:

  1. 两个模型可以根据不同的关注点,分别进行优化。

  2. 写模型富含业务逻辑,其一致性要求远甚于读模型,二者分离后维护将更容易定位错误、解决性能瓶颈。

  3. 读写模型可以采用不同的方式存储自己关心的数据,比如用传统关系数据库实现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的聚合实例分布于不同结点时,或者因结点切换导致发生同一聚合实例被同时修改时,可能发生并发冲突。此时聚合的版本号,将成为并发控制的有力武器之一。主要策略不外乎乐观或者悲观两种方式:

  1. 乐观策略:仅当聚合当前版本与ES中的最新版本一致,才证明聚合是最新的,可以提交对聚合的修改,否则进行重试。

  2. 悲观策略:每一次都从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

CQRS Journey概览

原文地址:https://www.cnblogs.com/Abbey/p/11000491.html