2004年Eric Evans 发表Domain-Driven Design –Tackling Complexity in the Heart of Software (领域驱动设计),简称Evans DDD。Evans DDD是一套综合软件系统分析和设计的面向对象建模方法。
领域建模时思考问题的角度:
我们设计领域模型时不能以用户为中心作为出发点去思考问题,不能老是想着用户会对系统做什么。而应该从一个客观的角度,根据用户需求挖掘出领域内的相关事物,思考这些事物的本质关联及其变化规律作为出发点去思考问题。
领域模型是排除了人之外的客观世界模型,但是领域模型包含人所扮演的参与者角色,但是一般情况下不要让参与者角色在领域模型中占据主要位置,如果以人所扮演的参与者角色在领域模型中占据主要位置,那么各个系统的领域模型将变得没有差别,因为软件系统就是一个人机交互的系统,都是以人为主的活动记录或跟踪;比如以Eric Evans(DDD之父)在他的书中的例子中:
如果是以人为中心的话,就变成了:托运人托运货物,收货人收货物,付款人付款,等等。
如果是领域驱动设计的思想,在经过一些用户需求讨论之后,在用户需求相对明朗之后,Eric这样描述领域模型:一个Cargo(货物)涉及多个Customer(客户,如托运人、收货人、付款人),每个Customer承担不同的角色。Cargo的运送目标已指定,即Cargo有一个运送目标。由一系列满足Specification(规格)的Carrier Movement(运输动作)来完成运输目标。
1.软件系统复杂性应对
解决复杂和大规模软件的武器可以被粗略地归为三类:抽象、分治和知识。
分治 把问题空间分割为规模更小且易于处理的若干子问题。分割后的问题需要足够小,以便一个人单枪匹马就能够解决他们;其次,必须考虑如何将分割后的各个部分装配为整体。分割得越合理越易于理解,在装配成整体时,所需跟踪的细节也就越少。即更容易设计各部分的协作方式。评判什么是分治得好,即高内聚低耦合。
抽象 使用抽象能够精简问题空间,而且问题越小越容易理解。举个例子,从北京到上海出差,可以先理解为使用交通工具前往,但不需要一开始就想清楚到底是高铁还是飞机,以及乘坐他们需要注意什么。
知识 顾名思义,DDD可以认为是知识的一种。
DDD提供了这样的知识手段,让我们知道如何抽象出限界上下文以及如何去分治。
2.与微服务架构相得益彰
微服务架构众所周知,此处不做赘述。我们创建微服务时,需要创建一个高内聚、低耦合的微服务。而DDD中的限界上下文则完美匹配微服务要求,可以将该限界上下文理解为一个微服务进程。
上述是从更直观的角度来描述两者的相似处。
在系统复杂之后,我们都需要用分治来拆解问题。一般有两种方式,技术维度和业务维度。技术维度是类似MVC这样,业务维度则是指按业务领域来划分系统。
微服务架构更强调从业务维度去做分治来应对系统复杂度,而DDD也是同样的着重业务视角。
DDD的核心诉求就是将业务架构映射到系统架构上,在响应业务变化调整业务架构时,也随之变化系统架构。而微服务追求业务层面的复用,设计出来的系统架构和业务一致,在技术架构上则系统模块之间充分解耦,可以自由地选择合适的技术架构,去中心化地治理技术和数据。
设计领域模型的一般步骤如下:
1.根据需求划分出初步的领域和限界上下文,以及上下文之间的关系。
2.进一步分析每个上下文内部,识别出哪些是实体,哪些是值对象。
3.对实体、值对象进行关联和聚合,划分出聚合的范畴和聚合根。
4.为聚合根设计仓储,并思考实体或值对象的创建方式。
5.在工程中实践领域模型,并在实践中检验模型的合理性,倒推模型中不足的地方并重构。
战略和战术设计是站在DDD的角度进行划分。战略设计侧重于高层次、宏观上去划分和集成限界上下文,而战术设计则关注更具体使用建模工具来细化上下文。
领域
现实世界中,领域包含了问题域和解系统。一般认为软件是对现实世界的部分模拟。在DDD中,解系统可以映射为一个个限界上下文,限界上下文就是软件对于问题域的一个特定的、有限的解决方案。
限界上下文
一个由显示边界限定的特定职责。领域模型便存在于这个边界之内。在边界内,每一个模型概念,包括它的属性和操作,都具有特殊的含义。
一个给定的业务领域会包含多个限界上下文,想与一个限界上下文沟通,则需要通过显示边界进行通信。系统通过确定的限界上下文来进行解耦,而每一个上下文内部紧密组织,职责明确,具有较高的内聚性。
一个很形象的隐喻:细胞质所以能够存在,是因为细胞膜限定了什么在细胞内,什么在细胞外,并且确定了什么物质可以通过细胞膜。
上下文映射图
在进行上下文划分之后,我们还需要进一步梳理上下文之间的关系。
康威定律告诉我们,系统结构应尽量的与组织结构保持一致。这里,我们认为团队结构(无论是内部组织还是团队间组织)就是组织结构,限界上下文就是系统的业务结构。因此,团队结构应该和限界上下文保持一致。
梳理清楚上下文之间的关系,从团队内部的关系来看,有如下好处:
a. 任务更好拆分,一个开发人员可以全身心的投入到相关的一个单独的上下文中。
b. 沟通更加顺畅,一个上下文可以明确自己对其他上下文的依赖关系,从而使得团队内开发直接更好的对接。
从团队间的关系来看,明确的上下文关系能够带来如下帮助:
每个团队在它的上下文中能够更加明确自己领域内的概念,因为上下文是领域的解系统;
对于限界上下文之间发生交互,团队与上下文的一致性,能够保证我们明确对接的团队和依赖的上下游。
限界上下文之间的映射关系
合作关系(Partnership):两个上下文紧密合作的关系,一荣俱荣,一损俱损。
共享内核(Shared Kernel):两个上下文依赖部分共享的模型。
客户方-供应方开发(Customer-Supplier Development):上下文之间有组织的上下游依赖。
遵奉者(Conformist):下游上下文只能盲目依赖上游上下文。
防腐层(Anticorruption Layer):一个上下文通过一些适配和转换与另一个上下文交互。
开放主机服务(Open Host Service):定义一种协议来让其他上下文来对本上下文进行访问。
发布语言(Published Language):通常与OHS一起使用,用于定义开放主机的协议。
大泥球(Big Ball of Mud):混杂在一起的上下文关系,边界不清晰。
另谋他路(SeparateWay):两个完全没有任何联系的上下文。
通过上下文映射关系,我们明确的限制了限界上下文的耦合性,无论是上下文内部交互(合作关系)还是与外部上下文交互(防腐层),耦合度都限定在数据耦合(Data Coupling)的层级。
梳理清楚上下文之间的关系后,我们需要从战术层面上剖析上下文内部的组织关系。首先看下DDD中的一些定义。
实体
当一个对象由其标识(而不是属性)区分时,这种对象称为实体(Entity)。
例:最简单的,公安系统的身份信息录入,对于人的模拟,即认为是实体,因为每个人是独一无二的,且其具有唯一标识(如公安系统分发的身份证号码)。
在实践上建议将属性的验证放到实体中。
值对象
当一个对象用于对事务进行描述而没有唯一标识时,它被称作值对象(Value Object)。
例:比如颜色信息,我们只需要知道{“name”:”黑色”,”css”:”#000000”}这样的值信息就能够满足要求了,这避免了我们对标识追踪带来的系统复杂性。
值对象很重要,在习惯了使用数据库的数据建模后,很容易将所有对象看作实体。使用值对象,可以更好地做系统优化、精简设计。
它具有不变性、相等性和可替换性。
在实践中,需要保证值对象创建后就不能被修改,即不允许外部再修改其属性。在不同上下文集成时,会出现模型概念的公用,如商品模型会存在于电商的各个上下文中。在订单上下文中如果你只关注下单时商品信息快照,那么将商品对象视为值对象是很好的选择。
谨慎使用值对象: 在实践中,我们发现虽然一些领域对象符合值对象的概念,但是随着业务的变动,很多原有的定义会发生变更,值对象可能需要在业务意义具有唯一标识,而对这类值对象的重构往往需要较高成本。因此在特定的情况下,我们也要根据实际情况来权衡领域对象的选型。
聚合根
Aggregate(聚合)是一组相关对象的集合,作为一个整体被外界访问,聚合根(Aggregate Root)是这个聚合的根节点。聚合是一个非常重要的概念,核心领域往往都需要用聚合来表达。其次,聚合在技术上有非常高的价值,可以指导详细设计。聚合由根实体,值对象和实体组成。
聚合,它通过定义对象之间清晰的所属关系和边界来实现领域模型的内聚,并避免了错综复杂的难以维护的对象关系网的形成。聚合定义了一组具有内聚关系的相关对象的集合,我们把聚合看作是一个修改数据的单元。
聚合有以下特点:
1. 每个聚合有一个根和一个边界,边界定义了一个聚合内部有哪些实体或值对象,根是聚合内的某个实体;
2. 聚合内部的对象之间可以相互引用,但是聚合外部如果要访问聚合内部的对象时,必须通过聚合根开始导航,绝对不能绕过聚合根直接访问聚合内的对象,也就是说聚合根是外部可以保持 对它的引用的唯一元素;
3. 聚合内除根以外的其他实体的唯一标识都是本地标识,也就是只要在聚合内部保持唯一即可,因为它们总是从属于这个聚合的;
4. 聚合根负责与外部其他对象打交道并维护自己内部的业务规则;
5. 基于聚合的以上概念,我们可以推论出从数据库查询时的单元也是以聚合为一个单元,也就是说我们不能直接查询聚合内部的某个非根的对象;
6. 聚合内部的对象可以保持对其他聚合根的引用;
7. 删除一个聚合根时必须同时删除该聚合内的所有相关对象,因为他们都同属于一个聚合,是一个完整的概念;
8. 如果一个聚合只有一个实体,那么这个实体就是聚合根;如果有多个实体,那么我们可以思考聚合内哪个对象有独立存在的意义并且可以和外部直接进行交互。
如何创建好的聚合?
1. 边界内的内容具有一致性:在一个事务中只修改一个聚合实例。如果你发现边界内很难接受强一致,不管是出于性能或产品需求的考虑,应该考虑剥离出独立的聚合,采用最终一致的方式。
2. 设计小聚合:大部分的聚合都可以只包含根实体,而无需包含其他实体。即使一定要包含,可以考虑将其创建为值对象。
3. 通过唯一标识来引用其他聚合或实体:当存在对象之间的关联时,建议引用其唯一标识而非引用其整体对象。如果是外部上下文中的实体,引用其唯一标识或将需要的属性构造值对象。
4. 如果聚合创建复杂,推荐使用工厂方法来屏蔽内部复杂的创建逻辑。
聚合内部多个组成对象的关系可以用来指导数据库创建,但不可避免存在一定的抗阻。如聚合中存在List<值对象>,那么在数据库中建立1:N的关联需要将值对象单独建表,此时是有ID的,建议不要将该ID暴露到资源库外部,对外隐蔽。
下图为项目中的聚合根架构设计:
领域服务
一些重要的领域行为或操作,可以归类为领域服务。它既不是实体,也不是值对象的范畴。
当我们采用了微服务架构风格,一切领域逻辑的对外暴露均需要通过领域服务来进行。如原本由聚合根暴露的业务逻辑也需要依托于领域服务。
我们将领域行为封装到领域对象中,将资源管理行为封装到资源库中,将外部上下文的交互行为封装到防腐层中。我们再回过头来看领域服务时,能够发现领域服务本身所承载的职责也就更加清晰了,即就是通过串联领域对象、资源库和防腐层等一系列领域内的对象的行为,对其他上下文提供交互的接口。
当然,你也可以把服务理解为一个对象,但这和一般意义上的对象有些区别。因为一般的领域对象都是有状态和行为的,而领域服务没有状态只有行为。需要强调的是领域服务是无状态的,它存在的意义就是协调领域对象共完成某个操作,所有的状态还是都保存在相应的领域对象中。我觉得模型(实体)与服务(场景)是对领域的一种划分,模型关注领域的个体行为,场景关注领域的群体行为,模型关注领域的静态结构,场景关注领域的动态功能。这也符合了现实中出现的各种现象,有动有静,有独立有协作。
领域对象
与以往的仅有getter、setter的业务对象不同,领域对象具有了行为,对象更加丰满。同时,比起将这些逻辑写在服务内(例如**Service),领域功能的内聚性更强,职责更加明确。
领域事件
领域事件是对领域内发生的活动进行的建模。
资源库
领域对象需要资源存储,存储的手段可以是多样化的,常见的无非是数据库,分布式缓存,本地缓存等。资源库(Repository)的作用,就是对领域的存储和访问进行统一管理的对象。
资源库对外的整体访问由Repository提供,它聚合了各个资源库的数据信息,同时也承担了资源存储的逻辑(例如缓存更新机制等)。比起以往将资源管理放在服务中的做法,由资源库对资源进行管理,职责更加明确,代码的可读性和可维护性也更强。
仓储里面存放的对象一定是聚合,原因是之前提到的领域模型中是以聚合的概念去划分边界的;聚合是我们更新对象的一个边界,事实上我们把整个聚合看成是一个整体概念,要么一起被取出来,要么一起被删除。我们永远不会单独对某个聚合内的子对象进行单独查询或做更新操作。因此,我们只对聚合设计仓储。
仓储还有一个重要的特征就是分为仓储定义部分和仓储实现部分,在领域模型中我们定义仓储的接口,而在基础设施层实现具体的仓储。这样做的原因是:由于仓储背后的实现都是在和数据库打交道,但是我们又不希望客户(如应用层)把重点放在如何从数据库获取数据的问题上。所以我们需要提供一个简单明了的接口,供客户使用,确保客户能以最简单的方式获取领域对象,从而可以让它专心的不会被什么数据访问代码打扰的情况下协调领域对象完成业务逻辑。这种通过接口来隔离封装变化的做法其实很常见。
尽管仓储可以像集合一样在内存中管理对象,但是仓储一般不负责事务处理。一般事务处理会交给一个叫“工作单元(Unit Of Work)”的东西。
工厂(Factory)
DDD中的工厂也是一种体现封装思想的模式。DDD中引入工厂模式的原因是:有时创建一个领域对象是一件比较复杂的事情,不仅仅是简单的new操作。正如对象封装了内部实现一样(我们无需知道对象的内部实现就可以使用对象的行为),工厂则是用来封装创建一个复杂对象尤其是聚合时所需的知识,工厂的作用是将创建对象的细节隐藏起来。客户传递给工厂一些简单的参数,然后工厂可以在内部创建出一个复杂的领域对象然后返回给客户。领域模型中其他元素都不适合做这个事情,所以需要引入这个新的模式,工厂。
工厂在创建一个复杂的领域对象时,通常会知道该满足什么业务规则(它知道先怎样实例化一个对象,然后在对这个对象做哪些初始化操作,这些知识就是创建对象的细节),如果传递进来的参数符合创建对象的业务规则,则可以顺利创建相应的对象;但是如果由于参数无效等原因不能创建出期望的对象时,应该抛出一个异常,以确保不会创建出一个错误的对象。当然我们也并不总是需要通过工厂来创建对象,事实上大部分情况下领域对象的创建都不会太复杂,所以我们只需要简单的使用构造函数创建对象就可以了。隐藏创建对象的好处是显而易见的,这样可以不会让领域层的业务逻辑泄露到应用层,同时也减轻了应用层的负担,它只需要简单的调用领域工厂创建出期望的对象即可。
防腐层
亦称适配层。在一个上下文中,有时需要对外部上下文进行访问,通常会引入防腐层的概念来对外部上下文的访问进行一次转义。
有以下几种情况会考虑引入防腐层:
1. 需要将外部上下文中的模型翻译成本上下文理解的模型。
2. 不同上下文之间的团队协作关系,如果是供奉者关系,建议引入防腐层,避免外部上下文变化对本上下文的侵蚀。
3. 该访问本上下文使用广泛,为了避免改动影响范围过大。
如果内部多个上下文对外部上下文需要访问,那么可以考虑将其放到通用上下文中。
数据流转
首先领域的开放服务通过信息传输对象(DTO)来完成与外界的数据交互;在领域内部,我们通过领域对象(DO)作为领域内部的数据和行为载体;在资源库内部,我们沿袭了原有的数据库持久化对象(PO)进行数据库资源的交互。同时,DTO与DO的转换发生在领域服务内,DO与PO的转换发生在资源库内。
与以往的业务服务相比,当前的编码规范可能多造成了一次数据转换,但每种数据对象职责明确,数据流转更加清晰。
如同为项目中用到的数据流转换图:
上下文集成
通常集成上下文的手段有多种,常见的手段包括开放领域服务接口、开放HTTP服务以及消息发布-订阅机制。同时,如果在一个上下文对另一个上下文进行集成时,若需要一定的隔离和适配,可以引入防腐层的概念。
分离领域
典型的企业应用架构由下面四个概念上的层组成:
1. 用户界面(表现层) 负责给用户展示信息,并解释用户命令。
2. 应用层 该层协调应用程序的活动。不包括任何业务逻辑,不保存业务对象的状态,但能保存应用程序任务过程的状态。
3. 领域层 这一层包括业务领域的信息。业务对象的状态在这里保存。业务对象的持久化和它们的状态可能会委托给基础设施层。
4. 基础设施层 对其它层来说,这一层是一个支持性的库。它提供层之间的信息传递,实现业务对象的持久化,包含对用户界面层的支持性库等。
让我们更详细地看一下应用层和领域层:
应用层
• 负责应用中UI屏幕之间的导航,以及与其它系统应用层之间的交互。
• 还能对用户输入的数据进行基本(非业务相关)的验证,然后再把数据传到应用的其它层(更底层)。
• 不包含任何业务、领域相关的逻辑、或数据访问逻辑。
• 没有任何反映商业用例的状态,但却能处理用户会话或任务进展的状态。
• 应用服务虽然没有领域逻辑,但涉及到了对多个领域服务的编排。当业务规模庞大到一定程度,编排本身就富含了业务逻辑(除此之外,应用服务在稳定性、性能上所做的措施也希望统一起来,而非散落各处),那么此时应用服务对于外部来说是一个领域服务,整体看起来则是一个独立的限界上下文。此时应用服务对内还属于应用服务,对外已是领域服务的概念,需要将其暴露为微服务。
领域层
• 负责业务领域的概念,业务用例和业务规则的相关信息。领域对象封装了业务实体的状态和行为。
• 如果用例跨越多个用户请求(比如贷款登记过程包含多个步骤:用户输入贷款详细信息,系统基于贷款特性返回产品和利率,用户选择特定的产品/利率组合,最后系统会用这个利率锁定贷款),还可以管理业务用例的状态(会话)。
• 包含服务对象,这些服务对象只包含一个定义好的、不属于任何领域对象的可操作行为。服务封装了业务领域的状态,而业务领域并不适用于领域对象本身。
• 是商业应用的核心,应该与应用的其它层隔离开来。而且,它不应该依赖于其它层使用的应用框架(JSP/JSF、Struts、EJB、Hibernate、XMLBeans等)。
在分层架构中其他层如何与领域层交互
从经典的领域驱动设计分层架构中可以看出,领域层的上层是应用层,下层是基础设施层。那么领域层是如何与其它层交互的呢?对于会影响领域层中领域对象状态的应用层功能,一般应用层会先启动一个工作单元,然后:
1. 对于修改领域对象的情况,通过仓储获取领域对象,调用领域对象的相关业务方法以完成业务逻辑处理;
2. 对于新增领域对象的情况,通过构造函数或工厂创建出领域对象,如果需要还可以继续对该新创建的领域对象做一些操作,然后把该新创建的领域对象添加到仓储中;
3. 对于删除领域对象的情况,可以先把领域对象从仓储中取出来,然后将其从仓储中删除,也可以直接传递一个要删除的领域对象的唯一标识给仓储通知其移除该唯一标识对应领域对象;
如果一个业务逻辑涉及到多个领域对象,则调用领域层中的相关领域服务完成操作。注意,以上所说的所有领域对象都是只聚合根,另外在应用层需要获取仓储接口以及领域服务接口时,都可以通过IOC容器获取。最后通知工作单元提交事务从而将所有相关的领域对象的状态以事务的方式持久化到数据库。
工作单元
工作单元的几种实现方法:
1. 基于快照的实现,即领域对象被取出来后,会先保存一个备份的对象,然后当在做持久化操作时,将最新的对象的状态和备份的对象的状态进行比较,如果不相同,则认为有做过修改,然后进行持久化;这种设计的好处是对象不用告诉工作单元自己的状态修改了,而缺点也是显而易见的,那就是性能可能会低,备份对象以及比较对象的状态是否有修改的过程在当对象本身很复杂的时候,往往是一个比较耗时的步骤,而且要真正实现对象的深拷贝以及判断属性是否修改还是比较困难的。
2. 不基于快照,而是仓储的相关更新或新增或删除接口被调用时,仓储通知工作单元某个对象被新增了或更新了或删除了。这样工作单元在做数据持久化时也同样可以知道需要持久化哪些对象了;这种方法理论上不需要ORM框架的支持,对领域模型也没有任何倾入性,同时也很好的支持了工作单元的模式。对于不想用高级ORM框架的朋友来说,这种方法挺好。
3. 不基于快照,也不用仓储告诉工作单元数据更改了。而是采用AOP的思想,采用透明代理的方式进行一个拦截。在Hibernate中,我们的属性通常要被声明为virtual的,一个原因就是Hibernate会生成一个透明代理,用于拦截对象的属性被修改时,自动通知工作单元对象的状态被更新了。这样工作单元也同样知道需要持久化哪些对象了。这种方法对领域模型的倾入性不大,并且能很好的支持工作单元模式,如果用Hibernate作为ORM,这种方法用的比较多。
4. 对于不会影响领域层中领域对象状态的查询功能:
可以直接通过仓储查询出所需要的数据。但一般领域层中的仓储提供的查询功能也许不能满足界面显示的需要,则可能需要多次调用不同的仓储才能获取所需要显示的数据;其实针对这种查询的情况,可以直接通过CQRS的架构来实现。即对于查询,我们可以在应用层不调用领域层的任何东西,而是直接通过某个其他的用另外的技术架构实现的查询引擎来完成查询,比如直接通过构造参数化SQL的方式从数据库一个表或多个表中查询出任何想要显示的数据。这样不仅性能高,也可以减轻领域层的负担。领域模型不太适合为应用层提供各种查询服务,因为往往界面上要显示的数据是很多对象的组合信息,是一种非对象概念的信息,就像报表。