MyBatis-Plus 源码阅读(四)逻辑删除原理深度剖析

92 阅读4分钟

接上篇MyBatis-Plus 源码阅读(三)条件构造器原理深度剖析,本文我们讲解一下 MP 中逻辑删除的实现原理。

环境

  • 核心依赖:mybatis-plus-spring-boot3-starter 3.5.14
  • 基础框架:Spring Boot 3.5.6
  • JDK 版本:17
  • 开发工具:IntelliJ IDEA
  • 数据库:MySQL 8.0.31

前言

在公司开发,为了保证数据的安全性和可追溯性,逻辑删除几乎是一件必做的事,但实现逻辑删除的代价是出现很多重复的 sql 代码片段,这一点通过 MP 的逻辑删除功能能够轻松解决。

简单示例

笔者比较习惯添加一个 delete_timestamp 字段用来表示数据库记录是否删除,使用 bigint 类型。其中 0 表示记录正常,大于 0 表示记录被软删。

建表语句

create table user
(
    id               int auto_increment
        primary key,
    name             varchar(10) default '' not null,
    password         varchar(15) default '' not null,
    date             datetime               null,
    delete_timestamp bigint      default 0  not null comment '用户注销时的时间戳,0 表示用户未注销。',
    constraint uniq_name_deleteTimestamp
        unique (name, delete_timestamp)
);

用户的 name 字段需要保持唯一,因为添加了逻辑删除字段,所以原来的 name 的唯一键要修改为 namedelete_timestamp 的联合唯一键。

添加 MP 配置

mp 提供了两种逻辑删除配置的方式,一种是在 yml 文件添加全局配置,另一种是使用 @TableLogic 注解在单独的实体上配置。

本文采用第一种 yml 全局配置。 在 application.yml 添加下面的配置。

mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: deleteTimestamp  # 全局逻辑删除字段名(deleteTimestamp为实体类属性名称)
      logic-not-delete-value: 0            # 逻辑未删除值。可选,默认值为 0
      logic-delete-value: UNIX_TIMESTAMP() # 一个 MySQL 内置函数,用来表示当前的时间戳

使用 BaseMapper 提供的方法

现在 mp 就已经替你自动实现逻辑删除的功能了,我们像之前一样使用 BaseMapper 提供的方法。

@Test
public void testLogicDelete() {
    int num = userMapper.deleteById(1001);
    System.out.println(num);
}

通过控制台的 sql 日志,可以发现 mp 自动帮我们把 sql 改写为下面的形式了。

UPDATE user SET delete_timestamp=UNIX_TIMESTAMP() WHERE id=? AND delete_timestamp=0

源码分析

mp 逻辑删除的源码相对来说比较简单。其实就是给 BaseMapper 的每个方法准备了两套 sql 模板,如果开启了逻辑删除功能,就会采用另一套模板。

public enum SqlMethod {

    DELETE_BY_ID("deleteById", "根据ID 删除一条数据", "DELETE FROM %s WHERE %s=#{%s}"),
    // ...
    LOGIC_DELETE_BY_ID("deleteById", "根据ID 逻辑删除一条数据", "<script>\nUPDATE %s %s WHERE %s=#{%s} %s\n</script>"),
}

deleteById 方法为例,发现对应两个 SqlMethod 枚举对象。

进入 com.baomidou.mybatisplus.core.injector.methods.DeleteById#injectMappedStatement 方法,里面包含 deleteByIdSQL 注入的核心逻辑,发现会通过判断 tableInfo 是否开启逻辑删除选择不同的模板。

