领域驱动模型&CQRS学习
1、领域驱动概述
微服务系统的设计自然离不开DDD(Domain-Driven Design,领域驱动设计),它由Eric Evans提出,是一种全新的系统设计和建模方法。DDD事实上是针对面向对象分析和设计的一个扩展和延伸,对技术架构进行了分层规划,同时对每个类进行了策略和类型的划分。领域模型是领域驱动的核心。领域模型通过聚合(Aggregate)组织在一起,聚合间有明显的业务边界,这些边界将领域划分为一个个限界上下文(Bounded Context)。采用DDD的设计思想,业务逻辑不再集中在几个大型的类上,而是由大量相对小的领域对象(类)组成,这些类具备自己的状态和行为,每个类是相对完整的独立体,并与现实领域的业务对象映射。领域模型就是由许多这样的细粒度的类组成的。基于领域驱动的设计,保证了系统的可维护性、可扩展性和可复用性,在处理复杂业务逻辑方面有着先天的优势。
1.1、Spring Cloud与领域驱动
在微服务(MicroServices)架构实践中,大量借用了DDD中的概念和技术,比如一个微服务应该对应DDD中的一个限界上下文(Bounded Context);在微服务设计中应该首先识别出DDD中的聚合根(Aggregate Root);还有在微服务之间集成时应该采用DDD中的防腐层(Anti-Corruption Layer, ACL)。我们甚至可以说DDD和微服务有着天生的默契。
注:聚合根的设计尤为重要,如果聚合根设计集中化,会随着后来的业务扩展模型越来越庞大,会导致一系列的内存、性能等问题,而且几乎不可能解决,除非重构聚合根设计。
1.2、为什么需要领域建模
领域模型有助于团队创建一个业务部门与IT部门都能理解的通用模型,并用该模型来沟通业务需求、数据实体、过程模型。模型是模块化、可扩展、易于维护的,同时设计还反映了业务模型,提高了业务领域对象的可重用性和可测性。反过来,如果IT团队在开发大中型企业软件应用时不遵循领域模型方法,不投放资源去建立和开发领域模型,会导致应用架构出现“肥服务层”和“贫血的领域模型”,在这样的架构中,会积聚越来越多的业务逻辑。我们希望领域对象能够准确地表达出业务意图,但是多数时候,我们看到的却是充满getter和setter的领域对象。此时的领域对象已经不是领域对象了,它们只是个数据载体,也就是Martin Fowler所说的贫血对象。这种做法会导致领域特定业务逻辑分散在一堆service层中,软件架构随业务开发常年累积野蛮生长,从而腐败,无法维护。
领域驱动设计告诉我们,在通过软件实现一个业务系统时,建立一个领域模型是非常重要和必要的,因为领域模型具有以下特点:
❑ 领域模型是对具有某个边界的领域的一个抽象,反映了领域内用户业务需求的本质;领域模型是有边界的,只反映了我们在领域内所关注的部分。
❑ 领域模型只反映业务,和任何技术实现无关;领域模型不仅能反映领域中的一些实体概念,如货物、书本、应聘记录、地址等;还能反映领域中的一些过程概念,如资金转账等。
❑ 领域模型确保了我们的软件业务逻辑都在一个模型中,这样对提高软件的可维护性,业务可理解性以及可重用性都有帮助。
❑ 领域模型能够帮助开发人员相对平滑地将领域知识转化为软件构造。
❑ 领域模型贯穿软件分析、设计及开发的整个过程;领域专家、设计人员、开发人员通过领域模型进行交流,彼此共享知识与信息;因为大家面向的都是同一个模型,所以可以防止需求走样,可以让软件设计开发人员做出来的软件真正满足需求。
❑ 要建立正确的领域模型并不简单,需要领域专家、设计人员、开发人员积极沟通共同努力,然后才能使大家对领域的认识不断深入,从而不断细化和完善领域模型。
❑ 为了让领域模型看得见,我们需要用一些方法来表示它;图是表达领域模型最常用的方式,但不是唯一的方式,代码或文字描述也能表达领域模型。
❑ 领域模型是整个软件的核心,是软件中最有价值和最具竞争力的部分;设计足够精良且符合业务需求的领域模型能够更快速地响应需求的变化。
2、领域驱动核心概念
2.1、 实体概述
实体(Entity)是领域中需要唯一标识的领域概念,因为我们有时需要区分是哪个实体。如果有两个实体,且唯一标识不一样,那么即便实体的其他所有属性都一样,我们也认为它们是不同的实体;因为实体有生命周期,实体被创建后可能会被持久化到数据库,然后某个时候又会被取出来。所以,如果我们不为实体定义一种可以唯一区分的标识,那我们就无法区分到底是这个实体还是那个实体。
另外,不应该给实体定义太多的属性或行为,而应该寻找关联,发现其他一些实体或值对象,将属性或行为转移到其他关联的实体或值对象上。比如Customer实体,它有一些地址信息,由于地址信息是一个完整的有业务含义的概念,所以我们可以定义一个Address对象,然后把Customer中与地址相关的信息转移到Address对象上。如果没有Address对象,而把这些地址信息直接放在Customer对象上,并且把其他类似Address的信息也都直接放在Customer上,会导致Customer对象很混乱,结构不清晰,最终导致它难以维护和理解。
2.2、值对象概述
在领域中,并不是每一个事物都必须有一个唯一标识,也就是说我们不关心对象是哪个,只关心对象是什么。就以上面的地址对象Address为例,如果有两个Customer的地址信息是一样的,我们就会认为这两个Customer的地址是同一个。也就是说只要地址信息一样,我们就认为是同一个地址。
用程序的方式来表达就是,如果两个对象的所有属性的值都相同,我们会认为它们是同一个对象,那么我们就可以把这种对象设计为值对象(Value Object)。因此,值对象没有唯一标识,这是它和实体的最大不同。另外值对象在判断是否是同一个对象时是通过它们的所有属性是否相同实现的,如果相同则认为是同一个值对象;而我们在区分是否是同一个实体时,只看实体的唯一标识是否相同,不管实体的属性是否相同。值对象另外一个明显的特征是不可变,即所有属性都是只读的。因为属性是只读的,所以可以被安全共享。当共享值对象时,一般有复制和共享两种做法,具体采用哪种做法还要根据实际情况而定。另外,我们应该将值对象设计得尽量简单,不要让它引用很多其他对象,因为它只是一个值。实体和值对象的对比,如下表所示:
2.3、领域服务
领域中的一些概念不适合建模为对象,即不适合归类到实体对象或值对象,因为它们本质上就是一些操作或动作,而不是实物。这些操作或动作往往会涉及多个领域的对象,并且需要协调这些领域对象共同完成这个操作或动作。如果强行将这些操作职责分配给任何一个对象,则被分配的对象就会承担一些不该承担的职责,从而会导致对象的职责不明确。但是基于类的面向对象的语言规定,任何属性或行为都必须放在对象里面。所以我们需要寻找一种新的模式来表示这种跨多个对象的操作,DDD认为服务是一个很自然的范式,可用来对应这种跨多个对象的操作,所以就有了领域服务(Domain Service)这个模式。领域服务本来就是来处理这种场景的。比如要对密码进行解密,可以创建一个PasswordService来专门处理加解密的问题。
领域服务还有一个很重要的功能,就是可以避免领域逻辑泄露到应用层。因为如果没有领域服务,那么应用层会直接调用领域对象完成本该属于领域服务做的操作,这样一来,领域层可能会把一部分领域泄露到应用层。因此,引入领域服务可以有效防止领域层的逻辑泄露到应用层。对于应用层,从可理解的角度来讲,通过调用领域服务提供的简单、易懂、明确的接口肯定要比直接操纵领域对象容易得多。
那如何去识别领域服务呢?主要看它是否满足以下三个特征:
❑ 服务执行的操作代表了一个领域概念,这个领域概念无法自然隶属于一个实体或者值对象。
❑ 被执行的操作涉及领域中的其他的对象。
❑ 操作是无状态的。
2.4、聚合及聚合根
聚合通过定义对象之间清晰的所属关系和边界来实现领域模型的内聚,并避免了错综复杂的、难以维护的对象关系网的形成。聚合定义了一组具有内聚关系的相关对象的集合,我们把聚合看作一个修改数据的单元。聚合中所包含的对象之间具有密不可分的联系,它们是内聚在一起的。比如一辆汽车(Car)包含了引擎(Engine)、车轮(Wheel)和油箱(Tank)等组件,缺一不可。一个聚合中可以包含多个实体和值对象,因此聚合也被称为根实体。如下图所示就是一个聚合,Customer是聚合根也是实体,address是值对象,ContactInfo也是值对象。
聚合根(Aggregate Root)是DDD中的一个概念,是一种更大范围的封装,其把一组有相同生命周期、在业务上不可分隔的实体和值对象放在一起考虑,只有根实体可以对外暴露引用,也是一种内聚性的表现。但是要确定聚合边界要满足固定规则(Invariant),也就是在数据变化时必须保持一致性规则,具体规则如下:
❑ 根实体具有全局标识,最终负责检查规定规则。
❑ 聚合内的实体具有本地标识,这些标识在Aggregate内部才是唯一的。
❑ 外部对象不能引用除根Entity之外的任何内部对象。
❑ 只有Aggregate的根Entity才能直接通过数据库查询获取,其他对象必须通过遍历关联来发现。
❑ Aggegate内部的对象可以保持对其他Aggregate根的引用。
❑ 对Aggregate边界内的任何对象进行修改时,整个Aggregate的所有固定规则都必须满足。
2.5、边界上下文
领域实体是有边界上下文的,系统获取的数据是有界上下文(Bounded Context)下的数据。边界上下文(Bounded Context)在DDD里面是一个非常重要的概念,Bounded Context明确限定了模型的应用范围。在Context中,要保证模型在逻辑上统一,而不用考虑它是不是适用于边界之外的情况。在其他Context中,会使用其他模型,这些模型具有不同的术语、概念、规则和Ubiquitous Language。那么不同Context下的业务要互相通信怎么办?这就涉及跨边界的集成了,集成不能是简单的RPC服务调用,而需要一个专门的防腐层(Anti-Corruption)做转化。防腐层主要是对外部依赖解耦,以及避免外部领域概念污染Context内部实体语义。以我们真实的业务场景举个例子,比如会员这个概念在ICBU网站上指网站上的买主,但是在CRM领域中指客户,虽然很多的属性都是一样的,但是二者在不同的Context下其语义和概念是有差别的,我们需要用防腐层做一下转换,如图所示:
2.6、工厂
DDD中的工厂(Factory)也是一种体现封装思想的模式。DDD中引入工厂模式的原因是:有时创建一个领域对象是一件比较复杂的事情,而不是仅仅进行简单的new操作就可以。正如对象封装了内部实现一样(我们无须知道对象的内部实现就可以使用对象的行为),工厂则用来封装创建一个复杂对象的操作。工厂的作用是将创建对象的细节隐藏起来。
工厂在创建一个复杂的领域对象时,通常会知道该满足什么业务规则(它知道先怎样实例化一个对象,然后对这个对象做哪些初始化操作,这些规则就是创建对象的细节),如果传递进来的参数符合创建对象的业务规则,则可以顺利创建相应的对象;但是如果由于参数无效等不能创建出期望的对象,则应该抛出一个异常,以确保不会创建出一个错误的对象。
当然也不是所有都需要通过工厂来创建对象,当构造器很简单或者构造对象不依赖于其他对象来创建时,我们只需要简单地使用构造函数创建对象就可以。隐藏创建对象的好处是显而易见的,这样可以不让领域层的业务逻辑泄露到应用层,同时也减轻了应用层的负担,它只需要简单地调用领域工厂创建符合期望的对象即可。
2.7、仓储/资源库
领域模型中的对象自从被创建出来后不会一直在内存中活动,当它不活动时会被持久化到数据库中,然后当需要的时候我们会重建该对象。重建对象就是根据数据库中已存储的对象的状态重新创建对象。所以重建对象是一个和数据库打交道的过程。从更广义的角度来理解,我们经常会像集合一样从某个类似集合的地方根据某个条件获取一个或一些对象,往集合中添加对象或移除对象。也就是说,我们需要提供一种机制,可以提供类似集合的接口来帮助我们管理对象。仓储(Repository)就是基于这样的思想被设计出来的。
仓储里面存放的对象一定是聚合,原因是领域模型中是以聚合的概念去划分边界的。聚合是我们更新对象的一个边界,事实上我们把整个聚合看成一个整体概念,要么一起被取出来,要么一起被删除。我们永远不会单独对某个聚合内的子对象进行单独查询或做更新操作。因此,我们只为聚合设计仓储。
仓储还有一个重要的特征就是分为仓储定义部分和仓储实现部分,在领域模型中我们定义仓储的接口,而在基础设施层实现具体的仓储。这样设计的原因是:仓储背后的实现都是在和数据库打交道,但是我们又不希望调用方(如应用层)把重点放在如何从数据库获取数据的问题上,因为这样做会导致调用方(应用层)代码混乱,很可能会因此而忽略了领域模型的存在。所以我们需要提供一个简单明了的接口供调用方使用,确保客户能以最简单的方式获取领域对象,从而可以让它在不被数据访问代码打扰的情况下协调领域对象以完成业务逻辑。这种通过接口来隔离封装变化的做法其实很常见。由于对外暴露的是抽象的接口并不是具体的实现,所以可以随时替换仓储的真实实现。
2.8、CQRS架构
CQRS的核心思想是将应用程序的查询部分和命令部分完全分离,这两部分可以用完全不同的模型和技术去实现。比如命令部分可以通过领域驱动设计来实现;查询部分可以直接用最快的非面向对象的方式来实现,比如用SQL。这样的思想有很多好处:
❑ 实现命令部分的领域模型,不用经常为了考虑领域对象可能会被如何查询而做一些折中处理。
❑ 由于命令和查询是完全分离的,所以这两部分可以用不同的技术架构实现,包括数据库设计理论上都可以分开设计,每一部分可以充分发挥其长处。
❑ 因为命令端没有返回值,所以可以像消息队列一样接受命令,放在队列中,慢慢处理;处理完后,可以通过异步的方式通知查询端,这样查询端可以做数据同步的处理。
CQRS架构的优缺点如下表所示:
2.9、领域事件
领域事件(Domain Event)是最近几年才加入DDD生态系统的,通过领域事件的方式达到各个组件之间的数据一致性。领域事件的额外好处在于它可以记录发生在软件系统中的所有重要修改,这样可以很好地支持程序调试和商业智能化。在CQRS架构的软件系统中,领域事件还用于写模型和读模型之间的数据同步。再进一步发展,事件驱动架构可以演变成事件源(Event Sourcing),即对聚合的获取并不是通过加载数据库中的瞬时状态实现的,而是通过重放发生在聚合生命周期中的所有领域事件完成的。
事件溯源(Event Sourcing)是基于DDD设计的,对于聚合,不保存聚合的当前状态,而是保存对象上所发生的每个事件。当要重建一个聚合对象时,可以通过回溯这些事件(即让这些事件重新发生)来让对象恢复到某个特定的状态;因为有时一个聚合可能会发生很多事件,所以如果每次要在重建对象时都从头回溯事件,会导致性能低下,所以我们会在一定时候为聚合创建一个快照。这样,我们就可以基于某个快照开始创建聚合对象了。
2.10、 领域驱动模型的设计步骤
领域驱动模型的设计步骤如下:
(1)根据需求建立一个初步的领域模型,识别出一些明显的领域概念及它们之间的关联,关联可以暂时没有方向但需要有一对一、一对多、多对多这些关系。可以用文字精确且没有歧义地描述出每个领域概念的涵义及包含的主要信息。
(2)分析主要的软件应用程序功能,识别出主要的应用层的类,这样有助于及早发现哪些是应用层的职责,哪些是领域层的职责。
(3)进一步分析领域模型,识别出哪些是实体,哪些是值对象,哪些是领域服务。
(4)分析关联,通过对业务进行更深入分析及各种软件设计原则、性能方面的权衡,明确关联的方向或者去掉一些不需要的关联。
(5)找出聚合边界及聚合根,这是一件很有难度的事情,因为在分析的过程中往往会碰到很多难以清晰判断的问题,此时需要我们凭借经验找出正确的聚合根。
(6)为聚合根配备仓储,一般情况下是为一个聚合分配一个仓储,此时只要设计好仓储的接口即可。
(7)捋顺实际业务应用场景,确定我们设计的领域模型能够有效解决业务需求。
(8)考虑如何创建领域实体或值对象,明确是通过工厂还是直接通过构造函数实现。
虽然上面介绍了设计领域模型的步骤,但是领域建模是一个不断重构、持续完善模型的过程。大家会在讨论中将变化的部分反映到模型中,从而使模型不断细化并朝正确的方向走。领域建模是领域专家、设计人员、开发人员之间沟通交流的过程,是大家工作和思考问题的基础。
2.11、领域驱动框架现状
自从Eric Evan提出DDD领域驱动设计以来已经过了很多年了,现在已经有很多人在学习或实践DDD。但是目前来看能够支持DDD开发的框架并不多,至少在国内比较罕见。在Java平台上,国外比较受欢迎的领域驱动框架是Axon Framework(http://github.com/AxonFramework/AxonFramework),该框架发展至今相对来说比较活跃,目前Github上星标已经超过1000。还有就是banq的Jdon framework(https://github.com/banq/jdonframework),这是基于DDD+CQRS+EventSourcing的开发,也是基于Java平台的。
上表中列举的领域驱动框架各有优点和缺点。如果就Java平台来讲,可以尝试使用Axon Framework,目前已经支持Spring Cloud。但是它不是目前最好的领域驱动框架,下面将介绍的Halo框架会更有优势。
3、Halo框架概述
Halo框架是基于领域驱动+CQRS+扩展点+流程编排的应用框架,致力于采用领域驱动的设计思想,规范控制程序员的随心所欲,从而解决软件的复杂性问题。架构设计原则非常简单,即在高内聚、低耦合、可扩展、易理解的大的指导思想下,尽可能贯彻面向对象的设计思想和原则。
Halo框架架构图如下:
分层设计如下:
CQRS架构如下:
参考书籍:
《重新定义Spring Cloud实战》
本文地址:https://blog.csdn.net/zangdaiyang1991/article/details/85984614