DDD(Domain-Driven Design 领域驱动设计) 初体验

DDD(Domain-Driven Design 领域驱动设计) 初体验

DDD (Domain-Driven Design 领域驱动设计) 或许也叫 Dream-Driven Design,某度说这是一种程序的设计思想,使用诸如聚合根,值对象,六边形架构,CQRS(命令和查询职责分离),事件驱动等等概念,在领域专家的指引下构造代码。在这种情况下写出的代码具备领域划分明确,响应需求变更快等特点。

但在目前大部分情况下都是用来吹逼的东西,概念虚无缥缈,实践中困难重重。

缘起

因为最近公司业务需要,我从一个只写java的码农需要逐渐转变成一个要写一部分golang的码农。

在熟悉了spring-boot各种封装特性以及刻在骨子里的MVC思想的我开始接触go — 这语法是真的丑

大约爬了2、3天代码,我发现出大问题,似乎没了MVC,就不会写代码了,更恐怖的是我发现正统golang出身的码农眼里似乎根本就没有MVC,他们写代码很多像是放飞自我型,5个人里面可能有6种风格,而我的代码似乎是第7种。

没了MVC,代码感觉失去了灵魂,那么除了MVC,还有什么是可以去指引代码结构的呢?怀着这种疑问,我发现了DDD

尝试

传统MVC结构写代码基本思路和面向过程没太大区别,以典型springboot项目为例,一个接口的调用链大概是这样的

controller -> service -> dao

基本遵循一个A -> B -> C 函数间调度,实体类entity基本都是当做入参来传递。与面向对象3大特性:封装、继承、多态几乎不沾边,写代码如同填空题,只需要往里面填东西就可以了

百度告诉我,DDD基本都采取面向对象的编程思路,在写的过程中要求针对业务对类进行职责分明的封装,并且需要将不同类的领域边界划分清楚,一个类体现的是"业务的高度浓缩",也就是一个类既具备能力也具备属性。DDD追求的是核心代码使用原生的代码,不依赖任何第三方库或者框架,这样易于维护和迁移。DDD的代码往往采用聚合根,值对象,六边形架构,CQRS(命令和查询职责分离),事件驱动等等高大上的手法,可以让我的代码无比完美。

nice

所以我随手拿了一个曾经的crud项目作为样例,开始了改造。

首先第一步,贫血模型改成充血模型

将贫血模型改成充血模型,简单点就是把service和dao这2层合并,将方法和私有成员放在一起,再按照业务重新划分对象边界,把新生的类丢到一个包下面,最后给它取一个名字 — 聚合根

大概就是原来长这样

public class OrderModel {
    
    
    private Long id;
    ...
}

现在长这样

public class Order {
    
    
    private Long id;
    ...
    public Long getOrderKey() {
    
    
      // 此处省略很多拼装逻辑
      return SHA256(id);
    }
    ...
}

将贫血模型改成充血模型之后,面临了2个问题

  1. spring没了,由于聚合根的数据和方法是放在一起的,方法中大量引用自身的数据,导致了大多数情况下,这个类的对象必须现场填入数据构建,这就导致了ioc基本失去了效果
  2. 数据校验很麻烦,原来的校验是通过springboot的各种注解统一校验以及ioc的自动注入保证service的引用都有实例存在,每个方法在调用的时候其实是比较放心的。充血模型就有个很奇妙的现象,有些方法其实不会用到所有的成员变量,在很多场景下,一个对象的成员变量是有空缺的,这个时候调用它的其他方法很容易造成NPE等问题,这种问题的出现使得代码不得不在每一个方法内部对它所需要的成员变量进行校验,否则就只能期望调用方自觉不碰雷区了

为了解决这2个问题,我不得不将spring的生存空间压缩到controller层,只用spring封装的request入口,在聚合根的方法里加入了大量的数据校验

接着第二步,抽离数据库交互

数据持久化需要单独抽离,写在一个叫repository的包下面,通过repository和聚合根本身或聚合根的数据的组合实现对数据库的crud工作。

大概是原来长这样

public Long addOrder(RequestDTO request) {
    
    
    // 此处省略很多拼装逻辑
    OrderDO orderDO = new OrderDO();
    orderDAO.insertOrder(orderDO);
    return orderDO.getId();
}

public void updateOrder(OrderDO orderDO, RequestDTO updateRequest) {
    
    
    orderDO.setXXX(XXX); // 省略很多
    orderDAO.updateOrder(orderDO);
}

public void doSomeBusiness(Long id) {
    
    
    OrderDO orderDO = orderDAO.getOrderById(id);
    // 此处省略很多业务逻辑
}

现在长这样

@Repository 
public class OrderRepositoryImpl implements OrderRepository {
    
    
    private final OrderDAO dao; 
    private final OrderDataConverter converter; 

    public OrderRepositoryImpl(OrderDAO dao) {
    
    
        this.dao = dao;
        this.converter = OrderDataConverter.INSTANCE;
    }

    @Override
    public Order find(OrderId orderId) {
    
    
        OrderDO orderDO = dao.findById(orderId.getValue());
        return converter.fromData(orderDO);
    }

    @Override
    public void remove(Order aggregate) {
    
    
        OrderDO orderDO = converter.toData(aggregate);
        dao.delete(orderDO);
    }

