领域驱动设计初识

Tommy Cheese | Jul 26, 2024 min read

领域驱动设计概述

领域驱动设计(DDD, Domain-Driven Design)是一种模型驱动设计的方法,它通过领域模型捕捉领域知识,使用领域模型构造更易维护的软件.

DDD的设计过程分为战略设计与战术设计,其中战略设计面向领域、子域以及限界上下文的设计,而战术设计面向实体、值对象、领域事件等设计,关系如下:

img

战略设计阶段相关概念

领域

领域是系统要解决问题的领域,如商品信息管理就可以是系统要解决问题的领域.

子域

根据使用语言的不同可以将领域划分为不同的子域:

  • 核心域:决定产品核心竞争力的子域,是最为重要、业务最核心、个性的部分;
  • 通用域:被多个子域使用的通用功能子域,比如用到的通用系统,例如认证、权限等等,这类应用没有企业特点限制,不需要做太多的定制化;
  • 支撑域:不包含核心功能与通用功能的子域,具有企业特性,但不具有通用性,例如数据代码类的数据字典等系统;

限界上下文

限界上下文,即限定使用不同模型来解决不同问题所产生的不同区域.

限界上下文是一个子域或者多个子域的集合要保证一个限界上下文须支持一个完整的业务流程保证这个业务流程所涉及的领域都在一个限界上下文中.限界上下文是微服务拆分的依据,即每个限界上下文对应一个微服务.

那么为什么要进行限界上下文呢?这是因为上下文的存在导致编码十分困难,举个例子说明:

例如在招聘域我们可能会使用“平台”来表明来自于哪个学校、机构;在跳水域我们使用“平台”来表明选手从那个跳台跳水;而在铁路交通运输领域,我们可能会使用“平台”来描述铁路站台……在某一天的某一个机会,跳水选手、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领域建模一般步骤如下:

  1. 根据需求划分出初步的子域和限界上下文,以及上下文之间的关系;
  2. 进一步分析每个上下文内部,识别出哪些是实体,哪些是值对象,并确定实体需要使用哪种代码形态:失血、贫血、充血、胀血;
  3. 对实体、值对象进行关联和聚合,划分出聚合的范畴和聚合根;
  4. 为聚合根设计资源库repo,并思考实体或值对象的创建方式;
  5. 在工程中实践领域模型,并在实践中检验模型的合理性,倒推模型中不足的地方并重构.

这就是DDD采用两阶段设计原则——先进行战略设计、随后进行战术设计。

在实践中,建议实体采用失血模型(实体方法只包括setter/getter)或者贫血模型(包含不涉及数据库操作的简单逻辑,如属性合法性校验);

实际上这是DDD建模方式之一,是自顶向下的设计方法,还存在一种方式是使用自下而上的方法,即先确定领域模型——实体、值对象等,再确定子域、限界上下文…

(本节完)

comments powered by Disqus