领域驱动设计(DDD)的学习与实践

领域驱动设计(DDD)的学习与实践

前言:在JAVA编码过程中,一般使用MVC架构来实现业务、视图和持久化层的解耦,但MVC没有提到对于业务内部的治理,随着功能增多,业务代码就会变得混乱、难以维护。

1. 摘要

领域驱动设计(Domain-Driven Design)简称 DDD,是一套综合软件系统分析和设计的面向对象建模方法。相比于面向对象设计(OO),DDD 是一种更加注重业务边界的设计方法,OO 更加注重抽象,从差异中寻找共同点,然后进行抽象,这是两种不同的思维方式。可以简单的理解为 DDD 是一种业务+解耦的设计实现,DDD 的业务边界思维能够很好的支持微服务拆分,与近些年提出的微服务理论不谋而合 。

2. 架构的演变

说到架构,就必须提一下MVC,MVC最初提出于GUI开发,后来在WEB得到发展并广泛传播。


MVC将应用抽象为三层

  • 视图(View):用户界面
  • 控制器(Controller):业务逻辑
  • 模型(Model):数据保存

并定义了运作时的流程:

  • View 传送指令到 Controller
  • Controller 完成业务逻辑后,要求 Model 改变状态
  • Model 将新的数据发送到 View,用户得到反馈

PS:MVC模型在不同的语言和领域有不用的应用,出现了非常多的版本。

2.1 前端的架构演变

对于前端,实际工作中,一般出现了下述变化:

  • 用户可以向 View 发送指令(DOM 事件),再由 View 直接要求 Model 改变状态。
  • 用户也可以直接向 Controller 发送指令,再由 Controller 发送给 View。
  • Controller 非常薄,只起到路由的作用,而 View 非常厚,业务逻辑都部署在 View。

此时期的代表:JavaScript使用了MVC架构。

可以看到MVC降低了耦合度,但没有限制数据流向,Model和View之间可通信。为了规范化数据流向,演变出MVP架构:

在MVP架构中:

  • 各部分之间的通信,都是双向的。
  • View 与 Model 不发生联系,都通过 Presenter 传递。
  • View 非常薄,不部署任何业务逻辑,称为"被动视图"(Passive View),即没有任何主动性,而 Presenter非常厚,所有逻辑都部署在那里。

此时期的代表:Android使用了MVP架构。

此时所有的数据传递和业务逻辑都包含在Presenter中,操作起来非常麻烦,于是又出现了MVVM架构:

  • MVVM 模式将 Presenter 改名为 ViewModel,基本上与 MVP 模式完全一致。
  • 唯一的区别是,它采用双向绑定(data-binding):View的变动,自动反映在 ViewModel,反之亦然。

此时期的代表:Angular、Vue使用了MVVM架构。

2.2 后端结构演变

后端常说的MVC更像是MVP模式,只是在层级上按照MVC进行了划分:

可以看到MVC存在如下缺陷:

  1. 是一种面向过程的编码,业务需要怎么组织,服务层中就怎么写逻辑,是一种事务脚本的逻辑;
  2. 违反单一职责原则,所有与组件的交互都在服务层中进行,逻辑复杂,与业务无关的改变会影响服务层中的业务代码;
  3. 违反开放封闭原则(对扩展开放、对修改封闭),服务层中的代码依赖太多的组件,业务功能需要扩展时需要修改核心代码。
  4. 违反依赖反转原则(面向接口依赖,而不要依赖实现类),Controller 虽然是依赖 service 接口,但是 service 内部的代码基本没有体现面向接口编程。
  5. service 中依赖的组件太多,无法单独进行测试,可测试性差。
  6. 业务逻辑与数据存储相互依赖,基本无法复用,可扩展性差。

之后有人提出了DDD思想。

3. DDD的优势与缺陷

优势:

  • 让代码的作用更加明确,降低项目迭代难度;
  • 业务隔离,系统迭代时减少影响范围,降低测试难度;
  • 方便系统进行升级和改动。

缺陷:

  • DDD 模式弥补了以上所述的 MVC 模式的缺陷,但是也将增加大量的类。当业务足够简单并且没有频繁改动时,没有使用领域驱动进行设计的必要,采用 MVC 分层架构进行快速开发也是不错的选择。

4. 失血、贫血、充血和胀血模型

失血模型:实体类中仅包含get、set方法,所有业务逻辑完全由 service 层完成,没有 dao,service直接操作数据库。
贫血模型:除了包含get、set方法外,还包含原子服务。
充血模型:大部分业务和实体放在一起,service只有少量业务,一个实体根据业务划分为多个实体,每个实体有自己相应的业务实现。
胀血模型:取消了 service 层,在实体中封装业务。

