Domain Driven Design:领域驱动设计

什么是领域驱动设计

DDD(Domain Driven Design,领域驱动设计)是一种以领域模型作为设计基础的开发方法,通过无限接近真实世界的领域模型,来帮助我们设计高质量的软件模型。

为什么要用领域驱动设计

软件发展的规律就是逐步由简单软件向复杂软件转变,当软件变得复杂时,代码就会越来越臃肿,不易维护,也就是软件退化

1
2
3
我们在做软件开发设计时要遵循一个原则:开放-封闭原则(OCP)
1、开放原则:对于功能扩展是开放的,即当系统需求发生变更时,可以对软件功能进行扩展,使其满足用户新的需求
2、封闭原则:对于软件代码的修改是封闭的,即在修改软件的同时,不要影响到系统原有的功能

软件退化的根源

  • 软件的本质是对真实世界的模拟,当我们只以需求/功能角度设计开发时,代码就会越来越难以理解
  • 软件要做成什么样,是由真实世界决定的

杜绝软件退化的利器:两顶帽子

  • 在不添加新功能的前提下,重构代码,调整原有程序结构,以适应新功能
  • 实现新的功能

反复重构代码,在经历了多次变更后就容易迷失方向,那应该如何重构代码而不迷失方向,答案就是:利用领域驱动设计,真实世界是什么样的,那么软件世界就怎么设计

领域模型

那么我们如何定义领域模型呢?首先我们知道好的软件设计常常遵循单一职责原则:软件系统中的每个元素只完成自己职责范围内的事,而将其他的事交给别人去做,我只是去调用。一个职责就是软件变化的一个原因。

  • 当“付款”发生变更时,“折扣”是不是一定要变
  • 当“折扣”发生变更时,“付款”是不是一定要变

答案不言而喻,因此“付款”和“折扣”是软件变化的两个原因,是两个职责,也是两个领域模型

领域模型的最终落地是三种类型的对象:服务、实体和值对象

  • 服务(Service):标识的是在领域对象之外的操作与行为,接收用户的请求和执行某些操作
  • 实体(Entity):通过一个唯一标识字段来区分真实世界中的每一个个体的领域对象
  • 值对象:代表真实世界中那些一成不变的、本质性的事物(可变性是实体的特点,不变性是值对象的本质)

设计思路有两种:贫血模型和充血模型

  • 贫血模型:就是在软件设计中,有很多的POJO对象,它们除了有一堆get/set方法,几乎没有任何业务逻辑(贫血模型更容易应对复杂的业务处理场景)
  • 充血模型:领域对象中既包括状态,又包括行为及相关业务逻辑,是最符合面向对象的设计方式

最佳实践:将需要封装的业务逻辑放到领域对象中,按照充血模型去设计;除此之外的其他业务逻辑放到Service中,按照贫血模型去设计。所有增删改操作应当遵循领域驱动设计的思想进行设计,所有的查询功能应当直接采用sql进行查询

image

聚合与聚合根

  • 聚合:表达的是真实世界中整体和部分的关系,当整体不存在时,则部分就没有意义。(订单与用户的关系就不是聚合关系)
  • 聚合根:是外部访问的唯一入口,当有了整体和部分时,整体就是聚合根。(一般来说领域设计面向增删改的设计,查询是不受领域的限制)

仓库(Repository)与工厂(Factory)

通过仓库查询聚合(装载),通过工厂来补填聚合的信息,对缓存、对数据库的操作都封装在了仓库和工厂中。

  • 仓库:负责领域整体的增删改查
  • 工厂:负责缓存以及填充聚合的数据

领域建模

领域建模是将一个系统划分成了多个子域,每个子域都是一个独立的业务场景。每个子域的实现就是限界上下文,它们之间的关联关系是上下文地图,各个领域之间通过领域事件进行交互。

统一语言建模与限界上下文

在 DDD 中,一个领域被分成若干个子域,领域模型在限界上下文中完成开发。限界上下文用来为领域提供上下文语境,统一语言建模,保证在领域之内的一些术语、业务相关对象等(通用语言)有一个确切的含义,没有二义性。

UL(Ubiquitous Language,通用语言)是团队共享的语言,是DDD中最具威力的特性之一。不管你在团队中的角色如何,只要你是团队的一员,你都将使用UL。由于UL的重要性,所以需要让每个概念在各自的上下文中是清晰无歧义的,于是DDD在战略设计上提出了模式BC(Bounded Context,限界上下文)。UL和BC同时构成了DDD的两大支柱,并且它们是相辅相成的,即UL都有其确定的上下文含义,而BC中的每个概念都有唯一的含义。

