DDD落地实践

692 阅读9分钟

前言

DDD(领域驱动)设计模式,目前更多的是作为一种思想指导,就了解的来看,并没有标准的落地方案,只能说,大部分人在一些落地上形成了共识,同时,对于实现设计来说,先有思想再有实现比所谓的代码分层更加重要,并且,基于java现有框架的实现情况,一些理论的落地又无法在现有技术框架下实现。

所以写这篇文章的目的只是在于我所落地的实现中,讨论其合理与不合理性,共同进行优化的思路,望有闲看到此文章者,能不吝赐教,亦或共同讨论细节实现。

注:非复杂业务使用DDD设计往往会陷入设计漩涡

核心思想

目前接触的落地方案和相关技术人员来看,大部分会将系统分层和数据结构作为重点关注,在分析设计时往往忽略核心领域模型的概念,导致在使用上偏MVC和面向数据库开发,导致过程中的各种水土不服,甚至看到有的开发使用DDD的分层去完成MVC的实现,所以个人认为,初次接触DDD的时候,应该同步的不是架构分层,而是思想统一。

领域模型

纵观所有的领域模型架构,都离不开六角模型和洋葱架构,六角模型如下:

image.png

理论的核心有点像谜题就在谜面上,搞懂领域模型可能对于理解DDD设计才是有最大的帮助,这里不谈理论设计,纯粹以目前落地的实现来反向讨论领域模型的设计是否合理。

基础层级

├─src  
   ├─main  
      ├─java
         ├─com
            ├─application  
            ├─domain  
            ├─infrastructure  
            └─web

domain:领域服务层,用于存放领域服务的,通常所有的业务逻辑,应该在这一层内聚

├─domain
   ├─aggregate
   |  └─impl
   ├─event
   └─service
       └─impl

aggregate

1、聚合体:包含聚合根和聚合的对象全部放入在这里

注:聚合一定要是充血对象!聚合一定要是充血对象!聚合一定要是充血对象!

2、仓储类:XXXStorage接口,理论上最好只包含两个方法

classDiagram
XXXStorage <-- XXXStorageImpl
XXXStorage : +void save(Aggregate aggregate)
XXXStorage : +Aggregate load(String id)
class XXXStorageImpl{
+void save(Aggregate aggregate)
+Aggregate load(String id)
}

但是在实际使用中可能更加复杂,这个时候一个聚合根的产生就不能是一个仓储类实现的,这个时候就需要聚合工厂了(此处不讨论)

event

1、领域事件:基本上任何一个领域服务完成后都会触发一个或多个领域事件,这里就是解耦的关键地方,也是事件驱动实现CQRS的关键点,通常有人很难理解的无事件驱动无CQRS就在这里。

service

1、领域服务对象:目前使用领域服务对象的根本原因在于使用spring框架的情况下,IOC导致了领域服务需要依赖一些其他服务,而且对于领域对象的新增和删除,并不能在聚合里面很好的处理(这里在后续的汇总讨论的时候会详细说明),同时,这里也是聚合根实际处理业务的地方

application: 应用层,通常用来组合领域服务的一层,同时CQRS一般也在这一层实现

├─application
   ├─command
   │  └─impl
   └─query
       └─impl

1、command:命令模块,用于CQRS中命令相关的操作

2、query:查询模块,用于CQRS查询相关的操作,在未进行读写分离和事件驱动前,此处的逻辑基本按照最简单的查询设计,甚至可以直接查询数据库,因为查询不带有任何业务逻辑,且幂等。

image.png

注:其实在未使用事件驱动之前,此处的设计不具有完整的意义,但是在设计上对于读写分离做了最基本的处理,且对于初学者来说,此处的命令与查询模式也是理解事件驱动的最好体现,同时需要注意的是,也存在读模型下的领域服务,但是目前没有较好的实践与案例可以比较。

infrastructure:基础设施层,所有与业务无关的实现技术,在这一层实现,同时项目的一些配置也在此

因为基础设施本身的含义较广包含有第三方服务接口、防腐层、配置中心、定时任务、数据持久化、基础工具包等等,所以按照划分上,所有和业务无关的具体实现技术都应该放入到这一层,但是需要划分的就是依赖倒置,基础设施层应该依赖领域服务层,领域服务层不应该依赖基础设施层

image.png

