如丝般顺滑:DDD再实践之类目树管理

背景

距离DDD实践反思写完已经过去一年,期间发生了很多事情,比如换了工作,细节按下不表;新团队的技术负责人对DDD在团队里的落地很关心,问最近有没有什么进展?这就很尴尬了:之前我接手并主要负责的XXX服务在现阶段是不太适合用DDD的,自身和外部其他几个服务的边界并不清楚(其中包含了一些历史技术债),而且当前处于一个变化比较快的阶段,也没有什么业务输入,不太适合贸然重构,所以并没有在XXX服务中搞DDD。

做技术总要有点追求嘛,虽然现阶段工作最高优先级还是保证业务快速发展,还是想继续实践下DDD的。
这时正巧一个老应用要做重构,在这个基础上一个新的类目管理功能,虽然是一个新的领域,但是产品文档确定的业务规则已经非常清晰,并且后续变化不会很大。美中不足的是需求对应的功能此时已经用传统的CRUD的方式写完了大半了,纠结了半天,还是决定:搞!

本文对于DDD的基础术语就不再单独讲解了,下面直接进入正题。

原则问题

关于DDD,我一年前观点基本没有变化,这里再总结归纳一下。要先确定是否满足以下条件,再考虑是不是要用DDD,不要为了DDD而DDD。永远记住:没有银弹

实践时,你就会发现DDD在项目落地时做了很多折中,不能教条化地照搬。

  1. 业务规则要有一定的复杂性和稳定性。如果一个业务通过CRUD就能轻易的搞定且以后也不会变得很复杂,或者业务还一直在快速变化(这也意味着经常有很强的的项目时间节点要求和临时性的规则),不要用DDD。
  2. 域的划分是清晰的,建模是准确的,领域方法是可以梳理的且足够丰富的,是考虑使用DDD先决条件。域的划分不等于将一个应用强行拆成很多个应用,人为地提升系统复杂性。
  3. 不要带来过多的额外成本,不要舍本逐末。如果因为DDD导致一个应用的开发、测试、运维成本翻倍,甚至引入了更多的bug,那么就要反思下这次实践是否成功了。

需求分析

这里概括一下需求要点,已刨除掉需求具体的背景以及和本文无关的其他项目需求内容。
本次需要实现一个管理如下图的类目树结构的功能:

具体的规则和支持的操作:

  1. 类目节点组织成一棵或多颗树,每个类目节点下可以有一个或多个子类目节点
    1.1 子类目节点是有序的,可以进行重排序
    1.2 最顶层的类目节点是根
    1.3 类目节点上可以关联多个同种类型的内容实体
  2. 类目节点可以新增、删除、重命名、上架、下架
    2.1 上架和下架是类目节点的状态。如果类目节点下没有关联内容,或者它其下没有上架的子类目节点,无法上架。
    2.1 删除节点时,其下的子节点和子节点关联的内容需要一并删除

建模

象征性地画一下限界上下文和ER图,因为隐藏了很多细节所以看上去很简单。ER图里并没有聚合根,要问为什么请继续往后看。

再实践——落地

怎么用代码表示领域对象:故弄玄虚还是打牢地基?

DDD只在脑中有概念是不够的,为了将概念转化为代码,第一步就是把这些概念变成代码,这样才能指导后续的编写。


实际上,这就可以看做是折中的开始了,因为DDD本身是不关心具体存储的,但是做模型设计,你必须考虑如何持久化。

值对象

本文中为了实现类目树本身并不会用到继承以下值对象的类,为了完整性考虑才写出来的。

点击查看代码

实体

点击查看代码

聚合根

除了实体本身的属性,空的。

点击查看代码

什么模型?那必须是充血模型

话说大了,其实这节列出来的都快成失血模型了。充血模型在哪里?等到下面一节就有了,先看看这些贫血模型提升下血压吧:

点击查看代码

领域服务的根基之一——Repository

