引言

领域驱动设计(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概览

ES与CQRS之旅的更多相关文章

  1. CQRS之旅——旅程2(分解领域)

    旅程2:分解领域 设计停靠站点 "没有石头就没有拱门" --马可波罗 在本章中,我们将对Contoso会议管理系统进行一个高层次的概述.这将帮助您理解应用程序的结构.集成点以及应用 ...

  2. CQRS之旅——旅程3(订单和注册限界上下文)

    旅程3:订单和注册限界上下文 CQRS之旅的第一站 "寓言家和鳄鱼是一样的,只是名字不同" --约翰·劳森 描述: 订单和注册上下文有一部分职责在会议预订的过程中,在此上下文中,一 ...

  3. CQRS之旅——旅程5(准备发布V1版本)

    旅程5:准备发布V1版本 添加功能和重构,为V1版本发布做准备. "大多数人在完成一件事之后,就像留声机的唱片一样,一遍又一遍地使用它,直到它破碎,忘记了过去是用来创造更多未来的东西.&qu ...

  4. CQRS之旅——旅程4(扩展和增强订单和注册限界上下文)

    旅程4:扩展和增强订单和注册限界上下文 进一步探索订单和注册的有界上下文. "我明白,如果一个人想看些新鲜的东西,旅行并不是没有意义的."儒勒·凡尔纳,环游世界80天 对限界上下文 ...

  5. CQRS之旅——前言(翻译)

    探索CQRS和Event Sourcing 本项目聚焦在使用命令和查询分离模式和事件溯源(CQRS+Event Sourcing)构建一个具有高扩展,高可用和高维护性的应用程序. 本项目定位为一个学习 ...

  6. CQRS之旅——旅程1(我们的领域:Contoso会议管理系统)

    旅程1:我们的领域:Contoso会议管理系统 起点:我们从哪里来,我们带来了什么,谁将与我们同行?" 只要前进,我愿意去任何地方." --大卫•利文斯通 本章介绍了一个虚构的公司 ...

  7. CQRS之旅——旅程6(我们系统的版本管理)

    旅程6:我们系统的版本管理 准备下一站:升级和迁移 "变化是生活的调味品."威廉·考珀 此阶段的最高目标是了解如何升级包含实现CQRS模式和事件源的限界上下文的系统.团队在这一阶段 ...

  8. CQRS之旅——旅程7(增加弹性和优化性能)

    旅程7:增加弹性和优化性能 到达旅程的终点:最后的任务. "你不能飞的像一只长着鹪鹩翅膀的老鹰那样."亨利·哈德逊 我们旅程的最后阶段的三个主要目标是使系统对故障更具弹性,提高UI ...

  9. CQRS之旅——旅程8(后记:经验教训)

    旅程8:后记:经验教训 我们的地图有多好?我们走了多远?我们学到了什么?我们迷路了吗? "这片土地可能对那些愿意冒险的人有益."亨利.哈德逊 这一章总结了我们旅程中的发现.它强调了 ...

随机推荐

  1. div绝对定位居中

    绝对定位absolute,left:50%,加上margin-left:-(宽度/2),可以实现绝对位置居中 .list{ width: 1200px; height: 300px; overflow ...

  2. Springboot+CAS单点登录

    一:安装CAS 下载cas:https://github.com/apereo/cas 1.1 将cas并打成war包.放入一个干净的tomcat中,启动tomcat测试: http://localh ...

  3. PHP支付宝手机网站支付功能

    1.开通支付宝商家中心里面的手机网站支付 2.再去开放平台-开发者中心-创建移动支付的应用-获取到APPID 3.接着去文档中心下载DEMO 其实demo很简单.如果第一次看的话会存在看不懂的状态. ...

  4. Label显示时间

    package 第十一章; import java.awt.Button; import java.awt.Color; import java.awt.Font; import java.awt.F ...

  5. js 全世界最短的IE浏览器判断代码

    var ie = !+"\v1"; 仅仅需要7bytes!参见这篇文章,<32 bytes, ehr ... 9, ehr ... 7!!! to know if your ...

  6. Redis怎么做持久化

    1.redis持久化的两种方式 2.两种持久化方式区别 3.两种持久化方式的使用场景 1.Redis 是内存型数据库,一般来用作缓存.保存在内存的数据有一个特点, 就是断电消失,因此一旦 Redis ...

  7. deletefile 与KILL

    1.Kill 语句 从磁盘中删除文件.语法Kill pathname必要的 pathname 参数是用来指定一个文件名的字符串表达式.pathname 可以包含目录或文件夹.以及驱动器.说明在 Mic ...

  8. First-hitting-time model

    见wiki: https://en.wikipedia.org/wiki/First-hitting-time_model

  9. Insomni'hack teaser 2019 - Misc - curlpipebash

    参考链接 https://ctftime.org/task/7454 题目 Welcome to Insomni'hack teaser 2019! Execute this Bash command ...

  10. php内置函数分析之array_diff_assoc()

    static void php_array_diff_key(INTERNAL_FUNCTION_PARAMETERS, int data_compare_type) /* {{{ */ { uint ...