领域驱动设计的实践 – CQRS & Event Sourcing

1、前言

领域驱动(Domain – Driven Design)设计的理念在于建立一系列既符合软件所处领域本身又适合软件分析开发需要的领域模型。命令查询与职责分离(Command Query Responsibility Segregation)和事件溯源(E、vent Sourcing)是为一种领域驱动设计的实践。

本文旨在简要介绍CQRS & Event Sourcing, 希望能够给大家在设计业务系统上提供一种新的思路和选择。

2、领域驱动设计

在开始介绍CQRS之前,有必要先了解DDD中的一些基本思想和概念。

各行各业都有业务系统和软件开发的需求。比如Fintech公司会开发贷款业务系统,证券公司会开发股票行情交易软件,旅行社会开发在线旅游网站。虽然作为程序员,我赞成大家都能博学多才,上晓天文,下知地理。但是毕竟术业有专攻,做贷款业务系统时,我们需要请教信贷专家;研发股票行情交易系统时,我们会和交易员一起讨论;实践旅游网站时,兴许我们需要请教资深的旅行家……

那么当我们和领域专家围坐一起,高谈阔论之时,我们怎样才能做到有效的沟通,而不是鸡同鸭讲呢?DDD给了我们如下一些启发:

1、确定的领域模型(Domain Model)

明确的领域模型是一切的基础。

一个良好定义的领域模型一般会有以下几个特点:

该模型应该包含所有来自领域专家的知识

该模型可以让开发团队很清楚的界定领域边界,并且判断知识的上下文一致性

开发者可以将该模型以代码的形式进行表述

该模型可以方便地应对来自领域的变化

2、通用的语言(Ubiquitous Language)

对于领域中的名词,概念,所有的开发者和领域专家会采用同样的词汇,并且有着同样的理解。

比如在支付系统中,“渠道”可以是“微信支付”,“连连支付”,而不是“工商银行”, “招商银行”。如果开发者和领域专家的理解不同,那最后设计出来的产品势必南辕北辙。

3、实体(Entities),值(value), 服务(Services)

实体有一个全局唯一的标识,并且在整个生命周期中不变。

比如在支付系统中,一个交易(Transaction)就是一个实体。每个交易都有自己独有的交易ID。

值没有一个唯一的标识。比如在支付系统中。

交易的状态可以分为:“创建中”,“处理中”,“成功”,“失败”。 所以交易的状态就是一个值。

服务:除了实体和值之外,对于描述的动作,领域驱动的设计认为这是一个服务。

比如在支付系统中,与第三方支付绑定银行卡的行为,我们就可以认为是一种服务。

4、聚合(Aggregate)和聚合根(Aggregate Root)

相对于实体,值和服务是用来进行领域驱动设计中的建模模型。聚合和聚合根则是根据领域的原则分割并且描述实体之间的组合。

想象一下,如果一个系统中有许多用户,每个用户都可以修改部分数据。那么如何保证数据的一致性问题:

当每一个用户修改数据时,将数据库中所有的表都锁定。确实,这样可以保证强一致性,但是这肯定不是一个用户体验好的系统,并且性能十分糟糕

当每一个用户修改数据时,只将部分数据锁定。如此在用户的可用性和系统的一致性上能取到权衡

这里的问题就在于,如何界定B方案中的“部分”, DDD认为聚合就是在考量系统一致性后,相关的实体和值组合在一起的最小不可分割的集合。而聚合根本质仍然是一个实体, 在DDD中认为聚合根是访问聚合的唯一方式。

可能说的比较抽象,仍然以支付系统为例:我们认为“订单”就是一个聚合。订单可以包含多个“交易”, 同时订单也是一个实体,因为订单号是订单的唯一“标识”,订单本身也可以作为订单自身聚合的聚合根,外界通过访问订单才能访问订单中的交易。

以上便是DDD的一些基本概念,作为开发者而言,我并不赞同概念的堆砌和教条主义,其实很多时候,我们已经不自觉的使用了DDD的一些概念潜移默化的指导我们平时的软件开发,比如我们会在开发的小组内,使用约定俗称的名词,开发者和业务员都能明白这些没有歧义的名词(通用语言), 开发者也会站在业务员的角度思考软件系统内部设计分割的原则(聚合/聚合根的设计)。我相信DDD不是软件设计的条条框框,而是大量软件设计实践后,对于良好设计范式的一个总结和提炼。

3、命令查询与职责 & 事件溯源的系统架构

首先我们来看一个经典的基于数据驱动(Data Driven)的系统设计结构:

这是一个非常经典的系统设计,数据驱动的架构有很多现代ORM可以方便地实现基本的功能,优点不言而喻。我们就来谈谈这样的系统的局限性:

1、无法实践领域驱动编程

很明显,这样系统对领域对象最基本的操作就是增删改查(CURD),但是增删改查是计算机世界的术语,并不是一个领域的通用语言。在领域世界中,通用的语言远远比增删改查复杂的多。

还是以支付系统为例:比如创建“订单”,这并不是一个简单的增加操作。因为“订单”可能包含多个“交易”,所以创建“订单”其实包括增加一个“订单”信息以及增加该订单下的多个“交易”信息,并且最终将订单和交易增加(序列化)到数据存储中。而查询订单,则包括查询订单所属的“交易”,最终组合出订单并且返回。