其实这里在实现的时候就会遇到一个问题,比如我们加载聚合对象的时候,因为使用的是聚合工厂类或者storage仓储类,此时对于基础层的依赖是倒置的,但是当我们有的业务校验需要查询数据的时候,又在领域服务里面进行注入,变成了直接依赖,有一种做法就是,将需要依赖的服务通过方法在应用层传入,以此来避免污染领域层,但是基于效率和定义上来说DomainService本身也可以依赖外部应用,真正需要完全独立的只定义为聚合和聚合根。

其实做了这么多的划分与要求,最理想的情况下,应该是领域服务层聚合了所有的业务逻辑,同时也是变化最频繁的地方,而当我们的其他层发生变化的时候,我们的领域服务可以直接复用--核心业务未变,同时基于划分的纯粹,我们就可以基于业务对业务核心做单元测试,而不会依赖其他服务

web:对外提供的接口层,目前定义的外部的消息消费、接口服务等对外服务在此层聚合

└─web
   ├─controller
   ├─listener
   ├─api
   └─dto
      ├─request
      └─response

1.controller:提供给前端服务的接口,其中和api层的区别一般定义为一个是给前端服务的,一个是给其他后端服务的。

2.listener:事件消费层,用于对接事件消费的实现。

3.dto:作为数据传输层,也是作为ACL的一个最简单的实现,理论上在每一层都应该有自己的DTO用于防腐操作,但是在实际应用的情况下,业务前期不稳定的时候,还是不要有太多的DTO转化,那么什么时候需要呢?就理解为,当你的下层服务在未调整的情况下,你的当前服务层的输入与输出不一样的时候,你就需要在当前层新增DTO进行防腐处理。

汇总讨论

聚合不能很好的处理新增/删除

  1. 聚合表示的是唯一确定的业务对象,一般用id作为唯一对象(有些设计里面的对应实体使用的是数据库自增字段)
  2. 聚合的删除(非逻辑删除的设计下)并不能在业务行为上有体现

基于以上两个说明,我们可以考虑,新增情况下,并不能唯一确定一个业务对象:

1.它没有唯一ID标识
2.它的所有行为都是最基本的CRUD操作
3.它不能很明确的定义为业务操作

因此,在实践上面,我进行过两种实现,第一种:

image.png

在领域服务层直接进行CRUD操作,此处满足了业务逻辑的不外泄,但是在实现上总是感觉偏移了领域服务的本质。

第二种:

image.png

关键点就在这里,虽然也有CRUD的部分,但是不管业务是否成功,我们先为业务对象创建了唯一ID,在通过业务行为丰富它的属性与值对象,整个实现上更加符合领域服务的本质。

同样对于删除操作,我们也需要考虑其合理性与实际意义,当我们的设计不是物理删除的时候,删除的逻辑就包含了实体、值对象、属性的删除,而在定义的仓储类里面并不能在只有持久化的逻辑下处理删除操作,因此实现上比新建还直接:

image.png

其实这个原因也在一些讨论和我自身的实践中有过体会,简单的CRUD功能使用负责的DDD设计本身就是反设计模式,那我们应该怎么办呢??--混合架构

其实spring boot本身就给过一些建议,比如在jpa框架下,你不需要写CRUD功能,本身就带有REST的功能自动帮你实现CRUD,但是在实践过程中来说,你不能期望你团队内的小伙伴都能很好的理解混合架构中的业务划分,所以在工程管理上来说,靠谱的做法还是先让所有人在单一架构下进行开发,等团队的理解和规范都到了一定程度以后,适时的引入混合架构的概念。

结语

其实所有的设计模式都是为了提效产生的,很多人都在质疑DDD的存在完全是浪费时间,但是实际的原因往往是团队中存在口号者和半吊子,读了一些文章,听了一些概念就指点江山,实际上在分析业务和实现的时候并不能给出很好的建议,而且经常又来个本末倒置的先管数据库设计。

数据结构本身是程序设计中很重要的一部分,但是在以往的惯性思维中,就会倒置在实践领域服务设计的时候,又变成了面向数据库开发,如果你多读一些书和自己实践一下就会发现,数据库的设计在领域服务里面是最外层的,就和你选型技术的时候是选mysql存储还是mongodb存储一样,并不应该影响我的业务设计。

最后,希望各位大佬能够参与讨论分析,毕竟,互联网的本质是智慧共享,但是同样希望各位在进行指点的时候是有实际落地方案与优缺点分析的,毕竟搬书谁不会呢?