领域驱动设计概述
领域驱动设计(DDD, Domain-Driven Design)是一种模型驱动设计的方法,它通过领域模型捕捉领域知识,使用领域模型构造更易维护的软件.
DDD的设计过程分为战略设计与战术设计,其中战略设计面向领域、子域以及限界上下文的设计,而战术设计面向实体、值对象、领域事件等设计,关系如下:
战略设计阶段相关概念
领域
领域是系统要解决问题的领域,如商品信息管理就可以是系统要解决问题的领域.
子域
根据使用语言的不同可以将领域划分为不同的子域:
- 核心域:决定产品核心竞争力的子域,是最为重要、业务最核心、个性的部分;
- 通用域:被多个子域使用的通用功能子域,比如用到的通用系统,例如认证、权限等等,这类应用没有企业特点限制,不需要做太多的定制化;
- 支撑域:不包含核心功能与通用功能的子域,具有企业特性,但不具有通用性,例如数据代码类的数据字典等系统;
限界上下文
限界上下文,即限定使用不同模型来解决不同问题所产生的不同区域.
限界上下文是一个子域或者多个子域的集合,要保证一个限界上下文须支持一个完整的业务流程,保证这个业务流程所涉及的领域都在一个限界上下文中.限界上下文是微服务拆分的依据,即每个限界上下文对应一个微服务.
那么为什么要进行限界上下文呢?这是因为上下文的存在导致编码十分困难,举个例子说明:
例如在招聘域我们可能会使用“平台”来表明来自于哪个学校、机构;在跳水域我们使用“平台”来表明选手从那个跳台跳水;而在铁路交通运输领域,我们可能会使用“平台”来描述铁路站台……在某一天的某一个机会,跳水选手、HR和乘务员聚到了一起,并且他们都不了解对方的身份,如果此时HR问跳水选手说你来自哪个平台……
看到了吧,同样是使用“平台”这个词语,但三个人在没有约定的情况下可能对于词语的理解不尽相同,解决这个问题的办法就是把跳水域、招聘域和交通域隔离,分别定义域内的术语,在一个域内讨论就可以最大可能的避免歧义,这就是约定.这也是为什么最好把业务所涉及的领域定义在一个限界上下文中:减少歧义.
在软件系统的设计中同样会遇到类似的问题,如果你负责设计一个足够大的计算机软件产品,产品涉及到跳水服务、招聘服务与乘车服务,系统没有对服务做任何切分,你在跳水有关业务逻辑中使用platform数据结构描述跳台、在招聘业务中使用platform代表平台、在交通业务中使用platform代表站台,当这三个业务不可避免的交汇到一起时,灾难就发生了,面对满屏幕不同语义但几乎同名的变量,我们能怎么办?
显然,限定某一个领域术语的细节,我们就可以在领域中畅通无阻的使用这个术语了,这就是限界上下文的重要作用.
战术设计阶段相关概念
值对象和实体
首先了解一下值对象和实体.
- 值对象是通过属性值来识别的对象,即如果两个值对象的内部值都是相同的,那么我们就认为这两个值对象是相同的.显然,值对象的属性不可变.
- 实体是拥有唯一标识和状态,且具有生命周期的业务对象.实体的属性是可变的,如果两个实体的属性是完全相同的,我们也不认为他们是相同的实体,只有他们两个的标识(如ID)是相同的才会被认为是相同的实体.
举例区分值对象和实体:
假如现在我们有一个白色值对象Color white{R:255, G:255, B:255}和一个轮胎实体tire{Air:,Size:}:
- 可变性:白色中的每一个属性都是不可变的,因为一旦变化此对象就不再代表”白色“;而轮胎中的气压、尺寸等值可以改变,因为了即便这些参数发生了变化它仍然是轮胎;
- 可比较性:正是由于值对象的值不可变性赋予了其相等的规则,两个白色对象的值一定会是相同的,因此如果两个值对象的内部值都是相同的,那么我们就认为这两个值对象是相同的,实体则由于其属性的可变性而丧失了这个特点.
对于实体而言,其形态一般有四种:
- 失血模型:模型仅仅包含数据的定义和getter/setter方法,业务逻辑和应用逻辑都放到服务层中.这种类在Java中叫POJO.
- 贫血模型:贫血模型中包含了一些业务逻辑,但不包含依赖持久层的业务逻辑.这部分依赖于持久层的业务逻辑将会放到服务层中.
- 充血模型:充血模型中包含了所有的业务逻辑,包括依赖于持久层的业务逻辑.
- 胀血模型:胀血模型就是把和业务逻辑不相关的其他应用逻辑(如授权、事务等)全部都放到领域模型中.
资源库Repo
Repo是针对Entity设计的存储操作,Repo中的操作应该尽可能低级、命名要简短,并且不能定义太多.Java中的MyBatis Mapper可以看作是Repo的一种实现.
聚合与聚合根
聚合是一种更大范围的封装,把一组有相同生命周期、在业务上不可分隔的实体和值对象放在一起考虑,只有聚合根可以对外暴露引用,聚合也是一种内聚性的表现.聚合根之间也可相互调用,聚合根抽象出来一般名字为名词.
聚合根通过以下几种手段实现封装:
- 必须通过操作聚合根来实现操作整个聚合,外部操作不允许直接操作聚合中的元素.比如紫色(外观)+轮胎+钢架+…=汽车,我们开车时不能也不会去单独操作轮胎、方向盘、外观…,相反,我们通过操作汽车这个聚合根来间接操作其他实体/值对象.
- 聚合定义了一组边界,边界内所有的组件必须对业务逻辑有效;
- 必须在一个原子性的事务中操作聚合,否则可能会出现异常,也就是说聚合是操作的单元,从仓库取出聚合、操作完成后放回是一个原子性的操作;如何理解原子性?加入汽车聚合根有轮子实体、钢/铝/碳架实体,你不能拆掉汽车的一个轮子检修完后不装上.
领域事件Domain Events
当实体的属性发生变化时就会产生领域事件,领域事件是领域专家认为重要的事情.领域事件是发生在领域中且值得注意的事件.而领域事件通常意味着领域对象状态的改变.领域事件在系统中起到了传递消息、触发其他动作的作用,是解耦领域模型的重要手段之一.我们往往利用消息队列来传递领域事件,这样所有订阅此消息的子域都会进行自己内部的响应操作.
消息总线也是一种实现方法👋.
如轮胎实体气压发生变化时,会释放领域leaked漏气事件,此漏气事件传递了“轮胎漏气”这个消息,从而引发一系列其他的响应,如动能系统变化、方向盘手感变化等等,领域事件通常会使用过去式命名来说明事件已经发生,不可撤销.
领域服务Domain Service
有些领域中的动作看上去并不属于任何对象.它们代表了领域中的一个重要的行为,不能忽略它们或者简单地把它们合并到某个实体或者值对象中.当这样的行为从领域中被识别出来时,推荐的实践方式是将它声明成一个服务,这个服务就是领域服务.
领域服务不是微服务中的“服务”概念.服务和聚合根概念有些相近,他们都可以操作多个实体,但是二者理念和作用不同,聚合根是对实体的组合,而服务是用于同步多个实体的状态,比如将某个item实体加入到list实体(比如把mail投递到inbox).所以Servie一般抽象出来都是动词.
DDD领域建模(设计领域模型)
DDD领域建模一般步骤如下:
- 根据需求划分出初步的子域和限界上下文,以及上下文之间的关系;
- 进一步分析每个上下文内部,识别出哪些是实体,哪些是值对象,并确定实体需要使用哪种代码形态:失血、贫血、充血、胀血;
- 对实体、值对象进行关联和聚合,划分出聚合的范畴和聚合根;
- 为聚合根设计资源库repo,并思考实体或值对象的创建方式;
- 在工程中实践领域模型,并在实践中检验模型的合理性,倒推模型中不足的地方并重构.
这就是DDD采用两阶段设计原则——先进行战略设计、随后进行战术设计。
在实践中,建议实体采用失血模型(实体方法只包括setter/getter)或者贫血模型(包含不涉及数据库操作的简单逻辑,如属性合法性校验);
实际上这是DDD建模方式之一,是自顶向下的设计方法,还存在一种方式是使用自下而上的方法,即先确定领域模型——实体、值对象等,再确定子域、限界上下文…
(本节完)