在DDD中,由于我们操作的聚合根对象,比如我们更新用户昵称这个字段,然后在持久化的过程中,执行的是全量的持久化
selectById : ==> Preparing: SELECT id,user_name,nick_name,password,phone,email,create_time,update_time,version,deleted FROM cuc_customer WHERE id=? AND deleted=0
selectById : ==> Parameters: 1743815330685698049(String)
selectById : <== Total: 1
updateById : ==> Preparing: UPDATE cuc_customer SET user_name=?, nick_name=?, password=?, phone=?, email=?, create_time=?, update_time=?, version=? WHERE id=? AND version=? AND deleted=0
updateById : ==> Parameters: chenzl-2(String), 小土豆(String), 123456(String), 18112341232-2(String), chenzl@qq.com-2(String), 2024-01-07T10:02:28(LocalDateTime), 2024-01-07T14:57:19.885(LocalDateTime), 5(Integer), 1743815330685698049(Long), 4(Integer)
updateById : <== Updates: 1
这样会带来大量无效的update语句,导致binlog暴增,给数据同步带来不必要的开销,因此仅更新改动过的字段变得非常必要。
1.设计思路
本文提供一种基于mapstruct和反射的快照(snapshot)方案来解决此类问题。
2.接口定义
- 为了区分领域对象和持久化对象,分别定义2个接口,由领域对象和持久化对象实现各自的接口
public interface DmoSnapshot {
Object getSnapshot();
void setSnapshot(Object snapshot);
}
public interface PoSnapshot {
Object getSnapshot();
void setSnapshot(Object snapshot);
}
3.mapstruct实现SnapshotConverter
- 首先定义了部分字段是不能被清空的,即
snapshot
,id
,version
- 通过
@AfterMapping
定义方法,保证所有DO/PO互转完毕后,能执行此方法 - 通过判断接口是否实现,来认定是DO->PO还是PO->DO
- DO->PO转换时,获取DO的镜像,设置给PO,调用
cleanPO
方法清空PO的未修改字段 - PO->DO转换时,直接将PO自身作为镜像传递给DO。
- clearPO方法中,当镜像为空时,即非通过标准DB查询方法转换得到的DO,或者新建的领域对象,认为是修改的对象,直接返回不做处理;镜像不为空时,通过反射拿到每个属性的新值和旧值,当新旧=旧值,设置属性为空,此字段就不会拼接到update语句
public interface SnapshotConverter {
/**
* 不清空的属性
*/
List<String> IGNORE_FIELDS = Arrays.asList("snapshot", "id", "version");
@AfterMapping
default void after(Object source, @MappingTarget Object target) {
if (source instanceof DmoSnapshot && target instanceof PoSnapshot) {
//领域层往持久层转换
Object snapshot = ((DmoSnapshot) source).getSnapshot();
((PoSnapshot) target).setSnapshot(snapshot);
//清空PO对象上没有改变的字段,mybatis对于空值不会持久化
cleanPO((PoSnapshot) target);
} else if (source instanceof PoSnapshot && target instanceof DmoSnapshot) {
//持久层往领域层转换
((DmoSnapshot) target).setSnapshot(source);
}
}
default void cleanPO(PoSnapshot target) {
Object snapshot = target.getSnapshot();
if (null == snapshot) {
return;
}
Class<?> clazz = target.getClass();
while (null != clazz) {
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
String fieldName = field.getName();
if (IGNORE_FIELDS.contains(fieldName)) {
continue;
}
field.setAccessible(true);
try {
Object newValue = field.get(target);
Object oldValue = field.get(snapshot);
if (Objects.equals(newValue, oldValue)) {
field.set(target, null);
}
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
clazz = clazz.getSuperclass();
}
}
}
4.DO-PO转换器继承SnapshotConverter
转换完毕后,会自动执行after方法
@Mapper
public interface CustomerDmo2PoConverter extends SnapshotConverter {
CustomerDmo2PoConverter INSTANCE = Mappers.getMapper(CustomerDmo2PoConverter.class);
@Mapping(target = "userName", source = "source.userName.userName")
@Mapping(target = "password", source = "source.password.password")
@Mapping(target = "phone", source = "source.phone.phone")
@Mapping(target = "email", source = "source.email.email")
CustomerPO toCustomerPO(Customer source);
@InheritInverseConfiguration(name = "toCustomerPO")
Customer toDmo(CustomerPO po);
}
5.转换方法使用
@Override
public Customer save(@NonNull Customer dmo) {
CustomerPO po = CustomerDmo2PoConverter.INSTANCE.toCustomerPO(dmo);
if (null == po.getId()) {
if (customerMapper.insert(po) == 0) {
throw new BException(RCodeEnum.PERSIST_OBJECT_ERROR);
}
dmo.setId(po.getId());
} else {
if (customerMapper.updateById(po) == 0) {
throw new BException(RCodeEnum.PERSIST_OBJECT_ERROR);
}
}
return dmo;
}
6.示例
以更新用户昵称为例
6.1 app层实现
应用层接收到入参为用户id和用户昵称,调用repo的findById
方法得到领域对象后,设置用户昵称,然后调用save方法持久化即可
@Service
public class CustomerAppService {
...
@Transactional(rollbackFor = Exception.class)
public Long updateNickName(UpdateNickNameCmd cmd) {
Customer customer = customerRepo.findById(cmd.getId());
customer.setNickName(cmd.getNickName());
customerRepo.save(customer);
return customer.getId();
}
}
6.2 repo实现
- repo层findById拿到PO对象后,调用
CustomerDmo2PoConverter
的toDmo
方法,得到领域对象的同时,也把PO作为镜像存储到DO中 - repo层的save方法,调用
CustomerDmo2PoConverter
的toCustomerPO
方法,将DO的镜像传给PO,同时清空PO的未变更属性
@Override
public Customer save(Customer customer) {
CustomerPO po = CustomerDmo2PoConverter.INSTANCE.toCustomerPO(customer);
...
do save
...
return customer;
}
@Override
public Customer findById(Serializable id) {
CustomerPO po = getById(id);
return CustomerDmo2PoConverter.INSTANCE.toDmo(po);
}
6.3 测试
可以看到,update语句,只更新nick_name这一个字段(update_time自动填充),再次在postman点击发送,值不变的情况下,只有自动填充的update_time被更新
//第一次请求,只更新昵称、时间、乐观锁版本号
selectById : ==> Preparing: SELECT id,user_name,nick_name,password,phone,email,create_time,update_time,version,deleted FROM cuc_customer WHERE id=? AND deleted=0
selectById : ==> Parameters: 1743815330685698049(String)
selectById : <== Total: 1
updateById : ==> Preparing: UPDATE cuc_customer SET nick_name=?, update_time=?, version=? WHERE id=? AND version=? AND deleted=0
updateById : ==> Parameters: 小土豆啊(String), 2024-01-07T15:00:06.157(LocalDateTime), 7(Integer), 1743815330685698049(Long), 6(Integer)
updateById : <== Updates: 1
//第二次请求,只更新时间、乐观锁版本号
selectById : ==> Preparing: SELECT id,user_name,nick_name,password,phone,email,create_time,update_time,version,deleted FROM cuc_customer WHERE id=? AND deleted=0
selectById : ==> Parameters: 1743815330685698049(String)
selectById : <== Total: 1
updateById : ==> Preparing: UPDATE cuc_customer SET update_time=?, version=? WHERE id=? AND version=? AND deleted=0
updateById : ==> Parameters: 2024-01-07T15:00:38.172(LocalDateTime), 8(Integer), 1743815330685698049(Long), 7(Integer)
updateById : <== Updates: 1
7.小结
- 本方案通过mapstruct和反射,保证了只更新必要字段;
- 但是因为清空了PO的非变更字段,持久化后,PO对象不可重新转换为DO对象,否则会造成属性不全,应通过重查DB得到新的镜像来执行相关操作;
- 也可以通过mybatis的切面在没生成sql的时候进行清除未变更字段的操作,这样对PO的影响更小。
- 项目源码