public class DeleteById extends AbstractMethod {
    // ...
    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        String sql;
        SqlMethod sqlMethod;
        if (tableInfo.isWithLogicDelete()) {
            sqlMethod = SqlMethod.LOGIC_DELETE_BY_ID;
            List<TableFieldInfo> fieldInfos = tableInfo.getFieldList().stream()
                .filter(TableFieldInfo::isWithUpdateFill)
                .filter(f -> !f.isLogicDelete())
                .collect(toList());
            if (CollectionUtils.isNotEmpty(fieldInfos)) {
                String sqlSet = "SET " + SqlScriptUtils.convertIf(fieldInfos.stream()
                    .map(i -> i.getSqlSet(EMPTY)).collect(joining(EMPTY)),
                    "@org.apache.ibatis.reflection.SystemMetaObject@forObject(_parameter).findProperty('" + tableInfo.getKeyProperty() + "', false) != null", true)
                    + tableInfo.getLogicDeleteSql(false, false);
                sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(), sqlSet, tableInfo.getKeyColumn(),
                    tableInfo.getKeyProperty(), tableInfo.getLogicDeleteSql(true, true));
            } else {
                sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(), sqlLogicSet(tableInfo),
                    tableInfo.getKeyColumn(), tableInfo.getKeyProperty(),
                    tableInfo.getLogicDeleteSql(true, true));
            }
            SqlSource sqlSource = super.createSqlSource(configuration, sql, Object.class);
            return addUpdateMappedStatement(mapperClass, modelClass, methodName, sqlSource);
        } else {
            sqlMethod = SqlMethod.DELETE_BY_ID;
            sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(), tableInfo.getKeyColumn(),
                tableInfo.getKeyProperty());
            return this.addDeleteMappedStatement(mapperClass, methodName, super.createSqlSource(configuration, sql, Object.class));
        }
    }
}

简单 debug 会发现代码走到了第 21 行,即

sql = String.format(sqlMethod.getSql(),
    tableInfo.getTableName(),
    sqlLogicSet(tableInfo), // 逻辑删除的 set 部分
    tableInfo.getKeyColumn(),
    tableInfo.getKeyProperty(),
    tableInfo.getLogicDeleteSql(true, true)); // 逻辑删除的 where 部分

值得关注的是 sqlLogicSet(tableInfo)tableInfo.getLogicDeleteSql(true, true)) 两部分。 前者得到了 SET delete_timestamp=UNIX_TIMESTAMP() 这样的 sql 片段,后者得到了 AND delete_timestamp=0

往下深入会发现这两个方法都调用了 com.baomidou.mybatisplus.core.metadata.TableInfo#formatLogicDeleteSql 方法

protected String formatLogicDeleteSql(boolean isWhere) {
    final String value = isWhere ? logicDeleteFieldInfo.getLogicNotDeleteValue() : logicDeleteFieldInfo.getLogicDeleteValue();
    if (isWhere) {
        if (NULL.equalsIgnoreCase(value)) {
            return logicDeleteFieldInfo.getColumn() + " IS NULL";
        } else {
            return logicDeleteFieldInfo.getColumn() + EQUALS + String.format(logicDeleteFieldInfo.isCharSequence() ? "'%s'" : "%s", value);
        }
    }
    final String targetStr = logicDeleteFieldInfo.getColumn() + EQUALS;
    if (NULL.equalsIgnoreCase(value)) {
        return targetStr + NULL;
    } else {
        return targetStr + String.format(logicDeleteFieldInfo.isCharSequence() ? "'%s'" : "%s", value);
    }
}

这个方法的入参 isWhere 的意思是返回的 SQL 片段是否在 where 后面。如果 isWheretrue,说明返回的 SQL 片段在 where 后面,那么需要使用逻辑未删除的值(因为我们查找或更新时肯定希望查找到的记录是未被软删的);反之需要使用逻辑删除的值(因为不在 where后面就一定在 set 后面,而 set 说明需要进行软删,所以需要使用逻辑删除的值,即我们配置的 UNIX_TIMESTAMP()

结语

MP 逻辑删除是一个非常简单但实用的功能,仅仅通过多准备一组 sql 模板就解决了让无数程序员头疼的重复 SQL 问题,让人感叹作者的智慧。

如果这篇文章对你理解「逻辑删除原理」有帮助,麻烦点赞 + 关注支持一下~ 你的认可就是我持续输出源码解析系列的最大动力!❤️❤️❤️🤩🤩🤩🚀🚀🚀

文章源码地址