聚合的作用
DDD 在战术设计中聚合的设计是核心问题,本质上面向对象的设计。为什么需要面向对象设计? 因为软件设计里面的元素太多太复杂了,如果不进行控制各种元素相互关联,很容易就成了大泥球,牵一发而动全身。控制复杂度的方式就是进行分类,把相关的放一起,不相关的尽量减少联系,这样要找到相关代码并去修改,就会变得更加容易。
聚合就是代码的一种分类方式,面向对象比面向过程好的地方在于面向过程只封装了过程,而面向对象通过对象把过程和数据都封装起来。使得对象不仅更加内聚,能力更强了。聚合正是这么一个对象。聚合由实体和值对象组成,实体和值对象其实也是一种分类方式,只是作用稍微有些不同。
本文不通过聚合的定义来解释聚合,而是通过例子分析聚合的作用。下面举一个例子分析一下代码的问题,并使用通过聚合来优化。
缺少聚合带来的问题
背景是有一个设备的场景,有2个用例,修改设备名,设备可以绑定学校。
业务模型:
@Data
public class Device {
private String deviceId;
private String deviceName;
private String schoolId;
private String bindTime;
}
Service:
public class DeviceService {
private DeviceMapper deviceMapper;
private PushService pushService;
public void updateDeviceName(String deviceId, String changeDeviceName) {
if (changeDeviceName.length() > 10) {
throw new RuntimeException("设备名长度过长");
}
Device device = deviceMapper.selectById(deviceId);
if (device.getDeviceName().equals(changeDeviceName)) {
throw new RuntimeException("设备名没变");
}
deviceMapper.updateDeviceName(deviceId, changeDeviceName);
// 通知设备端修改名字
pushService.notifyDeviceUpdateName(deviceId, changeDeviceName);
}
public void bindSchool(String deviceId, String schoolId) {
String boundSchoolId = deviceMapper.selectSchoolIdStatusById(deviceId);
if (boundSchoolId != null) {
throw new RuntimeException("设备已绑定学校");
}else{
deviceMapper.deviceBindSchool(deviceId,schoolId);
}
}
}
Mapper:
interface DeviceMapper {
@Update("update device set device_name = #{deviceName} where device_id = #{deviceId}")
int updateDeviceName(String deviceId, String deviceName);
@Update("update device set school_id = #{schoolId}, bind_time = NOW() where device_id = #{deviceId}")
int updateDeviceBind(String device, String schoolId);
@Select("select school_id from device where device_id = #{deviceId}")
String selectSchoolIdStatusById(String deviceId);
@Select("select * from device where device_id = #{deviceId}")
Device selectById(String deviceId);
}
上面的代码是典型的面向过程的设计,Device 虽然看上去是对象,但只是作为数据对象使用,是一个容器,并没有行为。Service 和 Mapper 都是过程式代码。数据和过程是分离的,因此是面向过程的。面向过程带来了2个问题。
-
逻辑散落在不同的地方,Service 和 Mapper 各有部分逻辑。例如 Mapper 里面同时更新 school 和 bindTime。这样不仅会造成可读性下降,而且 Mapper 会有非常多的 update 方法, 难以复用,Mapper 和 业务逻辑强绑定了。
-
Service, Mapper 和 Device 内部耦合太大。Service 需要知道 Device 里面的属性,并进行操作。Device 就像一个提线木偶,被 Service 操控,Service 因此负担也很重。例如 updateDeviceName 里面知道了 deviceId 里面有 deviceName 属性,bindSchool 里面知道 device 有 schoolId 属性,对 Device 内部的修改也要经过 Service 或者 Mapper。如果要增加一个最近开机时间,Service 的逻辑就要修改。
通过聚合优化代码实现
目标是希望代码有层次,且逻辑能内聚。尝试用聚合来实现上面的逻辑。
业务模型:
@Getter
public class Device {
private String deviceId;
private String deviceName;
private DeviceBindInfo bindingInfo;
public void updateDeviceName(String changeDeviceName) {
if (changeDeviceName.length() > 10) {
throw new RuntimeException("设备名长度过长");
}
if (deviceName.equals(changeDeviceName)) {
throw new RuntimeException("设备名没变");
}
deviceName = changeDeviceName;
}
public void bindSchool(String bindSchoolId) {
if (bindingInfo != null) {
throw new RuntimeException("设备已绑定学校");
}
bindingInfo = DeviceBindInfo.bind(bindSchoolId);
}
}
@Getter
public class DeviceBindInfo {
private String schoolId;
private LocalDateTime bindTime;
private DeviceBindInfo(String schoolId, LocalDateTime bindTime) {
this.schoolId = schoolId;
this.bindTime = bindTime;
}
public static DeviceBindInfo bind(String schoolId) {
return new DeviceBindInfo(schoolId, LocalDateTime.now());
}
}
Service:
public class DeviceService {
private DeviceMapper deviceMapper;
private PushService pushService;
public void updateDeviceName(String deviceId, String changeDeviceName) {
Device device = deviceMapper.selectById(deviceId);
device.updateDeviceName(changeDeviceName);
deviceMapper.save(device);
// 通知设备端修改名字
pushService.notifyDeviceUpdateName(device);
}
public void bindSchool(String deviceId, String schoolId) {
Device device = deviceMapper.selectById(deviceId);
device.bindSchool(schoolId);
deviceMapper.save(device);
}
}
Mapper:
interface DeviceMapper {
@Select("select * from device where device_id = #{deviceId}")
Device selectById(String deviceId);
@Update('insert on duplicateKey')
void save(Device device);
}
和之前实现的区别在于 Device 是一个有数据有行为的对象,承担起一部分的职责。并且没有对外包括 Mapper 和 Service 暴露自己的内部属性,对于其他类来说是一个黑盒。业务逻辑没变,只是把部分相对内聚的逻辑迁移到了 Device 中。这就是聚合的优点,能承担一定的职责,作为一个整体对外隐藏内部实现。
但缺点在于把 Device 当成统一的整体了,会导致在从 Mapper 里面获取对象和保存对象的时候也需要整个聚合来获取和保存。大部分场景性能都不会是瓶颈,如果确实是瓶颈可以考虑把聚合拆小,或者采用类似 hibernate 懒加载的方式来解决。
聚合的组成
一个聚合里面往往不是所有属性的关系都一样紧密,例如上面的更新设备名和绑定学校,从 Device 内部看处理的属性是不同的。这时如果把聚合拆成 2部分,则对外的复杂度又增加了,这时可以把关系更加密切的对象设计成实体或者值对象。例如上面 bindTime 和 schoolId 关系会更加紧密,因此设计了 BindInfo 这个值对象,把一部分职责再下沉下去,让 Device 可以更简单一些。BindInfo 和 Device 的区别在于要获取和修改 BindInfo 的值都需要经过 Device,例如不能直接通过 Mapper 获取 BindInfo 或者 修改BindInfo 的值。
同一个概念在不同场景可以设计成聚合,也可以设计成实体和值对象,后面文章再进行介绍。
总结
聚合并不是一个新的东西, 从面向对象的角度业务对象就应该这么设计。只是聚合的概念明确提供了一些限制, 例如聚合包含行为, 尽可能不对外暴露属性, 和仓储一对一映射, 且需要对整个聚合进行获取和保存等等聚合的约束, 让我们往面向对象的道路不要走偏罢了。