Domain Driven Design浅解
Domain Driven Design
DDD是domain driven design的缩写,中文是领域驱动设计,是由Eric Evans提出的一种架构思想。
中文定义:领域驱动设计是一种处理高度复杂领域的设计思想,它试图通过分离技术实现的复杂性,围绕业务概念构建领域模型,来控制业务的复杂性,解决软件难以理解难以演化的问题。团队应用DDD可以成功的开发复杂的业务软件系统,使系统在增大时仍然保持敏捷。
第一次看这段话谁懂喔(bushi)
个人理解:DDD思想的本质就是为了高内聚,低耦合,通过聚合一个个领域,让业务逻辑和技术实现分离,通过端口-适配器等思想进行依赖倒置解决了业务和数据持久(外部库)的耦合,通过充血模型等方式业务内聚,提升系统的可维护性、可扩展性和可测试性。
三层架构的一些问题
上层对于下层有直接的依赖关系,导致耦合度过高,若数据访问层发生改变,大量业务代码也会跟着变
使用贫血模型和过程化设计,若业务复杂时,业务逻辑、状态会散落到在大量方法中,原本的代码意图会渐渐不明确(内聚低,耦合高)对象只是数据的载体,没有行为。以数据为中心,以数据库ER设计作驱动。分层架构在这种开发模式下,可以理解为只是对数据移动、处理和实现的过程。
解决业务和数据持久的耦合
传统的三层架构中业务层依赖数据访问层
- 从小的说,若数据要做分库分表、表设计发生改变、改字段名等操作,业务层代码要跟着维护。
- 从大的说,若要更换数据库、更换ORM框架,迁移成本巨大。
可以看到业务层其实依赖了两个东西:dao和Java Beans
案例1
这里有一个StudentService,项目架构用的是传统的三层架构
@Service public class StudentService { //这里直接用的jpa private StudentRepository studentRepository; public Long addStudent() { Student student = new Student(); studentRepository.save(student); return student.getId(); } public void doSomeBusiness(Long id) { Optional<Student> student = studentRepository.findById(id); //以下省略很多业务逻辑 } //省略很多业务方法 }
如果我们现在要变动dao层,比如加一层缓存,可以发现,基本上涉及到CRUD的业务方法都要改动
而当你的代码量变得比较大,然后如果在某个地方你忘记了查缓存,或者在某个地方忘记了更新缓存,轻则需要查数据库,重则是缓存和数据库不一致,导致bug
@Service public class StudentService { //这里直接用的jpa private StudentJpaRepository studentJpaRepository; private Cache cache; public Long addStudent() { Student student = new Student(); studentJpaRepository.save(student); cache.put(student.getId(), student); return student.getId(); } public void doSomeBusiness(Long id) { Student student = cache.get(id,Student.class); if(student == null){ studentJpaRepository.findById(id); } //以下省略很多业务逻辑 } //省略很多业务方法 }
可以看得出,我们需要将业务逻辑和数据访问解耦,将他们两者隔开
我们可以使用端口适配器模式或者说ACL防腐层或者说Repository规范来解决这个问题
端口适配器
DDD通过端口-适配器模式的思想解决业务层和数据访问层的耦合
要理解端口和适配器,举个形象的例子:电脑有USB插口,这个插口就是端口,作为电脑我不关心你插的是鼠标还是键盘,我只提供端口。当你插上一个设备时,电脑弹窗提醒你该设备无法识别,需要你下载驱动,这个驱动就是适配器了,通过适配器,我们可以随意更换机械键盘还是薄膜键盘。
从上面的案例来说,我们数据怎么存怎么取,走不走缓存,和业务逻辑无关
我们抽象出一个repository接口,这就是端口-适配器的端口
@Repository public interface StudentRepository { void addStudent(Student student); Student findStudentById(Long id); //省略其他方法 }
然后之前的service业务代码变成这样,这里我们用的是的接口,我不关心具体的实现,我不在乎你用的是mybatis还是jpa,不关心你走没走缓存
@Service public class StudentService { @Autowired private StudentRepository repository; public Long addStudent() { Student student = new Student(); repository.addStudent(student); return student.getId(); } public void doSomeBusiness(Long id) { Student student = repository.findStudentById(id); //以下省略很多业务逻辑 } //省略很多业务方法 }
然后有一个StudentRepositoryProvider类实现repository接口,这个类就是端口-适配器中的适配器
可以看到,适配器关心具体的技术实现,这里使用jpa完成数据访问
public class StudentRepositoryProvider implements StudentRepository { @Autowired private StudentJpaRepository jpaRepository; @Override public void addStudent(Student student) { jpaRepository.save(student); } @Override public Student findStudentById(Long id) { return jpaRepository.findById(id).get(); } }
这样数据访问和业务逻辑就完全分开了,此时如果想加个缓存,业务逻辑的代码可以完全不用动,只需改动provider适配器就行,这里省略改动代码
解决完业务代码和dao耦合的问题,我们再来看业务层依赖的java Bean
案例2
现在dao层的一些操作和改动均不会影响业务代码了,此时我们再着眼于数据
可以发现数据的载体一直都是Student
@Getter @Setter @Entity @Table(name = "students") public class Student { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name;
//省略其他属性 }
改变数据库表的设计,增加或减少字段等操作均会影响Student类,也会直接影响业务代码
从数据载体上来说数据访问层和业余代码还没有分离开,我们用模型对象代码规范来解决这个问题
模型对象代码规范
这里会出现三个模型:DTO、Entity、DO,详细说明请见:阿里技术专家详解DDD系列第三讲
Data Object(DO、数据对象):作为数据库物理表格的映射,不能参与到业务逻辑中,简单来说就是上面的student类
Entity(实体对象):实体对象是我们正常业务应该用的业务模型,它的字段和方法应该和业务语言保持一致,和持久化方式无关,这个entity其实就是领域对象
DTO(传输对象):主要作为Application层的入参和出参,避免让业务对象变成一个万能大对象。
Entity和DO的使用,会达到数据载体隔离的效果,即数据库发生的变化不会直接影响业务代码,DTO则是方便应用的入参和出参,他们之间的转换则需要我们写转换器。
这是DO,属性和数据库字段一一对应,不参与业务逻辑
@Getter @Setter @Entity @Table(name = "students") public class StudentDo { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; //省略其他属性 }
这是Entity,是我们的业务模型,不参与数据持久化
public class Student { private Long id; private String name; //省略其他属性和方法 }
通过转换器将二者转换,这里使用MapStruct
@Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface StudentMapper { StudentMapper MAPPER = Mappers.getMapper(StudentMapper.class); Student toModel(StudentDo studentDo); StudentDo toDo(Student student); }
我们的业务代码就会变成这样,通过Mapper转换给数据访问提供的模型是DO
然后数据访问层的返回值同样通过Mapper转化成Entity
这样就算我们改变了表的设计,也不会影响业务代码,我们只需在MapStruct里将这些改变适配即可
例如我们将student表的name字段名改成nameABC,我们只需变动mapper即可
在实际开发中DO、Entity和DTO不一定是1:1:1的关系,详细说明请见:阿里技术专家详解DDD系列第三讲
至此,我们对业务层和数据访问层进行了解耦。
六边形架构
我们仔细观察三层架构,站在业务层的角度,发现表现层和持久层本质上都是输出输出的第三方库,上面我们说了可以用端口适配器解决业务层和数据访问层的耦合,同样我们可以在表现层加端口和适配器,我不关心你是PC端还是手机端,不关心你是GUI还是命令行,你要来适配我的端口。同样任何第三方库都能这样干,都能变成端口-适配器的模样。
随着端口的增加,可以发现没有层的概念了,业务被包裹在最中心,外围只是第三方库
这也就形成了六边形架构,当然其本质就是端口-适配器架构,有多少个端口就是几边形。
最中心的就是我们的业务代码,这部分会经常变动
外部则是第三方的输入输出,变动较少,可复用性强,并且因为防腐层的存在外部的改变不会直接影响业务员代码,更改适配器进行适配即可。
所以我们可以看到DDD的一个很重要的思想,聚合业务,把业务和具体实现进行解耦分离。
提高业务的内聚
贫血模型和充血模型
DDD主要使用DP(Domain Primitive)解决校验问题,使用充血模型减少业务代码的冗杂。
首先来看贫血模型,贫血模型指对象映射到数据库,对象仅仅只是数据的载体,只有一堆getter和setter,没有业务逻辑,那业务逻辑在哪呢,业务逻辑散落在在XXXService的方法里,service方法里的代码更像是面向过程的脚本代码,例如:校验参数->处理某些业务逻辑->进行数据持久化,这样的对象只有数据没有行为。
DDD则是提倡遵循OOP的思想,把业务逻辑写在实体对象里,而不是XXXService,即使用充血模型。
举个简单的例子,设计一个用户User相关功能
贫血模型是:
类:User+UserManager
用户业务逻辑调用:userManager.doSomeThing(User user)
充血模型是:
类:User
用户业务逻辑调用:user.doSomeThing();
案例
name是student的一个属性
@Getter @Setter public class Student { private Long id; private String name; //省略其他属性 }
在传统三层架构中,业务层的方法经常包含校验的逻辑
public Student register(String name, String address) throws ValidationException { if (name == null || name.length() == 0) { throw new ValidationException("name"); } // 省略业务逻辑 }
我们可以看到在业务逻辑开始之前,有数据校验的操作,如果大部分方法都要数据校验,那么就会有重复代码的坏味道,当然我们可以用工具类等方式提取代码
在一个DDD视频里看到一个有意思的弹幕:
处理参数校验,初级选手用工具类,中级选手用切面,高级选手用DDD
Domain Primitive
domain primitive是DDD里一切模型、方法、架构的基础,它是一个value object
这里我们可以写一个Name的value Object,把校验写在构造函数
public class Name { private final String name; public Name(String name) throws ValidationException { if (name == null) { throw new ValidationException("name不能为空"); } this.name = name; } public String getName() { return name; } }
这样把校验逻辑写在了构造函数,确保Name类被创建出来后,一定是校验通过的
是不是每一个属性都要变成DP呢,也不是,DP应该应用在有限制的数据结构上
例如有限制的String,可枚举的int,复杂的数据结构例如map,详见:阿里技术专家详解 DDD 系列
充血模型
和贫血模型不同,充血模型则是把业务写在业务对象自身
例如现在有一个账户实体对象,除了getter和setter方法,我们还将转入转出的业务也写进对象里,这样才符合OOP的思想
@Data public class Account { //省略其他属性 private Money money; public void withDraw() { //转出的业务代码 } public void deposit() { //转入的业务代码 } }
领域服务
不是所有业务代码都能放进领域对象中的,如果你的业务操作涉及多个领域对象,例如将多个领域对象作为输入进行计算,结果产生一个值对象,
例如在《DDD系列第四讲》里,用了一个玩家打怪的例子,玩家和怪物都是领域对象,那么两个领域对象之间的交互,玩家打怪的逻辑到底是写在玩家里还是怪里呢?
这是就需要一个领域服务整合这两个领域对象的业务,将业务逻辑写在这个领域服务里。
但又不能过度使用领域服务,过度使用的话就还是贫血模型,即所有的业务逻辑都位于领域服务中,详见《实现领域驱动设计》第238页。
项目结构?代码落地?
从找到的资料来看,以DDD建模的项目没有要求你以什么样的方式组织代码模块
根据DDD的分层职责定义,我们可以分成这样四层
具体代码目录结构落地,可详见:https://bbs.huaweicloud.com/blogs/273978
这里提供两个DDD代码落地的例子:
本文没有涉及到的内容
领域是如何划分
聚合、限界上下文、领域事件都是什么
总结
DDD思想的本质就是减耦合,提内聚。DDD采用端口适配器的方式解业务和外部库的耦合,使用DP、充血模型提高业务的内聚——个人理解
DDD不是一个特殊的架构设计,而是所有Transction Script代码经过合理重构后一定会抵达的终点——阿里技术专家详解DDD系列
至于具体怎么划分领域,怎么设计领域,则是由事件风暴工作坊得出的产物
学习过程碰到的难点
不同资料中概念会有偏差,也容易和其他架构概念搞混,比如DO可以是data object,也可以是domian object
资料中概念太多太杂,代码实例太少,学习过程中好想敲点什么
缺乏实践,现在要我用DDD的思想驱动我写代码是很难写出来的
References
阿里技术专家详解 DDD 系列- Domain Primitive
阿里技术专家详解DDD系列 第三讲 - Repository模式
https://zq99299.github.io/note-book2/ddd/
https://tech.meituan.com/2017/12/22/ddd-in-practice.html?utm_source=wechat_session&utm_medium=social&utm_oi=698166473230680064
https://tech.meituan.com/2017/12/22/ddd-in-practice.html?utm_source=wechat_session&utm_medium=social&utm_oi=698166473230680064
https://cloud.tencent.com/developer/article/1787209
https://www.bilibili.com/video/BV1Ci4y1978C/?spm_id_from=333.788&vd_source=a58d0aa3591cdd200c549746ed2ffa7a
https://nifxcrzmch.feishu.cn/docx/doxcnRYrE1SHbU7TYvFxPaNpUBc
https://www.pch520.com/article/119
https://bbs.huaweicloud.com/blogs/273978
《实现领域驱动设计》
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?