    @Override
    public void save(Order aggregate) {
    
    
        if (aggregate.getId() != null && aggregate.getId().getValue() > 0) {
    
    
            // update
            OrderDO orderDO = converter.toData(aggregate);
            dao.update(orderDO);
        } else {
    
    
            // insert
            OrderDO orderDO = converter.toData(aggregate);
            dao.insert(orderDO);
            aggregate.setId(converter.fromData(orderDO).getId());
        }
    }

    @Override
    public Page<Order> query(OrderQuery query) {
    
    
        List<OrderDO> orderDOS = dao.queryPaged(query);
        long count = dao.count(query);
        List<Order> result = orderDOS.stream().map(converter::fromData).collect(Collectors.toList());
        return Page.with(result, query, count);
    }

    @Override
    public Order findInStore(OrderId id, StoreId storeId) {
    
    
        OrderDO orderDO = dao.findInStore(id.getValue(), storeId.getValue());
        return converter.fromData(orderDO);
    }

}

可以看到在使用repository模式之后,又面临了1个问题

  1. 麻烦,以前是crud 4个接口,现在多了一些接口也就算了,最恐怖的是需要对每个接口做数据转换,所有的Entity/Aggregate会被转化为DO,事后如果需要则把DO转换回Entity。特别是当你的对象非常多的时候,各种格式的对象转换会直接让你怀疑人生

最后第三步,CQRS

将所有事情分成查询类和变更类:

查询类的事情由controller接到,直接构造repository对象调用查询方法返回即可。

变更类就比较麻烦了,先发出一个事件记录变更的开始,事件处理时编写业务构造聚合根,repository等对象,如果需要多个聚合根进行链式变更,则需要处理完一次变更后再发出一个事件通知下一个,直到事件完全结束。

很明显,变更类的事件驱动的调用链建立在一个又一个的事件传递上,那么我们就必须要有一个机制保证事件的连续和最终一致。比较典型的做法就是建立事件中心,提供回滚机制等。

改造完毕

改造到现在,基本已经完成DDD所要求的雏形,整个调用逻辑就是借助springboot为我们提供的controller接到request对象,开始按接口职责不同分为

如果是查询类的接口就通过repository的接口查询数据库返回数据,中间或许需要经历 dto对象转entity对象转do对象查询,最后查出的结果可能还得转回来。

如果是变更类接口就通过事件驱动,一个事件一个事件的处理,为了保证事件的最终一致,那么需要建立事件中心,提供回滚机制等额外措施。

思考

在初步实践之后,我似乎发现,DDD思想有一个死穴

那就是所有的一切,都建立在领域的划分上

如果这个领域划分的不正确呢?如果有代码侵入到别人的领域呢?如何保证项目组所有人都能理解这个领域划分,大家各司其职不会犯错?

人总是会犯错的,特别是在上线日期临近的时候,往往会打破规范走捷径,在这个写法下,只要一步错,未来就会步步错,DDD就名存实亡了。

个人认为,使用DDD进行核心代码的编写需要满足以下的条件

  1. 要求码农对业务要有很深的理解,任何对业务理解不够的地方都有可能造成边界的划分不明确,以至于后期乱成一团浆糊。开发团队破罐子破摔,为了实现功能一通瞎写。
  2. 要求开发团队每个人的技术水平,视野,编码理念保持大体一致,风格统一。不然会出现每个人理解上的不一致而代码写出很多套风格,最后对不到一起去。但凡有后加入团队或者技术水平比较落后的人参与开发,在不完全理解代码的情况下赶进度,就会是整个代码风格走向崩溃的开始。这一点在横向切分任务的团队尤为明显,因为不是所有人都有精力有追求去检查规范代码的,大多数情况就是为了时间而妥协,然后风格就开始四不像起来,最终破罐子破摔,为了实现功能一通瞎写。
  3. 要求团队有一个强大的架构师或者领域专家规划,确定领域边界,监督实际开发情况,如果发现之前的设计有缺陷,千万不能为了弥补缺陷而引入一个新的东西尝试解决问题,新的东西会引入新的缺陷,最终缺陷越滚越大不可控制。遇到设计缺陷要及时重构,走贴合逻辑的正道,特别是核心代码部分。

总结

团队想使用DDD的思想进行开发,要具备一个稳定的高素质的团队,团队内每个人的技术能力、视野比较相近,每个人对业务都要有深入的理解。拥有一个能力足够强大的架构师,可以将业务分解成界限分明的模块,把控代码的质量。

DDD是个好东西,但也是个走钢丝的活,在一群大佬手上确实可以,例如kubernetes就是经典的DDD风格的架构设计,但不是所有团队都是Google,是阿里,是腾讯,能时时刻刻盯着你的代码不让项目走偏。真正普世的架构或许就是朴实无华的MVC,它简单明了,上手极快,问题易于处理,是那种真正做到是人是妖都要被框在大工程的进度中发挥正向的作用。

ps: github上stars>10000的Java业务项目里面,几乎找不到一个使用DDD进行开发的,都是标准的MVC思想,代码基本都是朴实无华的逻辑堆砌,或许这就是大型工程类项目的最有效方式。

参考: https://www.colabug.com/2020/0702/7488532/

猜你喜欢

转载自blog.csdn.net/synuwxy/article/details/115057448