引言

DDD是近年软件设计的热门。CQRS与Event Sourcing作为实施DDD的一种选择,也逐步进入人们的视野。围绕这两个主题,软件开发的大咖[Martin Fowler][Greg Young][Udi Dahan]分别有所论述,[MSDNC QRS Journey][Implementing DDD][Patterns, Principles, and Practices of DDD]等著述也提供了范例,国内外各大论坛的文章和DDD开源框架更是数不胜数,为学习CQRS和Event Sourcing提供了大量指导。

其中,Greg Young的论文最为系统。故本文通过解读其论文,简单梳理了CQRS与Event Sourcing的发展脉络,厘出其中的主要技术重点,并提出以Akka作为落地方案,以求对这两个主题有一个较为全面的总结。错谬之处,还望指正。

传统系统等于数据库表

这是典型的传统架构,其中Application Service是Domain Model的屏风,负责与Client打交道:

CQRS与Event Sourcing之浅见-LMLPHP

在传统架构下,数据在UI、模型和持久化的数据库之间流动,遵循下图所示的循环。

CQRS与Event Sourcing之浅见-LMLPHP

系统从数据库中读出DTO(数据传输对象,Data Transfer Object)后,根据DTO与领域对象的映射规则,将其转换为领域对象,同时呈现在UI上。当用户在UI上完成修改后,又根据同样的映射规则,将其更新到领域对象上,同时持久化到数据库当中。最后,系统根据持久化结果刷新UI,完成一次领域操作,从而保证了从UI到领域对象,再到持久化数据之间的一致性。这当中,DTO只是为防止暴露模型细节而设计的领域对象的投影,UI则体现为对该DTO各字段的对照呈现。

CQRS与Event Sourcing之浅见-LMLPHP

很自然地,系统的所有业务流程也随之演变成一系列围绕DTO的CRUD操作(Create-Read-Update-Delete)。于是在很长一段时间里,下图这样千篇一律的界面发展成为MIS(信息管理系统,Management Infomation System)类软件的主流,图中左下角的记录导航条成为标配元素。在这种情况下,如果把DTO当作数据库里的一行记录,那么整个系统可以视作以DTO为Row、以CRUD操作为主要事务的一系列数据库Table。

CQRS与Event Sourcing之浅见-LMLPHP

传统架构利弊明显

传统架构(包括分层架构)简单直观,只要设计好数据模型,系统设计就算成功了一大半,而且所有写入操作都由事务包裹,能够达到强一致性要求。但它也有以下几个弊端:

  • 在经过Application Service这层屏风后,用户意图全部被分解为CRUD操作,在领域对象之间无法得以体现。
  • 为保证DTO的信息完整和数据一致性,部分与操作无关的信息也将一并被纳入DTO,查询和构造DTO将成为系统的主要任务,而领域模型的业务流程相应被肢解和冲淡。
  • 完成一次领域操作,需要在DTO与领域对象间进行多次转译,增加了系统额外负担。这种转译被称为阻抗失配(Impedance Mismatch),其实质就是多维的对象图Graph与二维的关系Relationship之间相互转换时发生的、不可避免的信息丢失。
  • 读写操作将围绕同一数据模型展开,即使有数据库分库分表方案支持,其效率也不可避免地要受到竞态影响。

贫血模型换汤不换药

传统架构中的CRUD模式,其最大的弊端在于语义与操作的脱节。Application Service中的API通常代表着用例的某个方面,因此尚含有领域语义,比如API PlaceOrder()表示用户下单。然而该API在到达内部模型后,就被拆解映射为CRUD操作Order.Create()。相应地,API AddOrderItem()被映射为Order.Update()CancelOrder()被映射为操作Order.Delete(),等等。这样的别扭,又在DTO转译的负担之上,给理解和维护模型带来了一定的困难。

于是,为了尽可能保留用户意图,我们首先想到通过命名规范和方法的二次封装,使CRUD操作在字面上接近API的语义,比如用Product.Rename()封装Product.Update(Name = "NewName")。但这样的做法并未能改变实质,因此即使套用了Aggregate、Value Object和Repository等DDD的战术概念,但这种完全以“DTO结构 + CRUD操作”为主要元素构成的模型,被Martin Fowler等人称为“[贫血模型]”(Anemic Model)。

接下来,吸取贫血模型的教训,开始着手建立富含领域行为的各种领域对象。当API PlaceOrder()最终交给Order.Place()完成时,工作似乎已经画上了圆满句号。

加入Command一举多得