一个业务领域划分成若干个BC,它们之间通过Context Map进行集成。BC是一个显式的边界,领域模型便存在于这个边界之内。领域模型是关于某个特定业务领域的软件模型。通常,领域模型通过对象模型来实现,这些对象同时包含了数据和行为,并且表达了准确的业务含义。
从广义上来讲,领域即是一个组织所做的事情以及其中所包含的一切,表示整个业务系统。由于“领域模型”包含了“领域”这个词,我们可能会认为应该为整个业务系统创建一个单一的、内聚的和全功能式的模型。然而,这并不是我们使用DDD的目标。正好相反,领域模型存在于BC内。

事件风暴会议和领域事件

什么是事件风暴?事件即事实,即在业务领域中那些已经发生的事件就是事实。需求分析阶段,通过【统一语言建模】来指导思想,开发更进一步的理解业务,实践方法是通过【事件风暴会议】来和客户一起探讨出领域事件、领域建模等。

常用的领域建模方法:四色建模法

四色建模法是在UML建模的基础上增添了一些描述,把实体分为四类,并标注不同的颜色的一种建模方法。通过还原业务逻辑事件,依据是否影响公司的运营和发展,确定凭证作为时标型对象,并补全相关描述。

四色建模法包括哪四色:

  • 时标型(Moment-Interval)对象:具有可追溯性的记录运营或管理数据的时刻或时段对象,用粉红色表示
  • PPT(Party/Place/Thing)对象:代表参与到流程中的参与方/地点/物,用绿色表示
  • 角色(Role)对象:在时标型对象与 PPT 对象(通常是参与方)之间参与的角色,用黄色表示
  • 描述(Description)对象:对 PPT 对象的一种补充描述,用蓝色表示

image

一句话来概括:一个什么样的人或物品以某种角色在某个时刻或某段时间内参与某个活动

时标型对象的特点:

  • 可追溯性的记录数据
  • 代表某个时间的事实
  • 如果缺失会影响企业的运营和管理

分析步骤:

1.首先以满足管理和运营的需要为前提,寻找需要追溯的事件。
2.根据这些需要追溯,寻找足迹以及相应的时标性对象。
3.寻找时标性对象周围的人、事、物。
4.从中抽象角色。
5.把一些信息用描述对象补足。

一个完整的四色建模法完成品为下图所示:
image

深入理解DDD

DDD的真谛

首先深刻理解业务,然后把我们把对业务的理解绘制成领域模型,再通过领域模型来指导程序和数据库的设计。

基于DDD的微服务设计思路

image

DDD的整洁架构之道

image

DDD的三种分层架构

分层架构的一个重要原则是每层只能与位于其下方的层发生耦合。分层架构的好处是显而易见的。首先,由于层间松散的耦合关系,使得我们可以专注于本层的设计,而不必关心其他层的设计,也不必担心自己的设计会影响其它层,对提高软件质量大有裨益。其次,分层架构使得程序结构清晰,升级和维护都变得十分容易,更改某层的具体实现代码,只要本层的接口保持稳定,其他层可以不必修改。即使本层的接口发生变化,也只影响相邻的上层,修改工作量小且错误可以控制,不会带来意外的风险。

下面介绍一下分层架构中比较经典的三种模式:

四层架构

Eric Evans在《领域驱动设计-软件核心复杂性应对之道》这本书中提出了传统的四层架构模式,如下图所示:
image

  • User Interface为用户界面层(或表示层),负责向用户显示信息和解释用户命令。这里指的用户可以是另一个计算机系统,不一定是使用用户界面的人。
  • Application为应用层,定义软件要完成的任务,并且指挥表达领域概念的对象来解决问题。这一层所负责的工作对业务来说意义重大,也是与其它系统的应用层进行交互的必要渠道。应用层要尽量简单,不包含业务规则或者知识,而只为下一层中的领域对象协调任务,分配工作,使它们互相协作。它没有反映业务情况的状态,但是却可以具有另外一种状态,为用户或程序显示某个任务的进度。
  • Domain为领域层(或模型层),负责表达业务概念,业务状态信息以及业务规则。尽管保存业务状态的技术细节是由基础设施层实现的,但是反映业务情况的状态是由本层控制并且使用的。领域层是业务软件的核心,领域模型位于这一层。
  • Infrastructure层为基础实施层,向其他层提供通用的技术能力:为应用层传递消息,为领域层提供持久化机制,为用户界面层绘制屏幕组件,等等。基础设施层还能够通过架构框架来支持四个层次间的交互模式。

每一层用更接地气的方式解释如下:

  • User Interface层主要是Restful消息处理,配置文件解析,等等。
  • Application层主要是多进程管理及调度,多线程管理及调度,多协程调度和状态机管理,等等。
  • Domain层主要是领域模型的实现,包括领域对象的确立,这些对象的生命周期管理及关系,领域服务的定义,领域事件的发布,等等。
  • Infrastructure层主要是业务平台,编程框架,第三方库的封装,基础算法,等等。