失血模型和贫血模型实体中不包括业务,实体无法体现出业务。一般 MVC 架构采用贫血模型,DDD 模式采用充血模型。

5. DDD基础概念

  1. 值对象

当一个对象用于对事物进行描述而没有唯一标识时,那么它被称作值对象(Value Object)。因为在领域中并不是任何时候一个事物都需要有一个唯一的标识,也就是说我们并不关心具体是哪个事物,只关心这个事物是什么。

  1. 领域服务

一些重要的领域行为或操作,它们不太适合建模为实体对象或者值对象,它们本质上只是一些操作,并不是具体的事物,另一方面这些操作往往又会涉及到多个领域对象的操作,它们只负责来协调这些领域对象完成操作而已,那么我们可以归类它们为领域服务。

  1. 防腐层

一个上下文通过一些适配和转换与另一个上下文交互,这些适配称为防腐层(Anti-corruption layer)。在不共享相同领域模型的不同子系统之间实施防腐层,此层转换一个子系统向另一个子系统发出的请求。 使用反腐层可确保应用程序的设计不受限于对外部子系统的依赖。

6. 领域模型设计步骤

  1. 根据需求建立一个初步的领域模型,识别出一些明显的领域概念以及它们的关联,关联可以暂时没有方向但需要有(1:1,1:n,m:n)这些关系;可以用文字精确的没有歧义的描述出每个领域概念的涵义以及包含的主要信息;

  2. 分析主要的软件应用程序功能,识别出主要的应用层的类;这样有助于及早发现哪些是应用层的职责,哪些是领域层的职责;

  3. 进一步分析领域模型,识别出哪些是实体,哪些是值对象,哪些是领域服务;

  4. 分析关联,通过对业务的更深入分析以及各种软件设计原则及性能方面的权衡,明确关联的方向或者去掉一些不需要的关联;

  5. 找出聚合边界及聚合根,这是一件很有难度的事情;因为你在分析的过程中往往会碰到很多模棱两可的难以清晰判断的选择问题,所以,需要我们平时一些分析经验的积累才能找出正确的聚合根;

  6. 为聚合根配备仓储,一般情况下是为一个聚合分配一个仓储,此时只要设计好仓储的接口即可;

  7. 走查场景,确定我们设计的领域模型能够有效地解决业务需求;

  8. 考虑如何创建领域实体或值对象,是通过工厂还是直接通过构造函数;

  9. 停下来重构模型。寻找模型中觉得有些疑问或者是蹩脚的地方,比如思考一些对象应该通过关联导航得到还是应该从仓储获取?聚合设计的是否正确?考虑模型的性能怎样,等等;

  10. 领域建模是一个不断重构,持续完善模型的过程,大家会在讨论中将变化的部分反映到模型中,从而是模型不断细化并朝正确的方向走。

7. 资料

找到了阿里关于DDD的分享,其发布在【大淘宝技术】的微信公众号上:

  1. DDD系列 第一讲:Domain Primitive
  2. DDD系列 第二讲:应用架构
  3. DDD系列 第三讲:Repository模式
  4. DDD系列 第四讲:领域层设计规范
  5. DDD系列 第五讲:如何避免写流水账代码

项目结构:

包结构:

- interfaces:表现层,提供给外部用户访问
    - facade:提供粗粒度的业务实现接口,将用户请求分解为多个业务操作
        - rest:REST API
        - grpc:gRPC API
- application:应用层
    - dto:存放dto数据传输对象
        - request
        - response
    - assembler:进行DTO对象和领域对象的相互转换和数据交换
    - service:对领域服务或者外部应用服务进行封装、编排和组合,对外提供粗粒度服务
- domain:领域层
    - aggregate:各个领域聚合目录,按业务名称命名,如支付功能、用户管理
        - entity:领域对象
        - valueobject:值对象
        - event:领域事件
        - service:领域服务,进行领域业务实现
        - repository:领域仓储,进行领域数据的查询和持久化
- infrastructure:基础层
    - config:存放配置相关代码
    - client:存放跨服务接口
    - common:存放消息、数据库、缓存、文件、总线、网关、公用的常量、枚举等
        - enums:存放枚举
        - cache:缓存相关功能
    - util:存放开发框架、第三方类库、通用算法等基础代码

8. 实践

在第二讲时,明确了一个好的架构应该是什么样的:

  • 独立于框架: 架构不应该依赖某个外部的库或框架,不应该被框架的结构所束缚。
  • 独立于UI: 前台展示的样式可能会随时发生变化(今天可能是网页、明天可能变成console、后天是独立app),但是底层架构不应该随之而变化。
  • 独立于底层数据源: 无论今天你用MySQL、Oracle还是MongoDB、CouchDB,甚至使用文件系统,软件架构不应该因为不同的底层数据储存方式而产生巨大改变。
  • 独立于外部依赖: 无论外部依赖如何变更、升级,业务的核心逻辑不应该随之而大幅变化。
  • 可测试: 无论外部依赖了什么数据库、硬件、UI或者服务,业务的逻辑应该都能够快速被验证正确性。

这也是我们的目标,要做到上述要求,需要抓住重点的改造对象:

将隐性的概念显性化

在处理数据对象时,将原本使用String、Integer等表示业务概念的属性提出来,作为单独的对象使用,且其应具备自校验能力。

设备中的零件原来使用String model来表示这个设备的零件型号,现在使用PartModel model将零件型号的概念提出,作为零件的属性存在。同时应能自校验,零件的型号范围是有限的,不能允许不存在的型号出现,以前将校验逻辑放在了service,现在可以在初始化对象时校验。因为要使用到数据查询功能,可以使用一个Factory来创建Part。

将隐性的上下文显性化

若一个业务逻辑由多个业务概念共同完成,需要将其中使用字符串或数字常量来表示的概念提出。

设备的操作类型原来使用String来描述exec(Integer deviceId, String operation, Integer uid),现在将设备操作和设备组合成为一个新的数据对象exec(DeviceOperation op, UserId uid)

封装多对象行为

如果一个Value Object包含行为,那么应在这个Value Object中添加对应行为的实现,如果行为需要其他的Value Object作为参数,则将涉及到的Value Object添加到它的属性,那么这个Value Object就变成了Domain Primitive。

传感器采集到的各项参数,需要从原单位转换成目标单位,原来为convert(Param param, Unit source, Unit target),添加一个UnitConverter类,将原始单位和目标单位作为类的属性,然后给该类添加一个convert(Param param)方法来实现。

抽象数据存储层

在service和dao之间,增加一个repository层,repository关注业务操作,将数据库操作细节隐藏起来。

在从仓库取货物时,之前需要在service层判断库存等信息,然后再调用DAO的remove进行删除。现在我们把这些细节放在repository中,service只需要调用repository的take(Material mat, Warehouse wh)。

抽象第三方服务

将应用中使用到的第三方服务从业务代码中抽离,不在service中直接调用。

我们的应用使用到了企业微信的接口,之前使用企业微信的sdk在service中直接调用,现在改为service依赖externalWxService接口,然后在它的实现类中实现企业微信API的调用。

抽象中间件

中间件的操作应封装在对应的template中,而不是在service中直接操作中间件。

中间件已经使用了spring-boot-start-xxx的template,满足目标。

封装业务逻辑

用Domain Primitive封装无状态的计算逻辑,用Eneity封装单对象的有状态的行为。

无状态的计算逻辑,如:前文中的UnitConverter。有状态的行为,如:从A仓库转出到B仓库,创建一个Warehouse的entity,entity包含需要转移的物料、转货单、通知人等,然后将转入转出逻辑写在这个entity中。

模型对象代码规范

在每一个层级使用相对应的模型对象,使用MapStruct框架进行互相转换

这里列出我们使用的模型对象:

数据模型 目的 代码层级 命名规范 字段名称标准 字段类型标准 转换器
VO 与视图交换数据 Infrastructure XxxReq、XxxRsp 和前端商定 和前端商定 VOtransfer
DTO 适配业务场景 Application XxxCommand、XxxQuery、XxxEvent 和调用方商定 和调用方商定 DTOAssembler、VOtransfer
Entity 业务逻辑 Domain Xxx 业务语言 尽量是有业务含义的类型,比如DP DataConverter、DTOAssembler
DO 数据库表映射 Infrastructure XxxDO 数据库表字段名 数据库字段类型 DataConverter

Repository代码规范

接口使用 find、save、remove 等代表业务含义的词,而不是insert、select、update、delete这些数据库层面的词。出入参应使用Entity而不是Data Object。

接口使用 find、save、remove 等代表业务含义的词,而不是insert、select、update、delete这些数据库层面的词。出入参应使用Entity而不是Data Object。我们没有对Repository进行变更追踪,因为目前我们的逻辑处理够用,数据库也没遇到性能瓶颈。

实体类(Entity)

创建即一致,尽量避免public setter,通过聚合根保证主子实体的一致性,任何实体的行为只能直接影响到本实体(和其子实体)

实体类是DDD架构的核心,它需要创建即一致,所以我们在实体类的构造函数中添加了校验;使用行为化的命令替代getter、sette;不可以强依赖其他聚合根实体或领域服务;任何实体只能改变自身状态,不能改变其他实体状态。

领域服务

单对象策略型、跨对象事务型、通用组件型

单个对象变更时通过Double Dispatch来反转调用领域服务的方法,当一个行为会直接修改多个实体时直接调用服务的对应方法,当需要对不同类型实体执行相同的操作时考虑通用组件。

策略对象

一个Policy是一个无状态的单例对象,通常需要至少2个方法:canApply 和 一个业务方法。其中,canApply方法用来判断一个Policy是否适用于当前的上下文,如果适用则调用方会去触发业务方法。

在操作设备时,先使用Policy判断设备是否能这样操作,再返回操作后设备的状态,而不是直接修改设备的状态。

领域事件

当一个行为会对其他对象产生影响时,使用事件处理接下来的影响。

在订单遭到退回时,我们使用事件机制来处理后续的通知。

Interface接口层

Interface层接口统一返回Result,捕捉所有异常。
一个Interface层的类应该是“小而美”的,应该是面向“一个单一的业务”或“一类同样需求的业务”,需要尽量避免用同一个类承接不同类型业务的需求。

通过SpringMVC的全局异常处理捕获所有异常,最后都返回Result。每一类业务对应一个controller。

Application层

应用服务负责业务流程的编排,但本身不负责任何业务逻辑
DTO Assembler:负责将内部领域模型转化为可对外的DTO
Command、Query、Event对象:作为ApplicationService的入参
返回的DTO:作为ApplicationService的出参
ApplicationService的接口入参只能是一个Command、Query或Event对象,CQE对象需要能代表当前方法的语意。唯一可以的例外是根据单一ID查询的情况,可以省略掉一个Query对象的创建
不能复用CQE

  • Command指令:指调用方明确想让系统操作的指令,其预期是对一个系统有影响,也就是写操作。通常来讲指令需要有一个明确的返回值(如同步的操作结果,或异步的指令已经被接受)。
  • Query查询:指调用方明确想查询的东西,包括查询参数、过滤、分页等条件,其预期是对一个系统的数据完全不影响的,也就是只读操作。
  • Event事件:指一件已经发生过的既有事实,需要系统根据这个事实作出改变或者响应的,通常事件处理都会有一定的写操作。事件处理器不会有返回值。这里需要注意一下的是,Application层的Event概念和Domain层的DomainEvent是类似的概念,但不一定是同一回事,这里的Event更多是外部一种通知机制而已。

Anti-Corruption Layer防腐层

对于依赖的外部对象,我们抽取出所需要的字段,生成一个内部所需的VO或DTO类
构建一个新的Facade,在Facade中封装调用链路,将外部类转化为内部类
针对外部系统调用,同样的用Facade方法封装外部调用链路

业务流程设计模式

在代码中的依赖是比较明确的:如果你是下游,上游对你无感知,则只能走事件驱动;如果上游必须要对你有感知,则可以走指令驱动。反过来,如果你是上游,需要对下游强依赖,则是指令驱动;如果下游是谁无所谓,则可以走事件驱动。
第二种方法是根据业务场景找出其中的“负责人”。比如,如果业务需要通知卖家,下单系统的单一职责不应该为消息通知负责,但订单管理系统需要根据订单状态的推进主动触发消息,所以是这个功能的负责人。
在一个复杂业务流程里,通常两个模式都要有,但也很容易设计错误。如果出现依赖关系很奇怪,或者代码里调用链路/负责人梳理不清楚的情况,可以尝试转换一下模式,可能会好很多。

9. 模型对象一览

学习DDD之前用的模型对象:

  • POJO(Plain Ordinary Java Object): 简单的Java对象。表示只有属性和getter、setter方法的对象。没有特殊的业务含义。
  • PO(Persistant Object): 持久化对象。用于表示数据库表。是一种特殊用途的POJO。
  • DO(Data Object): 数据对象。一般用于表示数据库表,对应逻辑模型和物理模型。功能同PO。
  • VO(View Object): 视图对象。用于接收视图层的数据。是一种特殊用途的POJO。
  • DTO(Data Transfer Object): 数据传输对象。用于在controller、service、manager之间传递数据。是一种特殊用途的POJO。
  • BO(Business Object): 业务对象。一般用于表示业务单据,对应业务概念模型。除了属性、getter、setter外,还包含业务逻辑。

DDD中涉及的模型对象:

  • Entity: 实体类。用于表示一个含状态的实体。功能同BO。
  • VO(Value Object): 值对象。用于描述领域中的某个概念。是一种特殊用途的POJO。为了避免和View Object冲突,一般不使用。
  • DO(Domain Object): 领域对象。功能同Entity。一般使用Entity,不使用这个称呼。
  • DP(Domain Primitive): 领域原语。是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object。
Licensed under CC BY-NC-SA 4.0