当模型不再贫血之后,对充血模型中领域对象的方法调用,将与CRUD存在典型区别,因为传递给领域对象方法的不再是“肥胖的”DTO,而只有那些必要的少量参数,且方法名直接就表达了领域语义。比如,Order.RelocateAddress(address),而不是Order.Update(Address="NewAddress")

接下来,采用重构手法,将这些必要参数封装为Command对象,缩短方法调用的参数列表长度。进而在此基础上,引入[Command Pattern],将原本直接调用领域对象的方法,变成先构造Command对象、再委托Command对象执行统一的Execute()接口方法的两个步骤。

最后,再以Service API为请求方,Command对象为载体,领域对象的方法为Command Handler,使上述模式演变为Requst-Reponse Pattern,实现了API与领域对象方法之间调用关系的脱耦,接口变得更加一致和优雅。上面的例子就变成Service.Send(RelocateAddressCommand)Order.HandleCommand(RelocateAddressCommand)

至此,改进模型的工作应该算真正结束了吧。用户经Service API构造并发出Command对象,领域对象接收并处理Command对象,完成自身状态更新,然后把状态转译为DTO持久化到数据库。同时,Service API根据Command对象处理结果,将情况反馈给呈现层实现UI刷新。

这样的结果,虽然增加了系统的复杂度,但为实现Undo/Redo等复杂机制提供了基础,同时Command对象借助消息中间件传递,还可以实现Application Service Layer与Domain Model的跨主机部署,为分布式应用提供了条件。最关键的是,Command对象本身富含领域语义,其名称体现了用户意图,其字段限制了模型受影响的范围。

从中还可以得到如下的启示:

  • 虽然Command同DTO一样都是静态结构,但它用命名更清晰地表达了“要模型做什么”的含义,而且其属性只包含了“做什么”所需要的必要信息,因而更能准确地表达用户的意图。
  • Command与Command Handler组成了命令及其解释器的特定结构,Command的祈使时态也说明了它只是一种请求,可能会被拒绝。
  • 在发现Command时,要尽量避免Create、Edit、Update、Change或者Delete这样的用词,而要去发掘RegisterCustomer、CorrectAddress或者RelocateCustomer这样更富含领域的用词,否则无疑会再回到CRUD的老路上(Udi Dahan在[演讲]里也特别提到Delete的问题)。

改进UI以适配新架构

走到这一步,Service API、Command对象、Domain Model这几方面都已经做到“面向领域”了,剩下的只有UI和持久化了。

Microsoft在[Inductive User Interface]指南中,总结了改进用户体验的一些建议,强调不要寄希望于用户完全了解软件的整体架构和工作原理、流程,而要尽量使用引导式、聚焦式的UI设计,帮助用户专注于当前某个具体领域行为,确保一次只完成一项任务。在目前架构条件下,UI是Command的发起人,所以UI的关注点可以相应地限制于Command所需的那部分,这便得到了Task-based UI

之前的例子按Task-based UI的要求改造后,当用户点击列表项“已离职”下方的复选框时,就会弹出第二个对话框,提示填写离职的原因。

CQRS与Event Sourcing之浅见-LMLPHP

这样的UI设计变化,就好比论述题与填空题的区别。传统UI就象论述题,用户得知道解答论述题的套路:先解释主要概念,再回答特性、分类等等。而Task-based UI就象填空题,用户始终是在一个上下文里回答当前的提问,这样必然更直观和人性化。

引入CQS开辟新天地

经过前述改进,架构与循环分别变成下面这样:

CQRS与Event Sourcing之浅见-LMLPHP

CQRS与Event Sourcing之浅见-LMLPHP

如果把循环按左右一分为二,左半部分都执行的查询操作,右半部分都是写入操作,于是设想把API一分为二,其中Command部分的方法都没有返回值,但会修改聚合对象实例状态;Query部分的方法只返回查询结果,但不会修改任何东西。这便得到了CQS原则(Command Query Separation)。

CQRS与Event Sourcing之浅见-LMLPHP

CQRS和ES走入视野

在使用CQS原则对Service API进行切分后,进一步根据读写职责不同,把领域模型切分为Command端与Query端两个部分,便得到了下图所示的CQRS模式(命令与查询职责分离,Command and Query Responsibility Segregation)。Command端与Query端共享同一份持久数据,但Command端只写入状态,Query端只读取状态。

CQRS与Event Sourcing之浅见-LMLPHP

为进一步提高效率,读写端的持久数据分离成为必然选择,但也产生了新的矛盾——如何在两端进行数据同步,以达到最终一致性(Eventual Consistency)。

