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
在传统架构下,数据在UI、模型和持久化的数据库之间流动,遵循下图所示的循环。数据库
系统从数据库中读出DTO(数据传输对象,Data Transfer Object)后,根据DTO与领域对象的映射规则,将其转换为领域对象,同时呈如今UI上。当用户在UI上完成修改后,又根据一样的映射规则,将其更新到领域对象上,同时持久化到数据库当中。最后,系统根据持久化结果刷新UI,完成一次领域操做,从而保证了从UI到领域对象,再到持久化数据之间的一致性。这当中,DTO只是为防止暴露模型细节而设计的领域对象的投影,UI则体现为对该DTO各字段的对照呈现。编程
很天然地,系统的全部业务流程也随之演变成一系列围绕DTO的CRUD操做(Create-Read-Update-Delete)。因而在很长一段时间里,下图这样千篇一概的界面发展成为MIS(信息管理系统,Management Infomation System)类软件的主流,图中左下角的记录导航条成为标配元素。在这种状况下,若是把DTO看成数据库里的一行记录,那么整个系统能够视做以DTO为Row、以CRUD操做为主要事务的一系列数据库Table。windows
传统架构(包括分层架构)简单直观,只要设计好数据模型,系统设计就算成功了一大半,并且全部写入操做都由事务包裹,可以达到强一致性要求。但它也有如下几个弊端:安全
传统架构中的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”的方式很是相似,但从函数式编程的视角,这才是最合理、最优雅的模型。那么,它到底是不是贫血模型呢?😄
当模型再也不贫血以后,对充血模型中领域对象的方法调用,将与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对象自己富含领域语义,其名称体现了用户意图,其字段限制了模型受影响的范围。
从中还能够获得以下的启示:
走到这一步,Service API、Command对象、Domain Model这几方面都已经作到“面向领域”了,剩下的只有UI和持久化了。
Microsoft在[Inductive User Interface]指南中,总结了改进用户体验的一些建议,强调不要寄但愿于用户彻底了解软件的总体架构和工做原理、流程,而要尽可能使用引导式、聚焦式的UI设计,帮助用户专一于当前某个具体领域行为,确保一次只完成一项任务。在目前架构条件下,UI是Command的发起人,因此UI的关注点能够相应地限制于Command所需的那部分,这便获得了Task-based UI。
以前的例子按Task-based UI的要求改造后,当用户点击列表项“已离职”下方的复选框时,就会弹出第二个对话框,提示填写离职的缘由。
这样的UI设计变化,就比如论述题与填空题的区别。传统UI就象论述题,用户得知道解答论述题的套路:先解释主要概念,再回答特性、分类等等。而Task-based UI就象填空题,用户始终是在一个上下文里回答当前的提问,这样必然更直观和人性化。
通过前述改进,架构与循环分别变成下面这样:
若是把循环按左右一分为二,左半部分都执行的查询操做,右半部分都是写入操做,因而设想把API一分为二,其中Command部分的方法都没有返回值,但会修改聚合对象实例状态;Query部分的方法只返回查询结果,但不会修改任何东西。这便获得了CQS原则(Command Query Separation)。
关于CQS原则,Meyer的这句话很是准确:“Asking a question should not change the answer”。
在CQRS Journey的[Conference案例]中,ConferenceService就是典型的CQS示例。
在使用CQS原则对Service API进行切分后,进一步根据读写职责不一样,把领域模型切分为Command端与Query端两个部分,便获得了下图所示的CQRS模式(命令与查询职责分离,Command and Query Responsibility Segregation)。Command端与Query端共享同一份持久数据,但Command端只写入状态,Query端只读取状态。
为进一步提升效率,读写端的持久数据分离成为必然选择,但也产生了新的矛盾——如何在两端进行数据同步,以达到最终一致性(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成为改变和存储系统状态的核心机制。在这种模式下,由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的常见实现以下所示,其中AggregateId指示是由哪一个聚合对象实例处理,Version指示在将Command发送给该聚合时聚合的最新版本,以备发生并发冲突时进行检验。
class Command { Guid Id; Guid AggregateId; Int Version; // 包含其余信息的字段 }
Event的常见实现与Commanda基本相同,区别只是AggregateId指示是由哪一个聚合对象实例产生的Event,Version表示Event发生时聚合对象实例的版本。
聚合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是聚合的集合,其主要方法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)。
快照就是特定版本的聚合对象实例,因此构建快照的方法和重现得到一个聚合对象实例是相似的:构造一个空白的初始对象,利用获取的历史事件,按版本前后重现到特定版本。正由于快照等价于某个版本的聚合对象,因此快照的生成能够彻底独立并行于系统运行,并且能够在快照基础上重现其后续版本的事件,以获得更新版本的聚合对象实例。
Command只有一个接收者,而Event能够有若干个订阅者,因此Command总与特定类型的聚合Command Handler绑定。在引入Command队列后,根据聚合对象实例的Id进行Command分组,便可保证一个聚合对象实例在任意时刻只会处理一条Command,从而保证聚合的线程安全。这也是借鉴了Actor模式(此处的Actor并不是特指Akka框架里的Actor,而是范指如下这样的模式。)
每一个Actor,都是一个封闭的、有状态的、自带邮箱、经过消息与外界进行协做的并发实体。在Actor之间的消息发送、接收都是并发的,可是在Actor内部,消息被邮箱存储后都是串行处理的。即Actor在同一时刻只会对一条异步消息作出回应,从而回避加锁等并发策略。
若是不采用Actor模式,那么就须要本身处理并发冲突。因为Command与Command Handler是一对一的,因此只有当存在多个相同Id的聚合对象实例时,好比为提升吞吐量而将多个同一Id的聚合对象实例分布于不一样结点,或者因结点切换致使发生同一聚合对象实例被同时修改时,可能会发生并发冲突。此时聚合的版本号,将成为并发控制的有力武器之一,主要策略不外乎乐观或者悲观两种方式:
另外一方面,正如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总结了业内的三种方案:
Greg Young在论文及其开发的框架[EventStore]中,都采用了最后一种方案。其主要思想是给每条Event添加一个Long类型的SequenceNumber字段,该字段在库中是惟一且递增的,表明着事件被推送的顺序号。只要Event Storage保存好推送成功的最后一条事件的SequenceNumber,就能够肯定推送完成的状况了。
Actor模型最先出自1973年Carl Hewitt等人所著论文A Universal Modular ACTOR Formalism for Artificial Intelligence。
Akka是Lightbend公司推出的一个基于Actor模型的分布式框架,目前主要支持的语言包括Java和Scala。
如下是官网及个人笔记连接:
用Akka实现CQRS与Event Sourcing的示意图以下:
Lightbend公司在Akka基础上,推出了一个微服务框架Lagom。
Lagom框架坚持,微服务是按服务边界Boundary将系统切分为若干个组成部分的结果,这意味着要使它们与限界上下文Bounded Context、业务功能和模块隔离等要求保持一致,才能达到可伸缩性和弹性要求,从而易于部署和管理。所以,在设计微服务时应考虑大小是否“Lagom”,而非是否足够“Micro”。
如下是官网和个人笔记连接:
Lagom封装了服务定位、服务网关、消息队列和路由、集群等功能。每一个服务由服务描述子、调用标识符、消息处理器等组成,在服务的内部实现中,由Akka提供的EventSourcedBehavior承担实际的消息处理和持久化。
本文是近年我的学习DDD和Event Sourcing的心得总结。限于篇幅,没有就更多细节进行探讨。
在实践中,我使用DDD指导建模的流程可简单总结以下: