在DDD中,由于我们操作的聚合根对象,比如我们更新用户昵称这个字段,然后在持久化的过程中,执行的是全量的持久化
vbnet
selectById : ==> Preparing: SELECT id,user_name,nick_name,password,phone,email FROM cuc_customer WHERE id=?
selectById : ==> Parameters: 1743128870491926530(String)
selectById : <== Total: 1
updateById : ==> Preparing: UPDATE cuc_customer SET user_name=?, nick_name=?, password=?, phone=?, email=? WHERE id=?
updateById : ==> Parameters: chenzl(String), 小土豆啊(String), 123456(String), 18112341232(String), chenzl@qq.com(String), 1743128870491926530(Long)
updateById : <== Updates: 1
这样会带来大量无效的update语句,导致binlog暴增,给数据同步带来不必要的开销,因此仅更新改动过的字段变得非常必要。
1.设计思路
本文提供一种基于mapstruct和反射的快照(snapshot)方案来解决此类问题。

2.接口定义
- 为了区分领域对象和持久化对象,分别定义2个接口,由领域对象和持久化对象失败各自的接口
- 其中持久化对象接口除了镜像外,还定义了一个changed的get/set方法,用于标记持久化对象是否发生了变更。
java
public interface DmoSnapshot {
Object getSnapshot();
void setSnapshot(Object snapshot);
}
java
public interface PoSnapshot {
Object getSnapshot();
void setSnapshot(Object snapshot);
Boolean getChanged();
void setChanged(Boolean changed);
}
3.mapstruct实现SnapshotConverter
- 首先定义了部分字段是不能被清空的,即
snapshot
,changed
,id
,version
- 通过
@AfterMapping
定义方法,保证所有DO/PO互转完毕后,能执行此方法 - 通过判断接口是否实现,来认定是DO->PO还是PO->DO
- DO->PO转换时,获取DO的镜像,设置给PO,调用
cleanPO
方法清空PO的未修改字段 - PO->DO转换时,直接将PO自身作为镜像传递给DO。
- clearPO方法中,当镜像为空时,即非通过标准DB查询方法转换得到的DO,或者新建的领域对象,认为是修改的对象;镜像不为空时,通过反射拿到每个属性的新值和旧值,当新旧=旧值,设置属性为空,当新值!=旧值时,设置changed=true,标记这个对象需要进行持久化
java
public interface SnapshotConverter {
/**
* 不清空的属性
*/
List<String> IGNORE_FIELDS = Arrays.asList("snapshot", "changed", "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) {
target.setChanged(true);
return;
}
Field[] fields = target.getClass().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);
} else {
target.setChanged(true);
}
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
}
4.DO-PO转换器继承SnapshotConverter
转换完毕后,会自动执行after方法
java
@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.转换方法使用
在save或其他更新方法时,转换完成后,如果changed标记为false,直接返回成功,不继续持久化
ini
@Override
public Customer save(Customer customer) {
CustomerPO po = CustomerDmo2PoConverter.INSTANCE.toCustomerPO(customer);
if (!po.getChanged()) {
return customer;
}
boolean success;
if (null == po.getId()) {
success = save(po);
if (success) {
customer.setId(po.getId());
}
} else {
success = updateById(po);
}
if (!success) {
throw new BException(RCodeEnum.PERSIST_OBJECT_ERROR);
}
return customer;
}
6.示例
以更新用户昵称为例
6.1 app层实现
应用层接收到入参为用户id和用户昵称,调用repo的findById
方法得到领域对象后,设置用户昵称,然后调用save方法持久化即可
java
@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的未变更属性
java
@Override
public Customer save(Customer customer) {
CustomerPO po = CustomerDmo2PoConverter.INSTANCE.toCustomerPO(customer);
if (!po.getChanged()) {
return customer;
}
...
return customer;
}
@Override
public Customer findById(Serializable id) {
CustomerPO po = getById(id);
return CustomerDmo2PoConverter.INSTANCE.toDmo(po);
}
6.3 测试
可以看到,update语句,只更新nick_name这一个字段,再次在postman点击发送,值不变的情况下,没有执行update语句。
yaml
selectById : ==> Preparing: SELECT id,user_name,nick_name,password,phone,email FROM cuc_customer WHERE id=?
selectById : ==> Parameters: 1743128870491926530(String)
selectById : <== Total: 1
updateById : ==> Preparing: UPDATE cuc_customer SET nick_name=? WHERE id=?
updateById : ==> Parameters: 小土豆(String), 1743128870491926530(Long)
updateById : <== Updates: 1
7.小结
- 本方案通过mapstruct和反射,保证了只更新必要字段;
- 但是因为清空了PO的非变更字段,持久化后,PO对象不可重新转换为DO对象,否则会造成属性不全,应通过重查DB得到新的镜像来执行相关操作;
- 也可以通过mybatis的切面在没生成sql的时候进行清除未变更字段的操作,这样对PO的影响更小。
- 项目源码