一方面,从CQRS模式的结构看,系统状态变化都发生在Command端,因此只有Command端掌握着具体是哪些内容发生了变化,如果把变化的这些内容封装在一起,表明系统“刚刚发生了哪些变化”,就得到了所谓的事件Event。

反观Query端,查询返回的总是反映系统当前状态的静态数据。根据“当前状态 + 变化 = 新的状态”,如果能从Command端得到“变化”,就能得到变化后的“新的状态”。而Event正好符合“变化”的定义,所以选择从Command端将Event推送到Query端,Query端根据Event刷新状态,就能保证两端的模型都反映系统的最终状态,达到最终一致性。

另一方面,在解决了取得最终一致性的难题后,还得设法改进数据的持久化。

首先能确定的是,从Query端查询得到的总是系统当前状态的静态数据,所以从传统架构一直沿用到CQRS模式下的DTO方案依然有效。但是,由于这样的DTO直接映射领域对象,会暴露领域对象细节,而且这种映射会产生阻抗失配,导致过多的间接查询和多聚合数据的联结,使优化查询变得非常困难。所以,为提高查询效率,可以采取类似关系数据库中“视图”的方式,直接面向数据模型,采用一切可使用的数据库技术,构造一个Thin Read Layer

再是Command端的持久化。根据“初始状态 + 若干次变化 = 当前状态”,在初始状态上依次叠加每一次变化,同样能得到当前状态。其中聚合对象实例的初始状态是固定的,每一次变化即处理Command后产出的事件Event,那么只要保存好所有发生过的历史事件,就能从初始对象重现(Replay)到当前状态。所以,Command端的持久化最终演变成事件历史的持久化,这便是事件存储(Event Storage)。

最终,事件的产生、存储、推送和重现,即构成了完整的事件溯源(Event Sourcing)。

在CQRS与Event Sourcing的支持下,系统架构也相应地变成了下图这样:

CQRS与Event Sourcing之浅见-LMLPHP

探究新架构

CQRS使Event Sourcing成为改变和存储系统状态的核心机制。在这种模式下,由Application Service Layer统揽整个业务流程。Service首先从Query端查询系统状态,为执行Command准备好上下文环境;然后Service构造好Command,并发送给利用Repository.GetByID()加载(重现)得到的聚合对象实例;接着聚合对象实例使用内置的Command Handler完成命令处理,更新聚合状态,并产生Event,在其被持久化的同时推送往Query端;Query端收到Event后,对其自身维护的系统状态也进行更新,达到与Command端同样的一致,以迎接下一次Service的查询。

从上述过程可知,Service是一切活动的发起者和组织者,Command的执行环境均由Service准备,Command是活动内容的承载者,聚合是活动的执行者,而Event是活动的推动者。

同时要注意,Command本质是对领域模型的一种请求,可能会被模型拒绝执行(悲伤路径)。而Event则不同,它代表着系统刚刚完成了某项任务,必定发生了某种变化。事件的用语必定是肯定的过去式,而不仅仅是某个事实,比如应该是OpenFileFailed,而不是FileNotFound。

对需要多个步骤、跨越多个聚合协作才能完成的活动,本质上同样遵循上述循环,但为保证步骤间的有效衔接,又有一个新的模式Saga推出(在CQRS Journey和部分框架中,被称为Process Manager)。

Saga发出Command,也订阅Event。它在向某个聚合发出第一个Command后,就等待Event的回馈,然后根据该Event准备下一个步骤所需的上下文环境,接着向某个聚合发出下一个Command,再等待下一个Event回馈,如此周而复始,直到流程结束。

关于Saga应否有状态,争论也非常多。CQRS Journey第6章A Saga on Sagas专门就Saga进行了讲解。个人意见,Saga应当是无状态的(Stateless),否则还得花费额外精力去持久化Saga的状态。在这方面,可以参考Web服务的一些设计原则与方案。

此外,C端与Q端的差别主要有以下几点:

一些实现细节

Command与Event

Command的常见实现如下所示,其中AggregateId指示是由哪个聚合对象实例处理,Version指示在将Command发送给该聚合时聚合的最新版本,以备发生并发冲突时进行检验。

class Command {
  Guid Id;
  Guid AggregateId;
  Int Version;
  // 包含其他信息的字段
}

Event的常见实现与Commanda基本相同,区别只是AggregateId指示是由哪个聚合对象实例产生的Event,Version表示Event发生时聚合对象实例的版本。

Command与Event的Handler

聚合Aggregate是Command的处理器和事件的发布器,其Command Handler与Event Handler的基本结构如下:

class Aggregate {
  public readonly Guid AggregateId;
  public readonly List<Event> UnsavedEvents = new List<Event>();
  public Int Version = 0;

