聚合
在一个真实业务里,对象并不是孤立存在的。如订单有订单项,订单项有关联的商品,商品又有价格规则等。如果任何地方都能随便改任何对象,系统很快就会变成“牵一发动全身”的脆弱结构。
DDD 的思路是:把强相关、必须一起保持一致的对象,关进一个“边界”里。这个边界,就是“聚合”。
聚合是一组对象的组合,它们被视为一个整体来处理,始终保持在一致状态(在单个 ACID 事务中)。
举个常见的例子:
订单(Order)聚合
- Order(订单)
- OrderItem(订单项)
- Address(收货地址,值对象)
- Money(金额,值对象)
这些对象:
- 生命周期强一致绑定
- 修改时通常需要一起考虑一致性
- 不适合被外部对象直接“随手一改”
所以它们被打包成一个聚合的“微型业务世界”。世界内部怎么运转是它自己的事,外界不能随便插手。
聚合的核心是边界,代码层面通常采用包名体现,涉及的所有业务代码放在聚合包名的领域层(domain)下。在 domain 层定义了聚会中所需要的所有类型如值对象、实体、聚会根。
领域对象
领域对象重点就两个:实体和值对象。
实体
领域驱动中,实体(Entity)是通过“身份(ID)”来区分的对象,而不是通过属性值。 哪怕它所有属性都变了,只要身份还在,它还是“同一个东西”。实体的重心点是谁。 相反如果没有id的对象,这个通常叫值对象。值对象一般是创建了就不可以修改,如果修改就是替换。值对象的重心点是值。
我们举个例子:
两张一模一样的 100 元纸币
- 面额相同
- 图案相同
- 功能相同
你会说它们“没区别”。这类东西关心的是“值”,不是“个体”。这种对象可以理解为值对象(Value Object)。
两个同名同姓、同一天出生的人
- 名字一样
- 年龄一样
- 属性几乎一样
但你绝不会把他们当成同一个人,因为他们有不同的身份证号。这种对象就是实体。
类上添加 @Entity 注解声明这个类是一个实体。并且此类必须有一个被 @EntityIdentifier 注解标记的属性,我们称为实体id(简称:entityId)。
如下定义了一个订单明细的实体。
// ... existing code ...
import net.qiqbframework.modelling.domain.Entity;
import net.qiqbframework.modelling.domain.BizIdentifier;
// 订单明细
@Entity
public class OrderItem {
// 订单明细id
@EntityIdentifier
private String id;
// ... existing code ...
}
而聚会根就是比较特殊的实体,聚合根是聚合对外唯一的“入口”和“代表”。
类上添加@AggregateRoot注解声明一个实体,此类字段中添加@AggregateRootId或@EntityIdentifier注解表示聚合根id(简称:arId)。
// ... existing code ...
import net.qiqbframework.modelling.domain.AggregateRoot;
import net.qiqbframework.modelling.domain.AggregateRootId;
// 订单明细
@AggregateRoot
public class Order {
// 订单id
@AggregateRootId
private String id;
// ... existing code ...
}
AggregateRoot注解说明:
| 属性 | 说明 | 类型 | 默认值 | 版本 |
|---|---|---|---|---|
| name | 聚会根名称,需全局唯一。未配置时自动采用被标记的类全名称 | String | - | - |
Spring 模式下,使用
@Aggregate注解替代@AggregateRoot。被@Aggregate标记的类注解受 spring 扫描控制。
业务 Id
通常在实际开发中,某些业务会中会有和实体身份(id)很相似,但是可修改。
例如用户模块中,如果规定一个手机号必须只能绑定一个用户,那这个手机号在全局中是唯一的,但是用户可以修改,且修改的手机号也要必须保证全局唯一。那么通过手机号,我们也能确定到具体的用户。
像这种可变的非空全局唯一id,通过这个值能找到对应的聚会,简称 bizId。 其中arId (聚合根 id)是特殊的bizId,具有不可更改特性。一个聚合根能存在多个bizId,非arId可以支持修改,修改后的值需要保证唯一。
在 Qiqb 应用中,在聚合根类字段上标记@BizIdentifier注解表示 bizId。一个聚会根可以有多个 bizId。
// ... existing code ...
import net.qiqbframework.modelling.domain.AggregateRoot;
import net.qiqbframework.modelling.domain.AggregateRootId;
import net.qiqbframework.modelling.domain.BizIdentifier;
// 订单明细
@AggregateRoot
public class User {
// 用户id
@AggregateRootId
private String id;
// 绑定手机号,可以通过这个手机号确定此用户
@BizIdentifier
private String phone;
// ... existing code ...
}
值对象
在领域对象中,如果未特别其他声明,默认是值对象。
值对象的特点不可变(强烈推荐)。一旦值对象变化了,只能重新new一个对象,而不是通过值对象的方法去修改里面的某个值。
例如下面的修改订单金额的伪代码。
// ... existing code ...
import net.qiqbframework.modelling.domain.Entity;
import net.qiqbframework.modelling.domain.EntityIdentifier;
// 订单明细
@Entity
public class OrderItem {
// 订单明细id
@EntityIdentifier
private String id;
private Money money;
public void changeMoney(BigDecimal moneyVal){
// 这里通过直接赋值新的对象,而不是去原有的对象里修改
this.money = new Money(moneyVal);
}
// ... existing code ...
}
仓库
在命令处理的时如何获取对应的聚会根对象以及在处理后如何将变化后的聚合根数据保存起来,这就需要领域驱动中另一个概念:仓库。
在 DDD 里,“仓库(Repository)”不是数据库,也不是 DAO 的高级皮肤,它解决的是我们如何在不关心存储细节的情况下,最终拿到和保存一个“完整的聚合”。
仓库是用来加载和持久化聚合对象集合,即聚合根。代码上使用 @LoadHandler注解标记方法来加载聚合根, 使用 @PersistHandler注解标记方法保存聚会根。
被标记这两个注解的类一般叫 XXXRepository。当然如果想分开,可以叫 XXXLoader 和 XXXPersistence。
这里还是要特别提醒一下,这里的仓库并不操作数据库,仓库对数据库不关心。访问数据库是 DAO 层干的事。
例如:运行时的数据,直接采用一个Map来保存聚合根对象是完全有可能的。
加载
通过 @LoadHandler注解的方式,使这个方法赋予了加载聚会根功能。在命令调用时,框架会自动调用该方法加载聚合根。
// ... existing code ...
import net.qiqbframework.loadhanding.LoadHandler;
public class OrderLoader {
// ... existing code ...
@LoadHandler
public Order loadById(String id) {
// 通过dao层去查询组装完整的订单聚会根。包括订单信息和订单明细信息
return null;
}
// ... existing code ...
}
在 Spring 或者 Quarkus 项目中,默认被容器管理的对象的方法上添加@LoadHandler注解,Qiqb Framework 会自动收集,无需额外配置。如果是其他环境项目,需要自己添加,详情见高级配置。
加载处理器使用方式必须遵循以下规格,否则会在启动的时候报错。
- 返回对象必须是能指定某个聚会根。
- 第一个参数是必须是匹配到对应聚合根上的 bizId(或 arId)。
- 加载器的个数必须和聚会根里的 bizId 一一对应,不能多也不必少。
- 一个聚会根至少要一个加载处理器,因为 arId 也是 bizId。
bizId + 聚会根决定了加载处理器的唯一性。
@LoadHandler注解说明:
| 属性 | 说明 | 类型 | 默认值 | 版本 |
|---|---|---|---|---|
| aggregateName | 指定的聚合名称。未配置时匹配被标记方法的返回对象类全名称 | String | - | - |
| bizIdentifierName | 指定聚合根的 bizId。未配置时默认根据 bizId 的类型匹配,如果有多个相同的bizId,必须通过此属性指定。 | String | - | - |
| supportBatch | 是否支持批量。 | boolean | false | - |
批加载
批量加载是在执行批量命令时候的优化。当有多个相同类型的 bizId 的命令一次性执行时。执行前会将 bizId 收集起来一次性发给加载处理器,然后加载处理器一次性返回所有能查到的聚会根对象。这样就减少数据库的查询次数,提高了效率。
// ... existing code ...
import net.qiqbframework.loadhanding.LoadHandler;
public class OrderLoader {
// ... existing code ...
@LoadHandler(supportBatch = true)
public Map<String,Order> loadById(Collection<String> ids) {
// 通过ids 一次性去查询数据库所有信息,然后组装返回一个Map(key:id;value:对应的聚会根对象,如果没找到,直接返回null)。
return null;
}
// ... existing code ...
}
加载器的唯一性约束决定了必须且只能有一个,那如果支持了批量,那么单个的就必须删除,否则Qiqb 没办法去执行那个加载处理器。
持久化
通过 @PersistHandler注解的方式,使这个方法赋予了加载持久化功能。我们称这个方法叫做持久处理器,简称持久化。
// ... existing code ...
import net.qiqbframework.persisthanding.PersistHandler;
public class OrderPersistence {
// ... existing code ...
@PersistHandler
public void persist(Order order) {
// 通过dao层去保存订单所以信息。包括订单信息和订单明细信息
}
// ... existing code ...
}
在 Spring 或者 Quarkus 项目中,默认被容器管理的对象的方法上添加
@PersistHandler注解,Qiqb Framework 会自动收集,无需额外配置。如果是其他环境项目,需要自己添加,详情见高级配置。
持久化处理器使用方式必须遵循以下规格,否则会在启动的时候报错。
- 第一个参数就是命令操作后的聚会根对象,第二个参数是加载的初始对象(新增聚合根不支持第二个对象)。
- 一个聚会根至需要一个持久化处理器。
@PersistHandler注解说明:
| 属性 | 说明 | 类型 | 默认值 | 版本 |
|---|---|---|---|---|
| aggregateName | 指定的聚合名称。未配置时匹配被标记方法的第一个参数对象类全名称 | String | - | - |
| type | 持久化类型。ALL:增删改都会执行;SAVE:新增或修会执行;ADD:只有新增会触发;MODIFY:只有修改会执行;REMOVE:只有删除会执行 | PersistType | PersistType.ALL | - |
| supportBatch | 是否支持批量。 | boolean | false | - |
批持久处理器
批量持久处理器和加载处理器一样。区别是第一个参数通过Map一次传递所有变更的聚会根对象。Map 的key 是最新的聚会根,value:是旧的数据(新增时为null),如果不需要旧值,可以直接用 Collection<AR> 接收。
// ... existing code ...
import net.qiqbframework.persisthanding.PersistHandler;
public class OrderPersistence {
// ... existing code ...
@PersistHandler(supportBatch = true)
public void persist(Map<Order,Order> orders) {
// key:新对象。value:是旧对象。通过新值和旧值一起
}
// ... existing code ...
}
生命周期
聚会的生命周期体现在聚会根上。聚会根是领域模型里的“主权实体”。它代表一个聚合的身份、边界和一致性规则,外界只能通过它来动里面的东西。
创建
聚会根的创建通常发生在:
- 构造函数
- 或工厂方法(Factory)
活动期
接收命令,然后自身行为驱动的变更,执行后保证最新数据状态。
终结
Qiqb 通过删除标记 AggregateContext.markDeleted() 来声明当前聚合根即将终结。命令执行完后自动匹配到 @PersistHandler(type = PersistType.REMOVE) 的持久处理器。
// ... existing code ...
import net.qiqbframework.modelling.domain.AggregateContext;
import net.qiqbframework.modelling.domain.AggregateRoot;
@AggregateRoot
public class Order {
// ... existing code ...
public void remove(){
// 通过此方法可以将当前订单聚合根对象变为待删除。
AggregateContext.markDeleted();
}
// ... existing code ...
}
小结一下
通过此章节我们知道了如果定义聚合中涉及到的概念。
我们来总结一下:
- 聚会是一个完整数据集合,聚会根是唯一入口。
- 聚合根需要有且只有一个聚合根 id,简称 arId 。arId 在整个聚合根周期中不可更改。arId 属于特殊的 bizId。
- 聚合根内可以存在多个业务 id,简称 bizId。bizId 具有全局唯一且可以修改的特性。
- 每个聚会根内所有 bizId 必须对应一个加载处理器。
- 持久化是以聚会根为单位。