最令人头疼的代码

在实战DDD的过程中,我们编写最多的代码无疑就是DO(聚合根)转DTO(读模型)以及DO转PO(映射到数据库表)和PO转DO的转换器代码。百分之八十的BUG都来自这些整齐划一的属性拷贝代码,容易漏字段、错嫁郎,最终消失在持久化之后或前端展示。那为什么需要这么多层转换呢,直接将聚合根响应给请求、直接持久化聚合根不行吗?首先,在DDD中我们必须先获取到聚合根再通过聚合根完成业务逻辑,最终通过资源库持久化聚合根。为什么需要将DO转PO,这是必须要做的事情吗?如果我们选择关系型数据库持久化聚合根,那么就可能需要将聚合根拆分存储到多个表,并且对于枚举类型我们也需要转成数值类型再存储。基于这些场景就需要将聚合根转为PO再调用对应表的DAO存储到数据库中。为什么需要将DO转DTO?除了我们必须要遵守不暴露聚合根内部结构给外部之外,前端需要的数据也是不一样的,比如我们需要将枚举类型字段拆成值和名称两个字段,以及需要屏蔽一些字段。起初,笔者学习某个专栏作者的做法,为每个聚合根提供一个实现将DO(聚合根)转为DTO的装配器、以及一个实现PO和DO相互转换的转配器,这就不得不为聚合根的每个属性添加SET方法,这会导致应用层可以绕过聚合根的方法直接SET属性值。基于这种实现,笔者也寻找过能够解决这些繁琐操作提升工作效率的方法,我们试过用mapstruct框架,但mapstruct也只适用于简单的聚合根,对于复杂内部结构的聚合根映射也需要写一堆注解,工作量没有减少反而增加了问题排查的难度。使用Spring提供的属性拷贝工作类也是一样的,无法解决问题。笔者更认可《领域驱动设计(Thoughtworks洞见)》这本书中作者提倡的做法,聚合根只暴露GET方法,用于外部获取字段值,同时使用Builder模式提供builder方法给资源库将PO转为聚合根。对于实体也可以这样做,而值对象只提供所有参数的构造方法和GET方法。当然了,使用哪种做法都没有错。

DDD中的“零拷贝”

以上的约束都只适用于写操作,在DDD的写操作中,我们需要严格地按照“应用服务-领域服务-聚合根-资源库”的结构进行编码。而对于读操作(如分页查询),这样的步骤会使整个查询过程变得冗余、繁锁,因此就有了CQRS,即命令查询职责分离模式。采用CQRS就可以直接使用DAO查询,将查询结果直接映射为DTO响应,绕过了聚合根,笔者将其称为“零拷贝”,没错,与内存的零拷贝是相似的概念。笔者去年分享过一篇CQRS,介绍了如何在DDD中实现分页查询。要实现CQRS首先我们需要将数据同步到分析型数据库中,并且避免分库(分库情况下需要调用其它微服务接口查询),通过给表复制冗余字段避免跨多个表链接查询,提升查询的性能。但这需要不少的成本,并且在项目初期,也没有这方面的预算。在没有分析型数据库的情况下,我们如何实现CQRS呢?一种方法是在写库下创建只读表,通过消费领域事件去同步数据;另一种就是什么也不干,在查订单信息时,先查订单表,再调用商品服务接口查询商品信息,最后再聚合成DTO响应,这无疑也是性能最差的,笔者在一个新项目中就采用这种,性能是真差。

为DDD架构“瘦身”

笔者在做了几个DDD微服务之后,发现领域服务显得有些多余,而对于这种多余笔者选择干脆利落的去掉。也就是将“应用服务-领域服务-聚合根-资源库”的结构改为“应用服务->聚合根->资源库”结构。因为当我们将一个微服务下的两个或多个领域拆分出独立微服务时,应用服务也必须跟随领域层迁移。在迁移之后,只需要修改应用服务调用其它域的应用服务改为通过RPC去调用即可。必须遵守的原则:聚合根不能直接操作其它聚合根,两个领域的交互必须通过应用服务层。除领域服务外,聚合根工厂也是可以砍掉的,只需要将聚合根的创建写到聚合根下并改为静态方法。非常复杂的创建过程才建议写工厂类。

DDD聚合根存储与领域事件的原子操作问题