五层架构(DCI架构)

James O. Coplien和Trygve Reenskaug在2009年发表了一篇论文《DCI架构:面向对象编程的新构想》,标志着DCI架构模式的诞生。面向对象编程的本意是将程序员与用户的视角统一于计算机代码之中:对提高可用性和降低程序的理解难度来说,都是一种恩赐。可是虽然对象很好地反映了结构,但在反映系统的动作方面却失败了,DCI的构想是期望反映出最终用户的认知模型中的角色以及角色之间的交互。

传统上,面向对象编程语言拿不出办法去捕捉对象之间的协作,反映不了协作中往来的算法。就像对象的实例反映出领域结构一样,对象的协作与交互同样是有结构的。协作与交互也是最终用户心智模型的组成部分,但你在代码中找不到一个内聚的表现形式去代表它们。在本质上,角色体现的是一般化的、抽象的算法。角色没有血肉,并不能做实际的事情,归根结底工作还是落在对象的头上,而对象本身还担负着体现领域模型的责任。

人们心目中对“对象”这个统一的整体却有两种不同的模型,即“系统是什么”和“系统做什么”,这就是DCI要解决的根本问题。用户认知一个个对象和它们所代表的领域,而每个对象还必须按照用户心目中的交互模型去实现一些行为,通过它在用例中所扮演的角色与其他对象联结在一起。正因为最终用户能把两种视角合为一体,类的对象除了支持所属类的成员函数,还可以执行所扮演角色的成员函数,就好像那些函数属于对象本身一样。换句话说,我们希望把角色的逻辑注入到对象,让这些逻辑成为对象的一部分,而其地位却丝毫不弱于对象初始化时从类所得到的方法。我们在编译时就为对象安排好了扮演角色时可能需要的所有逻辑。如果我们再聪明一点,在运行时才知道了被分配的角色,然后注入刚好要用到的逻辑,也是可以做到的。

算法及角色-对象映射由Context拥有。Context“知道”在当前用例中应该找哪个对象去充当实际的演员,然后负责把对象“cast”成场景中的相应角色(cast 这个词在戏剧界是选角的意思,此处的用词至少符合该词义,另一方面的用意是联想到cast 在某些编程语言类型系统中的含义)。在典型的实现里,每个用例都有其对应的一个Context 对象,而用例涉及到的每个角色在对应的Context 里也都有一个标识符。Context 要做的只是将角色标识符与正确的对象绑定到一起。然后我们只要触发Context里的“开场”角色,代码就会运行下去。

于是我们有了完整的DCI架构(Data、Context和Interactive三层架构):

  • Data层描述系统有哪些领域概念及其之间的关系,该层专注于领域对象的确立和这些对象的生命周期管理及关系,让程序员站在对象的角度思考系统,从而让“系统是什么”更容易被理解。
  • Context层:是尽可能薄的一层。Context往往被实现得无状态,只是找到合适的role,让role交互起来完成业务逻辑即可。但是简单并不代表不重要,显示化context层正是为人去理解软件业务流程提供切入点和主线。
  • Interactive层主要体现在对role的建模,role是每个context中复杂的业务逻辑的真正执行者,体现“系统做什么”。role所做的是对行为进行建模,它联接了context和领域对象。由于系统的行为是复杂且多变的,role使得系统将稳定的领域模型层和多变的系统行为层进行了分离,由role专注于对系统行为进行建模。该层往往关注于系统的可扩展性,更加贴近于软件工程实践,在面向对象中更多的是以类的视角进行思考设计。

DCI目前广泛被看作是对DDD的一种发展和补充,用在基于面向对象的领域建模上。显式的对role进行建模,解决了面向对象建模中的充血模型和贫血模型之争。DCI通过显式的用role对行为进行建模,同时让role在context中可以和对应的领域对象进行绑定(cast),从而既解决了数据边界和行为边界不一致的问题,也解决了领域对象中数据和行为高内聚低耦合的问题

面向对象建模面临的一个棘手问题是数据边界和行为边界往往不一致。遵循模块化的思想,我们通过类将行为和其紧密耦合的数据封装在一起。但是在复杂的业务场景下,行为往往跨越多个领域对象,这样的行为如果放在某一个对象中必然会导致别的对象需要向该对象暴漏其内部状态。所以面向对象发展的后来,领域建模出现两种派别之争,一种倾向于将跨越多个领域对象的行为建模在领域服务中。如果这种做法使用过度,则会导致领域对象变成只提供一堆get方法的哑对象,这种建模结果被称之为贫血模型。而另一派则坚定的认为方法应该属于领域对象,所以所有的业务行为仍然被放在领域对象中,这样导致领域对象随着支持的业务场景变多而变成上帝类,而且类内部方法的抽象层次很难一致。另外由于行为边界很难恰当,导致对象之间数据访问关系也比较复杂,这种建模结果被称之为充血模型。

