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代码落地的例子:

  美团抽奖系统落地配套博客

  github搜到的DDD落地实践

本文没有涉及到的内容

  领域是如何划分

  聚合、限界上下文、领域事件都是什么

总结

  DDD思想的本质就是减耦合,提内聚。DDD采用端口适配器的方式解业务和外部库的耦合,使用DP、充血模型提高业务的内聚——个人理解

  DDD不是一个特殊的架构设计,而是所有Transction Script代码经过合理重构后一定会抵达的终点——阿里技术专家详解DDD系列

  至于具体怎么划分领域,怎么设计领域,则是由事件风暴工作坊得出的产物

学习过程碰到的难点

  不同资料中概念会有偏差,也容易和其他架构概念搞混,比如DO可以是data object,也可以是domian object

  资料中概念太多太杂,代码实例太少,学习过程中好想敲点什么

  缺乏实践,现在要我用DDD的思想驱动我写代码是很难写出来的

References

阿里技术专家详解 DDD 系列- Domain Primitive

阿里技术专家详解DDD系列 第二弹 - 应用架构

阿里技术专家详解DDD系列 第三讲 - Repository模式

DDD系列第四讲:领域层设计规范

DDD系列第五讲:聊聊如何避免写流水账代码

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

《实现领域驱动设计》

 
posted @ 2023-01-31 15:46  艾尔夏尔-Layton  阅读(291)  评论(0编辑  收藏  举报