聚合,字面意思就很简洁明了,是把领域对象聚合在一起,并维护领域对象之间的关系。我们来解释下这句话中出现的几个名词:
名词 | 解释 |
领域对象 | 实体,并且只有实体 |
领域对象的关系 | 实体之间的固定业务关系,主要是逻辑关系(如一对多)和聚合业务逻辑 |
聚合根 | 聚合的根,聚合业务的发起者,领域对象关系的起点。 |
所以我们总结下聚合的定义:聚合根发起的,并维护多个实体间固定业务关系的领域对象。
在领域模型中,我们通常用这样的图列代表聚合:
聚合图例
图例说明:
A 是聚合根的实体,我们用( 聚合根 )符号表明其地位。
聚合根和其他实体之间的逻辑关系主要是一对一和一对多,用两种箭头表示,图例中根聚合和实体 B 之间的实体箭头代表一对一,和实体 C 之间的空白箭头代表一对多。
从聚合根出发,用虚线框把其他实体框起来,虚线框内所有领域元素组成了聚合。
在虚线框空白地方,用文字给聚合取名 <聚合> 符号代表当前图像是一个聚合,ABC 聚合代表聚合的名称,通常是聚合下所有实体的组合名称。
图例比较容易理解,也建议大家尝试使用这样的图例,这种即使我们不在同一个公司,在互联网看到这种图,即使没有图例时,也能看的明白。当然如果你有更好的图例表示,欢迎在知识星球留言。
我们在 DDD 中听说过贫血、富血、充血,直白来说指的是领域对象表达的是否完整,我们稍微解释下:
名词 | 解释 |
贫血 | 领域对象该做的事情没做,交给 app 层做了。 |
富血 | 领域对象提供的能力和服务恰到好处,很完整。 |
充血 | 领域对象做的事情太多了,做了实体该做的事情。 |
初尝 DDD 时,我们使用的是充血模式,聚合做的事情太多,把聚合起来的实体该做的事情都做了,整个聚合代码 1040 行,聚合了 5 个实体,方法共 28 个,是不是不敢相信!!! 后来项目完成后,我们总结经验,觉得我们犯了两个错误,我们用代码现场还原这两个错误:
1:聚合的粒度太大,没有秉持一个聚合只维护一种业务关系的原则
我们说订单改价和订单优惠的例子:
名词 | 场景解释 |
订单改价 | 比如你在淘宝买东西,商品原价100元,邮费10元,共110元,私下和商家商量后,商家愿意帮你包邮,你下单后,商家在后台把订单总金额修改成100元。 |
订单优惠 | 在淘宝买东西,使用了一张满100减20的优惠券,或者特殊商品的优惠券,导致订单或者商品的实付金额减少。 |
show me code:
聚合的粒度问题
我们初看代码,似乎没有问题,OrderAgg 聚合了 OrderEntity 和 OrderItemEntity 两个实体,维护着两者之间的业务关系。
但我们想一想,OrderAgg 聚合是在维护订单实体和订单商品实体之间两种业务关系了:改价和优惠,目前看维护两种关系好像问题不大,但口子一旦放开,以后 OrderAgg 维护的关系会越来越多,大家都会往 OrderAgg 聚合里面加代码,导致代码会越来越长,就很难维护了,那怎么办呢?
缩小聚合的粒度!!!一种聚合最好维护实体间的一种业务关系,这也是我个人坚守的聚合的最小粒度。
所以这里的 OrderAgg 聚合应该进行合理的拆分,我们拆分成订单优惠聚合和订单改价聚合,订单优惠聚合只维护优惠场景下,订单和商品之间的优惠关系;订单改价聚合只维护改价场景下,订单和商品之间的改价关系,如图:
聚合的拆分
所以当你发现聚合实例已经在维护多种业务关系时,这时候应该果断的进行聚合拆分了,不然后续的同学看到有这样子的代码,会纷纷效仿,到时候聚合可能就会很庞大。
总结:聚合的最小粒度在于只维护实体间一种固定的业务逻辑关系。
2:聚合的边界
聚合的边界主要有两层含义,第一是指和调用自己的 app 层的边界,第二是指和被调用实体之间的边界。
和 app 层、实体之间划清边界是很重要的,这样子聚合才不会把没做的事情丢给 app 层去做,也不会抢着做实体该做的事情,我们简单画一个图:
聚合边界
还是刚才的改价的例子,有的同学这样实现改价的聚合,show me code:
/**
* 订单改价聚合
* author wenhe
* date 2019/5/18
*/
@Component
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class OrderChangeAggr implements Aggr<String> {
/**
* 订单唯一标识
*/
private String orderId;
/**
* 商品唯一标识
*/
private String orderGoodsId;
@Override
public String getAggrRoot() {
return orderId;
}
/**
* 错误的示范
*/
//订单改价
public void changeOrderPrice() {
OrderEntity orderEntity = OrderEntity.get().getByOrderId(orderId);
orderEntity.changeOrderPrice();
}
//修改订单商品金额
public void changeGoodsPrice() {
OrderGoodsEntity orderGoodsEntity = OrderGoodsEntity.get().getByGoodsId(orderGoodsId);
orderGoodsEntity.changeGoodsPrice();
}
/**
* 推荐的示范
*/
//订单改价
public void changePriceByOrder() {
//step 1:更新订单的总价
OrderEntity orderEntity = OrderEntity.get().getByOrderId(orderId);
orderEntity.changeOrderPrice();
//step 2:更新被影响的商品价格
List<OrderGoodsEntity> orderGoodsEntities = OrderGoodsEntity.get().getByOrderId(orderId);
OrderGoodsEntity.get().batchChangeGoodsPrice(orderGoodsEntities);
}
//商品改价
public void changePriceByGoods() {
//step 1:更新被修改商品的价格
OrderGoodsEntity orderGoodsEntity = OrderGoodsEntity.get().getByGoodsId(orderGoodsId);
orderGoodsEntity.changeGoodsPrice();
//step 1:更新订单的总价
OrderEntity orderEntity = OrderEntity.get().getByOrderId(orderId);
orderEntity.changeOrderPrice();
}
//在淘宝买东西,实付10元,第二天你发现自己不想要了,向商家申请退款,商家也
//把10元退给了你,这时候订单的状态会变成关闭,整个动作叫做关单。
public void closedOrder() {
//关单
}
public static OrderChangeAggr get() {
return DomainFactory.get(OrderChangeAggr.class);
}
}
代码中我们给出了错误示范和推荐示范(可能你有更好的的做法,欢迎到 DMVP 星球讨论),我们主要讲解下为什么错误示范是不合理的。
错误示范点一:OrderChangeAggr 给出了两个简单的方法:changeOrderPrice 和 changeGoodsPrice ,实现的逻辑比较单一,没有考虑到订单价格被修改了,也会影响商品的实付金额,商品的金额被修改了,同样也会影响到订单的总价,所以这个隐含联动逻辑 OrderChangeAggr 并没有体现出来,app 层可能就自己去编排了,app 层的代码可能如下:
@Component
public class OrderApp {
/**
* 订单改价
* 聚合的错误演示
*/
public void changePriceByOrder() {
OrderChangeAggr orderChangeAggr = OrderChangeAggr.get();
orderChangeAggr.changeOrderPrice();
orderChangeAggr.changeGoodsPrice();
}
}
这时候你会发现,OrderApp 把这个逻辑吃掉了,也就是说 OrderApp 在编排整这个逻辑,这明显是不合理的,图中代码只是 mock ,所有只有两行代码,真实的场景会更复杂,肯定不止两行代码可以搞定,如果许多 app 需要这段逻辑的话,好像也只能复制粘贴了,那么 app 层就会大量业务逻辑代码遍布,是不是很忧伤。
这个例子描述的就是上图中说的边界 C:聚合暴露给 APP 的能力要完整,不能让 APP 层做聚合该做的事情,严禁将业务逻辑转移到 APP 层,即使 APP 层有完善的流程编排。
建议的做法代码中也有,就是在 OrderChangeAggr 中新增一个 changePriceByOrder 方法,这个方法会吃掉所有的业务逻辑,这样 APP 层就不需要感知了。
错误示范点二:OrderChangeAggr 中有个 关单( closedOrder )的方法,这就有点奇怪了,关单不应该是 OrderEntity 实体的属性么,为什么又成为了聚合的能力?这个问题其实很多同学也都疑惑过,那就是聚合到底应不应该包装实体的能力,再暴露出去?答案是否定的。
如果 APP 层需要实体的领域能力的话,直接去调用实体的能力就好了,根本不需要再经过聚合的转化,如果经过聚合的转化的话,你会发现聚合的方法特别多,假设 OrderEntity 有 10 个能力,那么聚合可能也会有 10 个能力,这样明显是不合理的,你可能觉的这种很傻,可代码落地的时候,很多人都这么写。
这也就说明了边界 D:聚合也不应该抢着去做实体该做的事情。
建议的做法是:如果 APP 层需要实体的能力,直接调用就好,不需要经过聚合。
总结下以上两点:
一个聚合就管理实体间的一种业务关系就好了,并且把它做完整,其余的就不要做了。
聚合需要持久化吗
聚合是不需要持久化的,持久化的工作都需要交给实体去做,聚合只是管理实体之间的逻辑关系,并不负责持久化的工作。
我举个大学生小文和选修课之间的例子,场景就是小文去订阅一个课程(例子场景皆为 yy,大家应该都有大学选课的经历,理解起来应该不是很困难,我也没有做过这个选修课程的实际业务),我们画一个选课程的大概流程:
选修课程流程
在选修课程流程中,我们最终的目标是记录下学生选修的课程,过程中我们需要做四件事情,也就是四种能力,我们来讨论下四种能力的归属,也就是四种能力都是属于谁的? 如下图:
选修课程流程
在生成课程记录时,我们就比较迷糊了,生成课程记录是属于谁的能力呢?
首先生成课程记录是个动词,我看下动词前后是两个名词,分别是学生和课程,学生和课程都是实体,两者的结果是课程记录,两者之间是多对多的关系,所以我们认为生成课程记录,其实就是管理学生和课程之间多对多的关系,所以生成课程记录应该交给聚合去做,不知道这样解释大家有木有听明白,演变如下图:
生成课程记录
我们迫不及待的开始落地,聚合的代码可能长这样:
生成课程记录
代码初看似乎没啥问题,职责明确,聚合也管理了学生和课程之间的关系,但51和51行代码有点别扭,51和52行代码的主要作用是把选课记录实体落库,聚合直接使用了课程记录的仓库进行落库。
但其实课程记录落库的能力是属于选课记录实体,应该交给选课记录实体去做,聚合是不需要去做这个事情的,所以我们修改代码如下:
使用实体的能力去持久化
仅仅两行代码,体现了完全不同的思想,这就是 DDD 的魅力~
两者的本质区别在聚合和实体的边界问题,聚合只关心实体之间的逻辑关系,聚合可以使用实体的领域能力,但绝不能替换实体的领域能力。
反面论证的话,我们说一般持久化之后,都会有消息通知,那如果聚合直接调用仓储进行持久化,那是不是还需要发消息出来呢?这样就把实体该做的事情给做了,明显是不对。
所以说!!聚合里面根本不需要持久化,持久化的工作都交给实体去做吧!!
但也有的人这么做,我画个图示意一下:
课程记录4
这种写法把就不太一样了,课程记录实体会提供课程即可落库的能力,但把这种能力从聚合转移到了 app 层,通过 app 层编排来实现,代码实现如下:
StudentSubscribeCourceAggr 聚合修改成(去掉了课程记录落库的步骤):
聚合直接返回实体集合
新增 SubscribeCourceApp:
持久化编排
SubscribeCourceApp 调用聚合的 buildCourceByStudentAdvise 方法,返回课程记录的集合,然后调用课程记录实体的批量插入方法落库,这种实现领域能力的粒度就比较小了,让 app 层进行编排领域能力。
这种方式是没有问题的,不反对,主要还是看场景的需要。
如果场景简单,课程记录的创建和持久化是绑定的,建议在聚合里面直接持久化,就是我们前面那种方式,这样聚合能力粒度大一点,用起来方便一点。
如果场景复杂,需要领域能力的粒度特别细的时候,我们建议这种 app 编排的实现方式。
选课流程中,我个人还是推荐直接使用前者,因为实在想不到要把课程记录的创建和落库做成两步的场景,当然如果你想从前者变成后者,也是比较简单的。(大家可以在星球讨论,如果我先落地前者,后来想修改成后者,修改方便么?)
所有代码都在github上,关注知识星球付费用户私密文件,即可获得代码,关键类为:SubscribeCourceApp、CourceRecordEntity、StudentSubscribeCourceAggr、StudentEntity
以上代码都是mock,同学们可以尝试使用 DMVP 去生成这种场景下的代码。
聚合根
聚合根主要说两点:
如何确认聚合根
聚合根到底是实体示例对象还是实体的唯一标识
1:如何确认聚合根
业务上来说,聚合根一般都是业务关系的发起方,带头者,根据这个聚合根能够轻易的找到聚合内的其他实体。
技术上来说,我们希望聚合根和其他实体,最好是一对一,或者一对多的逻辑关系,聚合根基本都是逻辑关系中一的那方,我们不希望聚合根和其他实体之间是多对多的关系,如果是这样的话,建议拆分成两个聚合。
我们在学生选课的场景中,学生和课程是多对多的,但根据选课场景和业务关系,我们认为学生才是主体,所以我们把学生当作了聚合根,逻辑关系由多对多拆分成了一对多。
画了一个简图:
选修课的例子
在没有业务场景下面来说,学生和课程是多对多的。
如果加上场景,我们很容易拆分成一对多,一的那方就是聚合根,如下:
场景一:查询学生选秀了那些课程 -> 聚合根是学生
场景二:查询课程下面都有那些学生 -> 聚合根是课程
场景一和场景二是两个查询聚合,根据不同的场景,聚合根就不同。
我们不建议用一个聚合来维护两者多对多的关系,如果有多对多,那么就把聚合拆分吧。
2:聚合根到底是实体实例对象还是实体的唯一标识
第一种聚合根是实体的唯一标识,就像我们之前贴的图,如下:
聚合根是实体的唯一标识
第二种聚合根是实体的实例对象:
聚合根是实体的实例
我理解这是一个技术问题,而不是业务问题。
业务上来说,实体的实例对象代表着是当前实体,实体的唯一标识也是代表当前实体,两者都是代表实体,所以业务来说是没有区别的。
技术上来说,是有区别的,如果聚合根是实体的唯一标识的话,要得到实体的属性的时候,需要查询一次实体才行;如果聚合根是实体的实例对象的话,不需要查询,可以直接得到。
我个人的项目经验是:
如果实体在整个流程的上下文中需要保持一致的话,聚合根建议使用实体实例对象。
如果在整个流程中,时刻需要得到最新实体的属性值时,建议使用实体的唯一标识。
读写聚合
有一些同学这么问:是不是一个领域模型,只会有一个聚合实例对象?
不是这样的,我们说聚合是用来管理实体之间的关系,如果你有 N 种关系需要管理,那么就会 N 个聚合实例对象。
还有一些同学这么问:现在有实体 A 和 B,两者之间有关系 C,我们建立了一个聚合 ABC,来维护关系 C,那么来了一个查询,需要将 A B之间的关系 D 查询出来,这时候我需要建立一个新的聚合么?
如果关系 C 和 D 是一致的,建议读写共用一个聚合实例对象,如果不一致,建议使用读写两个聚合。
举个例子:
在学生订阅课程的场景下,刚刚我们新建了聚合 StudentSubscribeCourceAggr ,来维护学生和课程之间一对多的关系,那么现在有个场景来了,需要查询学生小美选修课程中的所有老师?
我们大概的思路是:根据小美查询到学生实体 ->查询到课程记录实体集合 ->去课程实体中拿出来老师的集合。
这种查询需要维护学生实体,课程记录实体,课程实体三种实体之间的关系,我们并没有这样的聚合,所以我们是需要新建一个查询聚合,如下图:
聚合查询
当查询聚合涉及到的实体特别多的时候,一般很难命名,我的习惯是按照查询的逻辑,用实体依次命名,如
StudentCourceRecordCourceQueryAggr。
领域服务查询的粒度,我个人也喜欢开的比较大,直接到实体,值对象级别,至于 app 层需要细粒度的数据,自己去取就好了,这样领域层的查询就不会过多。
本篇文章主要从战略的角度上去思考了一下聚合是什么,聚合能干什么,我们把每个小结的内容贴出来再次总结一下:
聚合的定义 | 聚合根发起的,并维护多个实体间固定业务关系的领域对象 |
聚合的表达 | 标准推荐的图文表示法。 |
聚合的原则 | 富血模型 |
聚合的最小粒度(建议粒度) | 只维护实体间一种固定的业务逻辑关系 |
聚合的边界 | 聚合暴露给 APP 的能力要完整,不能让 APP 层做聚合该做的事情,严禁将业务逻辑转移到 APP 层,即使 APP 层有完善的流程编排;聚合也不应该抢着去做实体该做的事情。 |
聚合需要持久化吗 | 聚合里面根本不需要持久化,持久化的工作都交给实体去做吧。 |
如何确认聚合根 | 业务和技术上统筹看。 |
聚合根到底是实体示例对象还是实体的唯一标识 | 这是一个技术问题。 |
读写聚合 | 不同场景,可能分开,也可能在一起。 |
每个小结的内容看起来比较散,其实都是战略设计聚合时需要考虑清楚的。
聚合的战略设计基本结束了,现在给出几个通用语言,大家看看是不是聚合 (集合聚合的定义和原则进行判断):
通用语言 | 解释 |
下单人黑名单校验 | 有的下单人在公安局黑名单内,是不能够购买基金保险产品的 |
拆单 | 当买家把多个店铺的不同商品加入购物车,一起支付时,需要根据店铺和商品的不同属性进行拆分,生成多个订单。 |
阶段支付 | 一笔订单10元,买家用储值卡先支付4元,再用微信支付6元。 |
后面还会有专题介绍聚合战术,如聚合的命名,聚合的构造,聚合的获取,聚合如何暴露实体的行为,聚合是否能有值对象,聚合之间能否互相调用,app 层如何优雅调用聚合等等,当你写聚合代码的时候,会发现有很多问题,我们将在 DMVP 后面的课程中去说~~~
如何获得DMVP框架,扫描下方二维码即可获得,是收费的,付费之后你将获得4大特权:
1:整套框架的使用权限(非商用),视频直播讲解DMVP,知识星球有问必答(晚上或周末集中作答,好的提问会有代码演示)。
2:多年DDD战略战术套路总结,每周一篇,约40篇左右。
3:目前DMVP只是1.0版本,计划6月15号发布2.0,7月底发布3.0,每次版本都是不一样的产品使用姿势和体验,市面上绝没有第二款!
4:星球每增加20人,开一次直播,每次直播除了介绍框架,每次都会新出主题。
购买内三天内都可以退款的,你可以先买着试试看我们的框架,如果觉得和自己八字不合,欢迎退款,但请不要外泄,谢谢。
目前的DMVP还有很多优化正在进行中,针对每次完善我都会发起投票,听取大家的建议,让我们一起搭建DDD领域的最牛实战框架!
扫描二维码即可获得:
附上40课课程章节(DMVP框架已经完成,战略设计已完成30%):
战略设计
0 领域驱动设计学习路径
1 通用语言
· 1.1 通用语言的意义:理解需求的指南针
· 1.2 通用语言的定义和表达
·1.3 快速挖掘通用语言的方法
· 1.3.1 抓住动词,扩展名词
· 1.3.2 思考问题本质的基本方法:WR原则
·1.4 快速转化为领域模型的方法
· 1.4.1 领域模型的图文表示法
· 1.4.2 对号入座法
· 1.4.2.1 实体
· 1.4.2.2 值对象
· 1.4.2.3 聚合
· 1.4.2.4 工厂
· 1.4.2.5 仓储
· 1.4.2.6 领域服务
·1.5 快速确立上下文边界的方法
· 1.5.1 上下文边界的定义和表达
· 1.5.2 领域归属-责任驱动法
· 1.5.3 领域联系-协作驱动法
战术设计
2 领域模型转化成数据模型的方法
· 2.1 通用建模方法
· 2.1.1 彩色uml建模法
· 2.1.2 uml建模法的运用
· 2.2 通用建模技巧
· 2.2.1 二级结构
· 2.2.2 type通用结构
3 代码如何体现领域模型
· 3.1 排版规范
· 3.1.1 system、module、package的业务组织方式
· 3.1.2 method,class等命名方式实体,值对象等等接口定义方式
· 3.2 领域构造规范
· 3.2.1 实体
· 3.2.1.1 实体的唯一标识,属性,行为和规约
· 3.2.1.2 实体行为的粒度和完整
· 3.2.1.3 实体的构造、存储和获取
· 3.2.2 值对象
· 3.2.2.1 构造和获取
· 3.2.2.2 值对象实例的多变化、在架构之间的传递。
· 3.2.3 领域服务
· 3.2.3.1 两种领域服务的实现
· 3.3 领域生命周期规范
· 3.3.1 聚合
· 3.3.1.1 聚合的作用,如何构造和获取
· 3.3.1.2 聚合的粒度、组成范围(业务范围的控制)
· 3.3.1.3 聚合行为定义和实体行为的区别、聚合如何暴露实体的行为
· 3.3.2 工厂
· 3.3.2.1 工厂的作用
· 3.3.2.2 build+factory两种模式
· 3.3.3 仓储
· 3.3.3.1 狭义仓储
· 3.3.3.2 广义仓储
4 架构的实战应用
· 4.1 多层架构
· 4.1.1 整体5层架构
· 4.1.2 应用层
· 4.1.3 领域层
· 4.1.4 基础设施层
· 4.1.5 SPI层
· 4.1.6 Controller层
· 4.2 DDD 和 Spring 架构的结合
· 4.2.1 new 和 Spring 容器间的艰难选择
· 4.2.2 和 Spring 架构的结合
· 4.2.3 依赖倒置和 Spring 框架的结合
· 4.3 简单六边形架构
· 4.3.1 上游复杂多变
· 4.3.2 核心保持不变
· 4.3.3 下游薄防腐层的出现
· 4.4 复杂六变形架构
· 4.4.1 流程编排的出现
· 4.4.2 核心不变粒度进行了优化
· 4.4.3 SPI层的演进
· 4.5 读写分离架构
· 4.5.1. 读写分离架构的思考
· 4.5.2 读写分离架构的实现
欢迎扩散,感谢。