  public void HandleCommand(Command c) {
    if (!Valid(c))
      throw new AggregateException();

    var e = new SomeEvent(AggregateId, ...);
    this.HandleEvent(e);
    e.Version = this.Version;
    this.UnsavedEvents.Add(e);

    DoAnythingWithSideEffect();
  }

  void HandelEvent(Event  e) {
    ModifyState();
    this.Version ++;
  }

  public void Replay(List<Event> events) {
    foreach(var e in events) {
      this.HandleEvent(e);
    }
  }
}

Repository与Event/Data Storage

Repository是聚合的集合,其主要方法GetByID()负责返回聚合对象实例给调用者。当该实例尚未在内存当中之时,将从Event Storage读取所有对应该聚合Id的事件,接着构造一个空白的初始对象,利用获取的历史事件按版本先后重现到对象的最新版本,此后便可直接从内存中返回实例,而不再需要重复上述加载过程了,这被称为In-Memory特性。

Event Storage是一个追加型的数据库。由于事件总与聚合对象实例相关,所以一个以聚合对象实例的Id为key、事件序列化流为value的Key-Value型NoSQL数据库将非常适合这样的场景。当然,传统的关系数据库也完全能胜任。数据库的结构也很简单,每条Event作为一条记录,大致为这样的结构:

而在Query端,其数据主要目的为前端展示,所以在数据模型设计上,更趋向于“面向界面”或“面向查询”,需要一次性加载呈现所需的全部数据,所以私以为MongoDB这样的文档型NoSQL数据库非常符合Query端的情况。

延迟加载与快照

在传统架构下,Repository从Data Storage中加载聚合对象实例,通常很纠结于是否使用延迟加载(Lazy Load)。

而在Event Sourcing条件下,因为写模型本质是历史的叠加,每一次操作都是追加事件,而不是刷新整个对象,所以延迟加载没有存在必要。

但是,每次从Event Storage读取所有属于某个聚合对象实例的事件然后进行重现,仍是可以改进的,方法就是使用快照(Snapshot)。

快照就是特定版本的聚合对象实例,所以构建快照的方法和重现获得一个聚合对象实例是类似的:构造一个空白的初始对象,利用获取的历史事件,按版本先后重现到特定版本。正因为快照等价于某个版本的聚合对象,所以快照的生成可以完全独立并行于系统运行,而且可以在快照基础上重现其后续版本的事件,以得到更新版本的聚合对象实例。

CQRS与Event Sourcing之浅见-LMLPHP

并发冲突

Command只有一个接收者,而Event可以有若干个订阅者,所以Command总与特定类型的聚合Command Handler绑定。在引入Command队列后,根据聚合对象实例的Id进行Command分组,即可保证一个聚合对象实例在任意时刻只会处理一条Command,从而保证聚合的线程安全。这也是借鉴了Actor模式(此处的Actor并非特指Akka框架里的Actor,而是范指以下这样的模式。)

每个Actor,都是一个封闭的、有状态的、自带邮箱、通过消息与外界进行协作的并发实体。在Actor之间的消息发送、接收都是并发的,但是在Actor内部,消息被邮箱存储后都是串行处理的。即Actor在同一时刻只会对一条异步消息做出回应,从而回避加锁等并发策略。

如果不采用Actor模式,那么就需要自己处理并发冲突。由于Command与Command Handler是一对一的,所以只有当存在多个相同Id的聚合对象实例时,比如为提高吞吐量而将多个同一Id的聚合对象实例分布于不同结点,或者因结点切换导致发生同一聚合对象实例被同时修改时,可能会发生并发冲突。此时聚合的版本号,将成为并发控制的有力武器之一,主要策略不外乎乐观或者悲观两种方式:

  • 乐观策略:仅当聚合当前版本与Event Storage中的最新版本一致,才证明聚合是最新的,可以提交对聚合的修改,否则进行重试。
  • 悲观策略:每一次都从Event Storage重塑整个聚合,并利用同步锁等机制,保证排他性地修改聚合状态。

另一方面,正如CQRS Journey第256页的“Commands and optimistic concurrency”所述,由于Command的执行环境来自于UI和Query端,所以当Query端与UI未同步时,比如管理员Tom刚停售某Product,而此时顾客Jimmy已经在提交包含该Product的Order,这便会出现破坏最终一致性的情况。相应的一个解决方案,就是在Query模型里保存当前聚合对象实例的最新版本号(即最近一个事件的版本号),然后由Service在构造Command对象时附上该版本号(参见前述Command的常见结构)。最后,由聚合对象实例在收到该Command对象时,与自身当前版本号作对比。若两者一致,即表明Query端目前发送来的Command正是基于聚合对象实例的当前最新版本。