如果最终的数据存储是一个关系型的数据库,则创建“订单”和查询“订单”的操作,需要开发人员理解订单和交易的关系,并且转换订单/交易模型至一个关系型的数据库。

2、单一的对象实体作用于数据读写

仍然以支付系统为例:无论是增加订单还是查询订单,在这样的系统中,订单被建模成一个单一的实体。无论是存储还是读取,都会将整个订单对象序列化到数据存储或者反序列出来。如果我们只是修改一个订单的状态,并且查询最新的订单状态,真的需要将整个对象都写入数据存储或者读取出来吗?撇开系统性能而言,安全性也是一个值得考量的问题。

基于对现实世界的观察,任何的方法,都可以拆解为两类:命令和查询。查询负责返回数据,并且不改变数据的状态。命令负责改变数据的状态,产生事件,但是不返回任何数据。任何复杂的方法(DDD中的服务),都可以是命令和查询的组合。

由此,我们来看一下基于CQRS & Event Sourcing的系统设计:

在CQRS的架构设计中,客户端可以发送命令,或者要求查询。对于命令而言,由命令总线负责分发给相应的命令处理器。命令处理器通过事件溯源加载得到相应的聚合根,修改聚合,并且产生相应的事件。事件首先会被存储,继而被事件总线分发给事件处理器。由事件处理器根据相应的事件将领域模型转换成写数据库中的存储表现形式。

写数据库可以以一种可靠的方式,将数据同步到读数据库,对于接受最终一致性的系统而言,这是可以接受的同步方式。

而另一方面,对应查询的需求,可以由简单的查询处理器接受查询请求,将写数据库中的数据转换成查询需要的形式予以返回。

事件溯源是一种通过采集所有的历史事件还原一个聚合状态的方法。

以一个支付系统的订单(聚合)为例。订单的生命周期可以是创建 -> 待计划 -> 执行中 -> 完成。那么对应的事件可以是订单创建事件,订单计划执行事件,订单执行事件,订单完成事件。

对于普通数据驱动的设计而言,订单的信息存储可能是这样的:(关系型数据库)

而对于支持事件溯源的系统而言,订单的存储可以是这样的:

{

“aggregateId” : “201609011005”

“eventPayload “: “Created”

“timeStamp” : “2016/09/01 10:50:01 ”

}

{

“aggregateId” : “201609011005”

“eventPayload “: “Scheduled”

“timeStamp” : “2016/09/01 10:51:11 ”

}

{

“aggregateId” : “201609011005”

“eventPayload “: “Executing”

“timeStamp” : “2016/09/01 18:02:59 ”

}

事件溯源将这三个事件依次加载处理,便可以还原出订单的现在状态。在聚合事件数量大的情况下,采用事件快照(Event Snapshot)可以有效提高事件溯源的效率和速度。

经过以上分析,在CQRS & Event Sourcing的设计中,我们可以看到以下优点:

读写分离:不同于数据驱动的设计,读写使用的同一个流程,甚至是同一个模型。在CQRS中,领域模型根据事件序列化至数据库。而查询模块则完全可以定义需要查询领域模型。读写是完全隔离的。如果使用数据库同步的方式,读写甚至可以使用不同的数据库(取决于系统对一致性的需求)。所以在这里,我们可以提高系统的吞吐量和性能。并且可以分别对写数据库和读数据库做出针对性的优化。

符合领域设计的原则:无论是命令还是事件,都是基于对现实世界的观察。不同于增删改,整个系统是由命令和事件驱动,由命令对相应的聚合(实体)进行修改。而修改则产生了相应的事件,事件可以再产生命令,如此往复。

我们的世界此刻不正是由无数个事件叠加产生的结果吗?

整个系统的所有事件都有历史记录:对于任何聚合的生命周期中,如何被创建,修改直至回收的过程,都可以通过一个又一个事件被回溯,分析。我们不仅仅关心聚合最终状态,对中间记录的分析同样也有价值。

同样,CQRS & Event Sourcing 也有自身的局限性:

系统结构相对于经典的设计而言复杂。需要设计命令总线,命令分发器,事件总线,事件分发器。需要设计良好的事件存储机制,以及事件溯源机制

对于简单,静态的系统,或者是没有复杂协作上下文(Bounded Context)的领域模型的系统,引入CQRS并不会得到很多益处,相反会使得系统臃肿,庞大

因为引入了部分DDD领域设计的概念,对于开发人员也有一定的学习曲线

4、总结

CQRS & ES 给我们提供了一种有别于传统经典体系的设计思路,在业务系统中分析哪里需要使用CQRS & ES 需要我们权衡实施这种新体系架构所需的代价和长期的回报。此文简要介绍了CQRS & ES 在领域驱动设计内的实践,希望能抛砖引玉,与诸君共勉。

本文作者:王磊(点融黑帮),点融网高级软件工程师,目前就职于Fincore部门。曾在投行工作四年,专注于支付业务。爱旅行,爱网球,爱乒乓。

推荐阅读更多精彩内容