(二)边学边用DDD-Repository镜像实现

475 阅读5分钟

在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)方案来解决此类问题。

snapshot原理.drawio.png

2.接口定义

  1. 为了区分领域对象和持久化对象,分别定义2个接口,由领域对象和持久化对象实现各自的接口
public interface DmoSnapshot {

    Object getSnapshot();

    void setSnapshot(Object snapshot);
}
public interface PoSnapshot {

    Object getSnapshot();

    void setSnapshot(Object snapshot);
}

3.mapstruct实现SnapshotConverter

  1. 首先定义了部分字段是不能被清空的,即snapshot, id, version
  2. 通过@AfterMapping定义方法,保证所有DO/PO互转完毕后,能执行此方法
  3. 通过判断接口是否实现,来认定是DO->PO还是PO->DO
  4. DO->PO转换时,获取DO的镜像,设置给PO,调用cleanPO方法清空PO的未修改字段
  5. PO->DO转换时,直接将PO自身作为镜像传递给DO。
  6. 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实现

  1. repo层findById拿到PO对象后,调用CustomerDmo2PoConvertertoDmo方法,得到领域对象的同时,也把PO作为镜像存储到DO中
  2. repo层的save方法,调用CustomerDmo2PoConvertertoCustomerPO方法,将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被更新 image.png

//第一次请求,只更新昵称、时间、乐观锁版本号
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.小结

  1. 本方案通过mapstruct和反射,保证了只更新必要字段;
  2. 但是因为清空了PO的非变更字段,持久化后,PO对象不可重新转换为DO对象,否则会造成属性不全,应通过重查DB得到新的镜像来执行相关操作;
  3. 也可以通过mybatis的切面在没生成sql的时候进行清除未变更字段的操作,这样对PO的影响更小。
  4. 项目源码