两步提交

在CQRS与Event Sourcing搭配的情况下,事件在持久化的同时更新Query端是一个显著的技术难点,因为这两个动作必须同时成功,否则将会破坏最终一致性。如果持久化成功,而更新Query端失败,那么Query端呈现的就不是正确的系统状态;如果持久化失败,而更新Query端成功,那么Command端执行环境与系统实际状态不符。

为此,CQRS Journey总结了业内的三种方案:

  • 将两个动作放进一个事务中执行。由于该事务将跨越读写两端,是典型的分布式事务,所以性能和可用性都较差,只有当分布式事务框架足以满足要求时才会考虑这个方案。
  • 引入消息队列,将原本分散在读写两端的两步提交,改为集中在写端的一个事务中,完成事件存入Event Storage和向消息队列推送事件的工作,再由读端负责从消息队列取出事件自行完成更新。这种情况下,两步提交的工作都主要在写端实现,相比第一种方案有了明显进步。
  • 在第二种方案基础上,改进Event Storage设计,由Event Storage本身实现将消息压入消息队列,此时写模型将只需要一个事务完成事件的持久化即可。这种方式下,事务的边界进一步缩小,写模型原本要负担的“两步提交”被简化为“一步提交”,性能得到更大幅的提升。但是Event Storage的推送能力,将成为重大考验。

Greg Young在论文及其开发的框架[EventStore]中,都采用了最后一种方案。其主要思想是给每条Event添加一个Long类型的SequenceNumber字段,该字段在库中是唯一且递增的,代表着事件被推送的顺序号。只要Event Storage保存好推送成功的最后一条事件的SequenceNumber,就可以确定推送完成的情况了。

使用Akka框架实现

Akka简介

Actor模型最早出自1973年Carl Hewitt等人所著论文A Universal Modular ACTOR Formalism for Artificial Intelligence

Akka是Lightbend公司推出的一个基于Actor模型的分布式框架,目前主要支持的语言包括Java和Scala。

以下是官网及我的笔记链接:

实现细节

用Akka实现CQRS与Event Sourcing的示意图如下:

CQRS与Event Sourcing之浅见-LMLPHP

  • 由Command与Event组成的Protocol,是Actor与外界沟通的唯一媒介。
  • EventSourcedBehavior是Write Model的核心,承担着聚合的主体责任,主要定义了Command和Event的Handler。
  • Event的SequenceNumber由框架自动生成,reply和snapshot由框架提供。
  • 聚合状态单独定义在State里,借State模式实现状态迁移。
  • Tag为事件做上标记,方便Read Model选择使用。
  • PersistenceQuerier是Read Model的核心,负责从Read Journal中根据Tag读取事件流,更新自身的读数据模型,从而实现读写模型的最终一致性。
  • Serialize为Command和Event提供序列化支持,可使用Json或二进制格式。

微服务

Lightbend公司在Akka基础上,推出了一个微服务框架Lagom。

Lagom框架坚持,微服务是按服务边界Boundary将系统切分为若干个组成部分的结果,这意味着要使它们与限界上下文Bounded Context、业务功能和模块隔离等要求保持一致,才能达到可伸缩性和弹性要求,从而易于部署和管理。因此,在设计微服务时应考虑大小是否“Lagom”,而非是否足够“Micro”。

以下是官网和我的笔记链接:

Lagom封装了服务定位、服务网关、消息队列和路由、集群等功能。每个服务由服务描述子、调用标识符、消息处理器等组成,在服务的内部实现中,由Akka提供的EventSourcedBehavior承担实际的消息处理和持久化。

写在最后

本文是近年个人学习DDD和Event Sourcing的心得总结。限于篇幅,没有就更多细节进行探讨。

在实践中,我使用DDD指导建模的流程可简单总结如下:

  • 使用事件风暴,查找所有可能的Event。
  • Command是Event的起因,因此从Event逐一倒查所有的Command。
  • Command与Command Handler一一对应,所以逐步向聚合添加职责。
  • 根据Command属性,为聚合添加相应属性,形成领域概念一览表。
  • 当聚合中的一些属性无法用Int、String等基本属性进行描述时,封装Value Object对领域概念进行说明。
  • 根据Command涉及不同聚合之间的协作,厘清聚合之间的关系,逐步丰富聚合图谱。
  • 待聚合图谱完整和清晰之后,根据变化边界进行划分,形成各BC及模块。
01-11 03:22