CRUD也可以用Repository,你也可以把Repository用Tunnel代替,这里还是使用Repository来表示将持久化的对象加载到内存中、将内存对象持久化的服务。
Repository与直接调用mybatis提供的mapper/DAO不同点:

  1. 可以包含业务逻辑、事务,本身会成为领域服务的一部分;
  2. 需要将DO转化为Model,不能直接把DO给外部使用。

在本次需求里,Repository具体提供了哪些方法就不列举了,可以看下面一个方法,它通过事务绑定了两个动作,保证新建的根节点的rootId字段是它自己创建时生成的主键。

点击查看代码

豁然开朗:聚合根

直到这里,除了看似玄虚的建模抽象类,几乎和CRUD没什么区别对不对?

重点来了:聚合根!
先抽象出聚合根,再将领域方法合理地抽象到聚合根,DDD才算是开始落地。再回顾一下【需求分析】这一节,所有的操作都是和节点有关的,但是单个节点不能支持所有的操作,比如子节点排序,是包含了一个节点下所有的子节点的操作。那么,将一棵类目树作为聚合根,所有对节点的操作都抽象为 对一棵树上某个节点及关联节点的操作,是不是就把操作本身和聚合根联系到了一起呢?

点击查看代码

你会发现,如果想要在聚合根实现领域方法,因为会涉及持久化,聚合根一定是和Repository绑定在一起的。那么,聚合根很自然的变成了充血模型

虽然聚合根是类目树的根节点,我不推荐将所有这课类目树的所有节点都加在到内存中,而是在每次操作时按需加载,操作完直接持久化,否则你会面对着无休止的数据一致性的纠结。

领域服务的前戏——工厂类

聚合根里包含了Repository、Redis并发锁,总不能每次new的时候都手动注入一次吧?


如果不用new来创建对象,很自然的可以想到用工厂类来做这些脏活累活。

点击查看代码

领域服务

接下来,就要在聚合根充实领域服务了,这一步是和抽象聚合根是紧密结合在一起的。

模板方法

这里先铺垫一下,为了提高代码的复用性,需要因地制宜的抽一下模板方法。在本例中,有两种:

  • 只操作单个节点
  • 自下而上操作每个节点
    后续也有可能自下而上操作的,实现起来和自下而上操作类似。

先看下适用于不同场景的两个方法接口

点击查看代码

再看下两种场景对应的模板方法,它们把一些通用操作封装了一下。自下而上的操作时,使用了堆栈和对列。

点击查看代码

领域方法

终于到这里了。前面经过噼里啪啦一顿抽象,领域方法写起来已经很简单了,下面举几个例子,分别展示单个节点操作和自底向上操作一个节点下的所有节点的写法。
实际上不止这几个方法,通过模板方法省掉了大量重复代码,看上去也干净整洁很多,这里就不一一列举了。

点击查看代码

读写分离也是如此丝滑自然

面对一部分需求里的内容,你会发现CQRS有时并不是要故意搞什么高大上的概念,而是不得已而为之......只靠领域服务臣妾做不到啊😂

比如,为了通过UI展示一颗类目树,你需要提供一个接口一次性把所有类目节点查出来,并且保持树的结构;
再比如,你要展示一个类目节点及其下面所有子级类目节点关联的内容,对于子级还要像子级的子级这样递归下去。

对于第一个场景,总不能把模型转VO这件事在聚合根里做吧?我选择另写一个CategoryReadService包裹着一些Repository来承载这种层级查询,顺便把其他所有的纯查询请求都用它来对接;
对于第二个场景,直接上ES走搜索了。
再补一个场景,一些刁钻的查询需求会破坏你原先自洽的mysql索引设计。

可以这样归纳:不要让查询破坏你的建模和设计

小结

整个实践下来发现,居然在无意间把聚合根、实体工厂、领域方法、读写分离都串起来了。代码很有条理,复用性也比较高,收获颇丰,对DDD也有了新的认识。


不过话说回来,这次也算是占了建模难度低的便宜,类目树它本身是一颗树,可以用数据结构里树的相关知识做抽象,其他的场景用DDD抽象未必有这么简单。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值