CQRS与Event Sourcing之浅见

引言

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提供了大量指导。html

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

本文并未就DDD的相关方法和战术模式等进行介绍。git

传统系统等于数据库表

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

Figure

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

Figure

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

Figure

很天然地,系统的全部业务流程也随之演变成一系列围绕DTO的CRUD操做(Create-Read-Update-Delete)。因而在很长一段时间里,下图这样千篇一概的界面发展成为MIS(信息管理系统,Management Infomation System)类软件的主流,图中左下角的记录导航条成为标配元素。在这种状况下,若是把DTO看成数据库里的一行记录,那么整个系统能够视做以DTO为Row、以CRUD操做为主要事务的一系列数据库Table。windows

Figure

传统架构利弊明显

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

  • 在通过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()完成时,工做彷佛已经画上了圆满句号。

在函数式编程范式中,“抽象数据类型ADT + 代数方法”组成的模型,与“DTO + CRUD”的方式很是相似,但从函数式编程的视角,这才是最合理、最优雅的模型。那么,它到底是不是贫血模型呢?😄

加入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的要求改造后,当用户点击列表项“已离职”下方的复选框时,就会弹出第二个对话框,提示填写离职的缘由。

Figure

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

引入CQS开辟新天地

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

Figure

Figure

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

关于CQS原则,Meyer的这句话很是准确:“Asking a question should not change the answer”

Figure

在CQRS Journey的[Conference案例]中,ConferenceService就是典型的CQS示例。

CQRS和ES走入视野

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

Figure

为进一步提升效率,读写端的持久数据分离成为必然选择,但也产生了新的矛盾——如何在两端进行数据同步,以达到最终一致性(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的支持下,系统架构也相应地变成了下图这样:

Figure

探究新架构

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服务的一些设计原则与方案。

重要提示
“世上没有后悔药,只有亡羊补牢”——因为事件意味着改变已经发生,因此没法被Undo,所以在以事件驱动的系统里,没有还原和回滚,只有善后和补救,这是与以事务为中心的传统架构的重要差异。

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

Command Side Query Side
一致性 一般使用事务维护强一致性。 一般采用最终一致性。
数据存储 为限制事务边界,一般要求符合第三范式。 为减小联结操做,一般知足第一范式便可。
扩展性 处理命令一般只占到系统事务很小的一部分,因此对扩展要求不高。 一般是命令处理量的数倍,所以对扩展性有较高要求。
方法 改变聚合对象实例的状态,而不返回任何结果(或者仅返回成功与否的标志)。Repository将剔除GetByID之外的其余方法。 一般返回DTO给调用者,再呈现到UI。
数据来源 处理的目标即领域对象的自己。 处理的目标是DTO,但它已经从领域对象的投影演变成直接面向数据模型的特异化结构。
了解程度 对领域模型必须有完整的理解和掌握。 只需能理解数据模型并从中拼合出DTO便可,对业务规则等无需关注。

一些实现细节

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特性。

重现部分的简单实现,参见前述Aggregate.Replay()

Event Storage是一个追加型的数据库。因为事件总与聚合对象实例相关,因此一个以聚合对象实例的Id为key、事件序列化流为value的Key-Value型NoSQL数据库将很是适合这样的场景。固然,传统的关系数据库也彻底能胜任。数据库的结构也很简单,每条Event做为一条记录,大体为这样的结构:

Name Type Content
Id Guid Event的Id,方便索引
AggregateId Guid 产出该事件的聚合对象实例Id
Version Integer 该事件的版本编号
Data Blob Event序列化获得的二进制流

Data字段的序列化除采用二进制流的方式,也可使用Json或者XML等结构化文本方式。
除上述字段外,还可附加Time Stamp等字段,这给系统回溯到指定时点提供了最基本的数据支持。

而在Query端,其数据主要目的为前端展现,因此在数据模型设计上,更趋向于“面向界面”或“面向查询”,须要一次性加载呈现所需的所有数据,因此私觉得MongoDB这样的文档型NoSQL数据库很是符合Query端的状况。

延迟加载与快照

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

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

在CQRS Journey第33页有一段关于Lazy Load在CQRS条件下有无必要的对话能够借鉴。

可是,每次从Event Storage读取全部属于某个聚合对象实例的事件而后进行重现,还是能够改进的,方法就是使用快照(Snapshot)。

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

Figure

并发冲突

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的示意图以下:

Figure

  • 由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或二进制格式。

🔗 Akka的CQRS示例,分别有Scala和Java版本

微服务

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

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

如下是官网和个人笔记连接:

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

🔗 Lagom的Hello World示例,分别有Scala和Java版本

写在最后

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

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

  • 使用事件风暴,查找全部可能的Event。
  • Command是Event的原由,所以从Event逐一倒查全部的Command。
  • Command与Command Handler一一对应,因此逐步向聚合添加职责。
  • 根据Command属性,为聚合添加相应属性,造成领域概念一览表。
  • 当聚合中的一些属性没法用Int、String等基本属性进行描述时,封装Value Object对领域概念进行说明。
  • 根据Command涉及不一样聚合之间的协做,厘清聚合之间的关系,逐步丰富聚合图谱。
  • 待聚合图谱完整和清晰以后,根据变化边界进行划分,造成各BC及模块。
相关文章
相关标签/搜索