image

引入DCI后,DDD四层架构模式中的Domain层变薄了,以前Domain层对应DCI中的三层,而现在:

  • Domain层只保留了DCI中的Data层和Interaction层,我们在实践中通常将这两层使用目录隔离,即通过两个目录object和role来分离层Data和Interaction。

    image

  • DCI中的Context层从Domain层上移变成Context层。

因此,DDD分层架构模式就变成了五层,如下图所示:

image

每一层用更接地气的方式解释如下:

  • User Interface是用户接口层,主要用于处理用户发送的Restful请求和解析用户输入的配置文件等,并将信息传递给Application层的接口。
  • Application层是应用层,负责多进程管理及调度、多线程管理及调度、多协程调度和维护业务实例的状态模型。当调度层收到用户接口层的请求后,委托Context层与本次业务相关的上下文进行处理。
  • Context是环境层,以上下文为单位,将Domain层的领域对象cast成合适的role,让role交互起来完成业务逻辑。
  • Domain层是领域层,定义领域模型,不仅包括领域对象及其之间关系的建模,还包括对象的角色role的显式建模。
  • Infrastructure层是基础实施层,为其他层提供通用的技术能力:业务平台,编程框架,持久化机制,消息机制,第三方库的封装,通用算法,等等。

六边形架构

有一种方法可以改进分层架构,即依赖倒置原则(Dependency Inversion Principle, DIP),它通过改变不同层之间的依赖关系达到改进目的。

1
2
3
依赖倒置原则由Robert C. Martin提出,正式定义为:
高层模块不应该依赖于底层模块,两者都应该依赖于抽象。
抽象不应该依赖于细节,细节应该依赖于抽象。

根据该定义,DDD分层架构中的低层组件应该依赖于高层组件提供的接口,即无论高层还是低层都依赖于抽象,整个分层架构好像被推平了。如果我们把分层架构推平,再向其中加入一些对称性,就会出现一种具有对称性特征的架构风格,即六边形架构。六边形架构是Alistair Cockburn在2005年提出的,在这种架构中,不同的客户通过“平等”的方式与系统交互。需要新的客户吗?不是问题。只需要添加一个新的适配器将客户输入转化成能被系统API所理解的参数就行。同时,对于每种特定的输出,都有一个新建的适配器负责完成相应的转化功能。

六边形架构又称端口适配器架构,实际上也是一种分层架构,只不过由上下或者左右变成了内部与外部。其核心理念就是应用通过端口与外部进行交互的。核心的业务逻辑(领域模型)与外部资源(数据库等资源)完全隔离,仅通过适配器进行交互,解决了业务逻辑与用户数据交错的问题,很好的实现了前后端分离。如下图所示:

image

六边形每条不同的边代表了不同类型的端口,端口要么处理输入,要么处理输出。对于每种外界类型,都有一个适配器与之对应,外界通过应用层API与内部进行交互。上图中有3个客户请求均抵达相同的输入端口(适配器A、B和C),另一个客户请求使用了适配器D。假设前3个请求使用了HTTP协议(浏览器、REST和SOAP等),而后一个请求使用了AMQP协议(比如RabbitMQ)。端口并没有明确的定义,它是一个非常灵活的概念。无论采用哪种方式对端口进行划分,当客户请求到达时,都应该有相应的适配器对输入进行转化,然后端口将调用应用程序的某个操作或者向应用程序发送一个事件,控制权由此交给内部区域。

六边形架构分为了三层:端口适配器、应用层与领域层。而端口又可以分为输入端口和输出端口,每一层用更接地气的方式解释如下:

  • 输入端口:用于系统提供服务时暴露API接口,接受外部客户系统的输入,并客户系统的输入转化为程序内部所能理解的输入。系统作为服务提供者是对外的接入层可以看成是输入端口。

  • 输出端口:为系统获取外部服务提供支持,如获取持久化状态、对结果进行持久化,或者发布领域状态的变更通知(如领域事件)。系统作为服务的消费者获取服务是对外的接口(数据库、缓存、消息队列、RPC调用)等都可以看成是输出端口。

  • 应用层:定义系统可以完成的工作,很薄的一层。它并不处理业务逻辑通过协调领域对象或领域服务完成业务逻辑,并通过输入端口输出结果。也可以在这一层进行事物管理。

  • 领域层:负责表示业务概念、规则与状态,属于业务的核心。

image

1
应用层与领域层的不变性可以保证核心领域不受外部的干扰,而端口的可替换性可以很方便的对接不用的外部系统。

image

参考文献