在《领域驱动设计(Thoughtworks洞见)》这本书里作者介绍了一种方法,通过数据库确保消息至少投递一次,对于需要严格要求消息不丢失的业务场景,这是一种不错的选择。笔者这样实现DDD架构设计中的领域事件发布,聚合根在处理业务的过程中将需要发布的事件存储在聚合根下,在聚合根通过资源库持久化之后,在应用服务层通过聚合根获取需要发布的事件,最后通过Spring框架的事件发布功能发布事件。通过编写Spring框架事件订阅者消费领域事件,这样可以实现进程内同步消费领域事件,如果是需要发布给其它服务订阅的事件则在此步骤发送。由于数据库事务与发布消息到MQ不是原子操作,因此可能会存在消息丢失的情况。对于需要确保消息至少投递一次的场景,可在消息发送失败时将消息保存到数据库中,最后由定时任务扫描重试。对于表的设计,至少需要一个字段标志这条消息是否已经发送成功。因为定时任务也存在发送失败的情况,所以事情远没有这么简单,可能会出现这样的情况,当消息发送成功但还未来得及更改记录状态时,下次重启就可能重复发送这条消息,这就需要消费端确保消息幂等消费。笔者认为,对于任一要求消息至少投递一次的场景,消费端都应该实现幂等消费。这个观点不应该存在争议。

DDD聚合根持久化并发数据一致性问题

在电商项目中,可能会在订单微服务服中消费支付事件,用于将订单更改为已支付状态,同时也可能存在一个定时任务在订单超时未支付时更改订单的状态为超时关闭。如果在某一时刻,用户完成支付,订单服务查询出订单状态为待支付,想要修改为已支付,好巧不巧,定时任务查出未支付订单包含了这个订单,因此定时任务也想要将其改为超时未支付关闭状态。假设订单服务先行一步将订单改成了出库中,随后定时任务又将其改成超时关闭,数据更新就会被覆盖,这就是典型的并发数据一致性问题。笔者有过相似经历,当时笔者想到的是学习Java的CAS(比较和替换)解决并发更新问题。在修改订单时,在SQL语句上加上订单状态判断,如果订单状态和期望状态不一样就不提交修改了,回滚事务。正是因为有过这样的经历,笔者在实战DDD时才会思考到这个问题。在DDD中,Repository(资源库)是聚合根的容器,与DAO扮演相同角色,但它只提供持久化聚合根的操作(新增或更新),以及提供根据ID获取聚合根的查询操作。在所有的领域对象中,只有聚合根才拥有Repository,因为Repository不同于DAO,它所扮演的角色只是向领域模型提供聚合根。这里隐藏的信息量就是不能跨过聚合根去修改表,并且,订单下的订单Item也只能由聚合根(订单)修改,并最终随聚合根一起由Repository持久化。在并发事务中,多个线程同时读取一个聚合根对象(id相同)到内存中,分别调用聚合根的业务方法,所有业务操作修改都只记录在内存中,只有最后调用Repository的save方法才会执行SQL持久化,很容易出现丢失更新的数据一致性情况。由于聚合根的存储需要通过Repository,所以想要实现CAS更新可在Repository实现save聚合根方法中调用DAO一个自定义的update方法,当update成功时再执行聚合根下的实体的save操作,如订单聚合根下的所有订单Item接口。而当更新失败时直接放弃,因为到此为止所有的操作都只修改内存中的数据,甚至连事务都不需要回滚。我们还可以采用最简单的方法,就是给记录加分布式锁,根据订单ID加锁,这样自然就不存在上述问题。由于只对单个订单加锁,对性能几乎没什么影响。DDD后,我们在项目的整体架构设计上,不应该再存在有定时器直接修改数据库数据。对于需要数据库定时修改的场景,应由微服务提供接口,定时器调用接口实现数据的修改,而如果是应用内的定时任务,也应该调用领域服务实现数据的修改。对于不同服务之间事务的一致性可通过消息队列实现,使用顺序消息可确保数据的最终一致性。

优化聚合根的持久化性能

对于使用关系型数据库持久化聚合根的场景,在“只能通过Repository的save方法持久化聚合根”这个约束下,save方法在性能上是有非常大的损耗的,因为更新一个聚合根需要同时更新聚合根下的实体。为了降低性能影响,可在更新之前对比一下内存中的快照,只对有更新的实体执行更新操作。在DDD中,事务注解我们是加在应用层的Service的方法上的,对于支持缓存的ORM框架,如My Batis,我们不需要另外实现快照,在save方法中再查询一遍即可。如今分布式数据库已经成熟,不建议在新项目中引入分库分表ORM框架以及分布式事务框架,更不建议使用分库分表,这些应该交由底层数据库完成,或增加一层代理完成。

总结

本篇文章分享了笔者在实战DDD过程中遇到的几个问题,以及笔者对这些问题的思考与总结。《领域驱动设计(Thoughtworks洞见)》这本书笔者总共看了两次,第一次看是在入门DDD时,而最后一次看是实战了几个小项目之后。每次看对书中的理论理解程度都不一样,第一次看有些理论理解得很模糊,实战过后再次阅读就都清晰了。实战才是理解书中理论的最好方法,在以后项目版本的迭代中,我们可能会遇到更多的问题,但也一定会收获更多对于DDD的领悟和补充。