基于mybatis-plus的逻辑删

本文章已同步分享在CSDN、、掘金,作为原创记录凭证。转载请标明出处,谢谢。
个人博客,欢迎访问☺

1 前言

本文介绍基于mybatis-plus( 版本:3.3.1 ) 的逻辑删处理办法,同时考虑到对关系数据库唯一索引的兼容。

许多公司都要求保留历史数据不能真的彻底删掉,像我所待过的公司,就要求使用逻辑删。逻辑删指的类似添加 is_deleted 字段,通过 n/y 来标识是否被删除。逻辑删的出发点是好的,但如果被删除的数据还保留在关系数据库原表时,事情就开始变复杂了。

关系数据库中许多表都会添加唯一索引,以确保业务相关数据的唯一性 ( 由数据库确保业务数据唯一性是最省事简单的 )。当相同的数据被删除后再次新增,然后再次删除,会导致数据库报错:唯一键重复。也就是说,仅靠 is_deleted=n/y 会导致唯一索引不可用。

如下示例:

# 表定义
create table user (
    id           char(64)          primary key comment '主键',
    name         varchar(80)       not null comment '姓名',
    id_card      char(60)          not null comment '身份证号',
    is_deleted   char(1)           not null comment '逻辑删标识n/y',
    unique index uk_id_card(`id_card`, `is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='用户表';

# 下面这样的数据理应是正常的,但由于唯一索引的存在,导致业务本身受到影响
--------------------------------------
|  id  | name | id_card | is_deleted |
--------------------------------------
|  xx1 |  z1  | xxx001  |      n     |  # 有效
--------------------------------------
|  xx1 |  z1  | xxx001  |      y     |  # 已删除
--------------------------------------
|  xx1 |  z1  | xxx001  |      y     |  # 已删除

唯一索引是必须的,阿里java规约有这样的说明:

【强制】业务上具有唯一特性的字段,即使是多个字段的组合,也必须建成唯一索引。

说明:不要以为唯一索引影响了 insert 速度,这个速度损耗可以忽略,但提高查找速度是明显的;另外,即使在应用层做了非常完善的校验控制,只要没有唯一索引,根据墨菲定律,必然有脏数据产生。

2 兼容逻辑删和唯一索引的思路

2.1 修改删除标志的赋值

当数据被逻辑删后,不再使用 is_deleted = y,而是使用 is_deleted = {uuid}

# 表定义
create table user (
    id           char(64)          primary key comment '主键',
    name         varchar(80)       not null comment '姓名',
    id_card      char(60)          not null comment '身份证号',
    is_deleted   unsigned tinyint  not null comment '逻辑删标识n/y',
    unique index uk_id_card(`id_card`, `is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='用户表';

--------------------------------------
|  id  | name | id_card | is_deleted |
--------------------------------------
|  xx1 |  z1  | xxx001  |      n     |  # 有效
--------------------------------------
|  xx1 |  z1  | xxx001  |   {uuid}   |  # 已删除
--------------------------------------
|  xx1 |  z1  | xxx001  |   {uuid}   |  # 已删除

2.2 添加辅助删除标志字段

is_deleted = n/y 依然不变,另外增加 delete_token = N/A ( 未删除 )、delete_token = {uuid} ( 已删除 )。

# 表定义
create table user (
    id           char(64)          primary key comment '主键',
    name         varchar(80)       not null comment '姓名',
    id_card      char(60)          not null comment '身份证号',
    is_deleted   unsigned tinyint  not null comment '逻辑删标识n/y',
    unique index uk_id_card(`id_card`, `is_deleted`, `delete_token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='用户表';

-----------------------------------------------------
|  id  | name | id_card | is_deleted | delete_token |
-----------------------------------------------------
|  xx1 |  z1  | xxx001  |      n     |      N/A     |  # 有效
-----------------------------------------------------
|  xx1 |  z1  | xxx001  |      y     |     {uuid}   |  # 已删除
-----------------------------------------------------
|  xx1 |  z1  | xxx001  |      y     |     {uuid}   |  # 已删除

3 逻辑删分析+处理

此文章基于mybatis-plus 3.3.1进行分析。mybatis-plus是基于myabtis的增强工具。

3.1 mybatis-plus执行流程

3.1.1 流程图

mybatis-plus在springboot中的加载流程

mybatisplus-process

3.1.2 关键流程分析

经过上面的流程图,可以大致了解(mybatis、mybatis-plus)的加载流程。我们使用基于mybatis-plus的删除操作时,一般会直接或间接使用 BaseMapper.delete*() 方法。

mybatis-plus官方文档的逻辑删章节提到:使用Sql注入器注入 LogicDeleteByIdWithFill 并使用(推荐)。因此我们可以参考该实现进行改写。

上图中红色部分是我们需要进行改造的流程点。它的作用是构造一个与 xxMapper.method? 对应的 MappedStatement 对象,存放到 MybatisConfiguration 中,以便后续由业务调用 xxMapper.method? 时使用。

/**
 * mybatis-plus使用的默认sql注入器
 */
public class DefaultSqlInjector extends AbstractSqlInjector {

    /**
     * 可以看出,集合中的每个对象,都对应BaseMapper接口的一个方法定义
     */
    @Override
    public List getMethodList(Class mapperClass) {
        return Stream.of(
            new Insert(),
            new Delete(),
            new DeleteByMap(),
            new DeleteById(),
            new DeleteBatchByIds(),
            new Update(),
            new UpdateById(),
            new SelectById(),
            new SelectBatchByIds(),
            new SelectByMap(),
            new SelectOne(),
            new SelectCount(),
            new SelectMaps(),
            new SelectMapsPage(),
            new SelectObjs(),
            new SelectList(),
            new SelectPage()
        ).collect(toList());
    }

}

下面介绍mybatis-plus是如何加载和使用Sql注入器:

加载Sql注入器:

public class MybatisPlusAutoConfiguration implements InitializingBean {

    @Bean
    @ConditionalOnMissingBean
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
        // ...
        // 加载容器中的ISqlInjector,如果不存在,则使用DefaultSqlInjector
        this.getBeanThen(ISqlInjector.class, globalConfig::setSqlInjector);
        // ...
    }

}

使用Sql注入器:

上图中有介绍调用流程:MapperFactoryBean.checkDaoConfig() --> configuration.addMapper(Class) --> MybatisMapperRegistry.addMapper() --> MybatisMapperAnnotationBuilder.parse()

public class MybatisMapperAnnotationBuilder extends MapperAnnotationBuilder {

    @Override
    public void parse() {
        String resource = type.toString();
        if (!configuration.isResourceLoaded(resource)) {
            // 尝试加载xml中对应的sql节点
            loadXmlResource();
            // ...
            // 获取xxMapper接口的方法定义
            Method[] methods = type.getMethods();
            for (Method method : methods) {
                try {
                    // issue #237
                    if (!method.isBridge()) {
                        // 如果该方法存在对应的xml sql节点,则解析节点,添加MappedStatement
                        parseStatement(method);
                        // ...
                    }
                } catch (IncompleteElementException e) {
                    // ...
                }
            }
            // ########## 关键方法(.inspectInject()) ##########
            // 如果xxMapper继承了Mapper接口,则使用sql注入器绑定MappedStatement
            if (GlobalConfigUtils.isSupperMapperChildren(configuration, type)) {
                // 获取MybatisConfiguration中注入的sql注入器,并进行处理
                GlobalConfigUtils.getSqlInjector(configuration).inspectInject(assistant, type);
            }
        }
        parsePendingMethods();
    }

}
public abstract class AbstractSqlInjector implements ISqlInjector {

    @Override
    public void inspectInject(MapperBuilderAssistant builderAssistant, Class mapperClass) {
        // ...
        // 获取SQL注入器中包含的方法集合:new Insert()、new Delete()、...
        List methodList = this.getMethodList(mapperClass);
        // 构建实体对应的table信息,该类有个关键方法,后续会用到:TableInfo.getLogicDeleteSql
        TableInfo tableInfo = TableInfoHelper.initTableInfo(builderAssistant, modelClass);
        // ########## 关键方法(m.inject) ##########
        // 循环注入自定义方法,构建MappedStatement,最终添加到MybatisConfiguratiion.mappedStatements
        methodList.forEach(m -> m.inject(builderAssistant, mapperClass, modelClass, tableInfo));
        // ...
    }

}

接下来就是具体的执行方法体根据各自需求来构建MappedStatement,以mybatis-plus的delete方法为例:

public class Delete extends AbstractMethod {

    /**
     * 注入自定义方法
     * PS: 该方法属于AbstractMethod,放到这里,方便展示
     */
    public void inject(MapperBuilderAssistant builderAssistant, Class mapperClass, Class modelClass, TableInfo tableInfo) {
        this.configuration = builderAssistant.getConfiguration();
        this.builderAssistant = builderAssistant;
        this.languageDriver = configuration.getDefaultScriptingLanguageInstance();
        // ########## 关键方法 ##########
        injectMappedStatement(mapperClass, modelClass, tableInfo);
    }

    @Override
    public MappedStatement injectMappedStatement(Class mapperClass, Class modelClass, TableInfo tableInfo) {
        String sql;
        // 重要的常量,标明了delete sql的构造格式 ""
        // ""
        SqlMethod sqlMethod = SqlMethod.LOGIC_DELETE;
        // 如果使用逻辑删
        if (tableInfo.isLogicDelete()) {
            // 
            sql = String.format(
                sqlMethod.getSql(),                      // 字符串模板
                tableInfo.getTableName(),                // tableName
                sqlLogicSet(tableInfo),                  // set语句  ####关键部分####
                sqlWhereEntityWrapper(true, tableInfo),  // where语句
                sqlComment());                           // 注释
            // 像读取xml的sql节点一样,解析该xml文本,构造sqlSource
            SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass);
            // 构造MappedStatement
            return addUpdateMappedStatement(mapperClass, modelClass, getMethod(sqlMethod), sqlSource);
        } else {
            // ...
        }
    }

}

参考上述默认的mybatis-plus.Delete对象,可以了解其构造MappedStatement的过程。逻辑删的关键部分是 sqlLogicSet(tableInfo)我们可以通过对其进行改写,从而达到兼容唯一索引的目的。

3.2 改造分析

通过章节3.1,对mybatis-plus的整个加载流程有了大致的了解,也为后续的改造指明了方向。BaseMapper.delete*() 方法默认情况下绑定类有:

  • com.baomidou.mybatisplus.core.injector.methods.Delete
  • com.baomidou.mybatisplus.core.injector.methods.DeleteByMap
  • com.baomidou.mybatisplus.core.injector.methods.DeleteById
  • com.baomidou.mybatisplus.core.injector.methods.DeleteBatchByIds

这些类的 injectMappedStatement() 都有一个共同的特点,构造sql语句时调用了 sqlLogicSet(tableInfo)。我们只需要重构该方法的调用。

3.3 完整代码

以章节2中的第二种思路 ( 添加辅助删除标识字段 ) 为例,两种思路写法非常类似。

注意事项:该代码暂不支持基于 UpdateWrapper 等方式的逻辑删处理。要么使用UpdateWrapper时手动处理,要么进一步改写Sql注入器中的 Update*

代码结构预览:

|---- config
|   |---- MybatisPlusConfiguration.java
|   |---- mybatisplus
|       |---- BaseEntityFieldsFillHandler.java     // 实体类字段适配
|       |---- MySqlInjector.java                   // 自定义sql注入器
|       |---- LogicDeleteSqlWrapper.java           // 逻辑删包装处理类
|       |
|       |---- method                               // 自定义sql方法实现
|       |   |---- Delete.java
|       |   |---- DeleteByMap.java
|       |   |---- DeleteById.java
|       |   |---- DeleteBatchByIds.java
|---- entity
    |---- BaseEntity.java

3.3.1 DbAutoConfiguration.java

import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
import com.baomidou.mybatisplus.autoconfigure.MybatisPlusLanguageDriverAutoConfiguration;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.core.injector.AbstractSqlInjector;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 配置类
 * @author gdzwk
 */
@Slf4j
@Configuration
public class MybatisPlusConfiguration {

    /**
     * 使用自定义sql注入器
     */
    @Bean
    public AbstractSqlInjector sqlInjector() {
        AbstractSqlInjector sqlInjector = new MySqlInjector();
        log.info("db sqlInjector finished.");
        return sqlInjector;
    }

    /**
     * BaseEntity 属性值处理
     */
    @Bean
    public MetaObjectHandler metaObjectHandler() {
        return new BaseEntityFieldsFillHandler();
    }

}

3.3.2 BaseEntity.java

/**
 * 实体 基类
 * @author gdzwk
 */
@Data
@EqualsAndHashCode
public abstract class BaseEntity implements Serializable {
    private static final long serialVersionUID = -6814276315761594505L;

    /**
     * 逻辑删标识
     */
    @TableField(value = "is_deleted", fill = FieldFill.INSERT_UPDATE)
    @TableLogic(value = "n", delval = "y")
    private String deleted;

    /**
     * PS: 该字段与{@link #deleted}字段配合,方便表在表中加唯一索引
     *      row1: [..., deleted: 'n', deleteToken: 'N/A']
     *      row2: [..., deleted: 'y', deleteToken: 'UUID']
     *      唯一索引: 业务字段 + deleted + deleteToken
     */
    @TableField(value = "delete_token", fill = FieldFill.INSERT_UPDATE)
    private String deleteToken;

    // ...其余字段

}

3.3.3 BaseEntityFieldsFillHandler.java

/**
 * {@link BaseEntity}字段自适配
 * @author gdzwk
 */
public class BaseEntityFieldsFillHandler implements MetaObjectHandler {

    private static final String FIELD_DELETED = "deleted";

    private static final String FIELD_DELETE_TOKEN = "deleteToken";

    // ...

    /**
     * 插入元对象字段填充(用于插入时对公共字段的填充)
     *
     * @param metaObject 元对象
     */
    @Override
    public void insertFill(MetaObject metaObject) {
        // 逻辑删
        this.strictInsertFill(metaObject, FIELD_DELETED, String.class, "n");
        // 删除标记
        this.strictInsertFill(metaObject, FIELD_DELETE_TOKEN, String.class, "N/A");
        // ...
    }

    /**
     * 更新元对象字段填充(用于更新时对公共字段的填充)
     *
     * @param metaObject 元对象
     */
    @Override
    public void updateFill(MetaObject metaObject) {
    }

}

3.3.4 MySqlInjector.java

import com.baomidou.mybatisplus.core.injector.AbstractMethod;
import com.baomidou.mybatisplus.core.injector.AbstractSqlInjector;
import com.baomidou.mybatisplus.core.injector.methods.*;

import java.util.List;
import java.util.stream.Stream;

import static java.util.stream.Collectors.toList;

/**
 * 自定义SQL注入器
 * @author gdzwk
 */
public class MySqlInjector extends AbstractSqlInjector {

    @Override
    public List getMethodList(Class mapperClass) {
        return Stream.of(
            new Insert(),
            new xx.config.mybatisplus.method.Delete(),           // 自定义
            new xx.config.mybatisplus.method.DeleteByMap(),      // 自定义
            new xx.config.mybatisplus.method.DeleteById(),       // 自定义
            new xx.config.mybatisplus.method.DeleteBatchByIds(), // 自定义
            new Update(),
            new UpdateById(),
            new SelectById(),
            new SelectBatchByIds(),
            new SelectByMap(),
            new SelectOne(),
            new SelectCount(),
            new SelectMaps(),
            new SelectMapsPage(),
            new SelectObjs(),
            new SelectList(),
            new SelectPage()
        ).collect(toList());
    }

}

3.3.5 LogicDeleteSqlWrapper.java

import com.baomidou.mybatisplus.core.injector.AbstractMethod;
import com.baomidou.mybatisplus.core.metadata.TableFieldInfo;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import com.baomidou.mybatisplus.core.toolkit.ExceptionUtils;
import lombok.extern.slf4j.Slf4j;

import java.lang.reflect.Field;
import java.sql.Driver;
import java.sql.DriverManager;
import java.util.Enumeration;
import java.util.List;

/**
 * 逻辑删包装处理类
 * @author gdzwk
 */
public class LogicDeleteSqlWrapper {

    // 获取这些信息,便于逻辑删的处理
    private TableInfo tableInfo;
    private Class entityType;
    private List fieldList;
    private String tableName;
    private boolean logicDelete;

    public LogicDeleteSqlWrapper(TableInfo tableInfo) {
        this.tableInfo = tableInfo;
        try {
            Field field0 = TableInfo.class.getDeclaredField("entityType");
            field0.setAccessible(true);
            this.entityType = (Class) field0.get(tableInfo);

            Field field1 = TableInfo.class.getDeclaredField("fieldList");
            field1.setAccessible(true);
            this.fieldList = (List) field1.get(tableInfo);

            Field field2 = TableInfo.class.getDeclaredField("tableName");
            field2.setAccessible(true);
            this.tableName = (String) field2.get(tableInfo);

            Field field3 = TableInfo.class.getDeclaredField("logicDelete");
            field3.setAccessible(true);
            this.logicDelete = (boolean) field3.get(tableInfo);

        } catch (NoSuchFieldException | IllegalAccessException e) {
            log.error("访问TableInfo.fieldList属性失败", e);
        }
    }

    /**
     * 来源于{@link AbstractMethod#sqlLogicSet(TableInfo)},
     * 并进行改写以适应项目的逻辑删需求
     */
    public String sqlLogicSet() {
        if (BaseEntity.class.isAssignableFrom(entityType)) {
            return "SET " + this.getLogicDeleteSql(false, false);
        } else {
            return "SET " + tableInfo.getLogicDeleteSql(false, false);
        }
    }

    /**
     * 给逻辑删语句片段添加额外的赋值处理
     * @param startWithAnd 该sql片段是否需要以AND开头
     * @param isWhere 该sql片段是否为where条件中的语句
     * @return 添加额外赋值处理的逻辑删语句片段
     */
    private String getLogicDeleteSql(boolean startWithAnd, boolean isWhere) {
        if (logicDelete) {
            TableFieldInfo field = fieldList.stream().filter(TableFieldInfo::isLogicDelete).findFirst()
                .orElseThrow(() -> ExceptionUtils.mpe("can't find the logicFiled from table {%s}", tableName));
            String logicDeleteSql = formatLogicDeleteSql(field, isWhere);
            if (startWithAnd) {
                logicDeleteSql = " AND " + logicDeleteSql;
            }
            if (BaseEntity.class.isAssignableFrom(entityType)) {
                logicDeleteSql += String.format(", delete_token='%s' ", UUID.randomUUID().toString().replace("-", "").toUpperCase());
            }
            return logicDeleteSql;
        }
        return TableInfo.EMPTY;
    }

    /**
     * 来源于{@link TableInfo#formatLogicDeleteSql(TableFieldInfo, boolean)},
     * 没有任何变化,仅为方便调用
     */
    private String formatLogicDeleteSql(TableFieldInfo field, boolean isWhere) {
        final String value = isWhere ? field.getLogicNotDeleteValue() : field.getLogicDeleteValue();
        if (isWhere) {
            if (TableInfo.NULL.equalsIgnoreCase(value)) {
                return field.getColumn() + " IS NULL";
            } else {
                return field.getColumn() + TableInfo.EQUALS + String.format(field.isCharSequence() ? "'%s'" : "%s", value);
            }
        }
        final String targetStr = field.getColumn() + TableInfo.EQUALS;
        if (TableInfo.NULL.equalsIgnoreCase(value)) {
            return targetStr + TableInfo.NULL;
        } else {
            return targetStr + String.format(field.isCharSequence() ? "'%s'" : "%s", value);
        }
    }
}

3.3.6 SQL方法实现

3.3.6.1 Delete.java

import com.baomidou.mybatisplus.core.enums.SqlMethod;
import com.baomidou.mybatisplus.core.injector.AbstractMethod;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlSource;

/**
 * 根据 entity 条件删除记录
 * PS: 参照{@link com.baomidou.mybatisplus.core.injector.methods.Delete},并改写sqlLogic
 * @author gdzwk
 */
public class Delete extends AbstractMethod {

    @Override
    public MappedStatement injectMappedStatement(Class mapperClass, Class modelClass, TableInfo tableInfo) {
        String sql;
        SqlMethod sqlMethod = SqlMethod.LOGIC_DELETE;
        if (tableInfo.isLogicDelete()) {
            sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(), new LogicDeleteSqlWrapper(tableInfo).sqlLogicSet(),
                sqlWhereEntityWrapper(true, tableInfo),
                sqlComment());
            SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass);
            return addUpdateMappedStatement(mapperClass, modelClass, getMethod(sqlMethod), sqlSource);
        } else {
            sqlMethod = SqlMethod.DELETE;
            sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(),
                sqlWhereEntityWrapper(true, tableInfo),
                sqlComment());
            SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass);
            return this.addDeleteMappedStatement(mapperClass, getMethod(sqlMethod), sqlSource);
        }
    }
}

3.3.6.2 DeleteByMap.java

import com.baomidou.mybatisplus.core.enums.SqlMethod;
import com.baomidou.mybatisplus.core.injector.AbstractMethod;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlSource;

import java.util.Map;

/**
 * 根据 entity 条件删除记录
 * PS: 参照{@link com.baomidou.mybatisplus.core.injector.methods.DeleteByMap},并改写sqlLogic
 * @author gdzwk
 */
public class DeleteByMap extends AbstractMethod {

    @Override
    public MappedStatement injectMappedStatement(Class mapperClass, Class modelClass, TableInfo tableInfo) {
        String sql;
        SqlMethod sqlMethod = SqlMethod.LOGIC_DELETE_BY_MAP;
        if (tableInfo.isLogicDelete()) {
            sql = String.format(
                    sqlMethod.getSql(),
                    tableInfo.getTableName(),
                    new LogicDeleteSqlWrapper(tableInfo).sqlLogicSet(),
                    sqlWhereByMap(tableInfo));
            SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, Map.class);
            return addUpdateMappedStatement(mapperClass, Map.class, getMethod(sqlMethod), sqlSource);
        } else {
            sqlMethod = SqlMethod.DELETE_BY_MAP;
            sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(), this.sqlWhereByMap(tableInfo));
            SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, Map.class);
            return this.addDeleteMappedStatement(mapperClass, getMethod(sqlMethod), sqlSource);
        }
    }
}

3.3.6.3 DeleteById.java

import com.baomidou.mybatisplus.core.enums.SqlMethod;
import com.baomidou.mybatisplus.core.injector.AbstractMethod;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlSource;

/**
 * 根据 entity 条件删除记录
 * PS: 参照{@link com.baomidou.mybatisplus.core.injector.methods.DeleteById},并改写sqlLogic
 * @author gdzwk
 */
public class DeleteById extends AbstractMethod {

    @Override
    public MappedStatement injectMappedStatement(Class mapperClass, Class modelClass, TableInfo tableInfo) {
        String sql;
        SqlMethod sqlMethod = SqlMethod.LOGIC_DELETE_BY_ID;
        if (tableInfo.isLogicDelete()) {
            sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(), new LogicDeleteSqlWrapper(tableInfo).sqlLogicSet(),
                tableInfo.getKeyColumn(), tableInfo.getKeyProperty(),
                tableInfo.getLogicDeleteSql(true, true));
            SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, Object.class);
            return addUpdateMappedStatement(mapperClass, modelClass, getMethod(sqlMethod), sqlSource);
        } else {
            sqlMethod = SqlMethod.DELETE_BY_ID;
            sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(), tableInfo.getKeyColumn(),
                tableInfo.getKeyProperty());
            SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, Object.class);
            return this.addDeleteMappedStatement(mapperClass, getMethod(sqlMethod), sqlSource);
        }
    }
}

3.3.6.4 DeleteBatchByIds.java

import com.baomidou.mybatisplus.core.enums.SqlMethod;
import com.baomidou.mybatisplus.core.injector.AbstractMethod;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import com.baomidou.mybatisplus.core.toolkit.sql.SqlScriptUtils;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlSource;

/**
 * 根据 entity 条件删除记录
 * PS: 参照{@link com.baomidou.mybatisplus.core.injector.methods.DeleteBatchByIds},并改写sqlLogic
 * PS: 虽然批量删除时,由指定了相同的UUID,但就该批次数据来说,不会和别的有冲突
 * @author gdzwk
 */
public class DeleteBatchByIds extends AbstractMethod {

    @Override
    public MappedStatement injectMappedStatement(Class mapperClass, Class modelClass, TableInfo tableInfo) {
        String sql;
        SqlMethod sqlMethod = SqlMethod.LOGIC_DELETE_BATCH_BY_IDS;
        if (tableInfo.isLogicDelete()) {
            sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(), new LogicDeleteSqlWrapper(tableInfo).sqlLogicSet(),
                tableInfo.getKeyColumn(),
                SqlScriptUtils.convertForeach("#{item}", COLLECTION, null, "item", COMMA),
                tableInfo.getLogicDeleteSql(true, true));
            SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, Object.class);
            return addUpdateMappedStatement(mapperClass, modelClass, getMethod(sqlMethod), sqlSource);
        } else {
            sqlMethod = SqlMethod.DELETE_BATCH_BY_IDS;
            sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(), tableInfo.getKeyColumn(),
                SqlScriptUtils.convertForeach("#{item}", COLLECTION, null, "item", COMMA));
            SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, Object.class);
            return this.addDeleteMappedStatement(mapperClass, getMethod(sqlMethod), sqlSource);
        }
    }
}

4 逻辑删后的查询处理

前面的章节已完成对兼容唯一索引的逻辑删分析与实现,但一个完整系统中,除了逻辑删除这个动作外,还需要考虑到查询时如何屏蔽掉被删除的数据。

4.1 阐述疑问

  • 对单表的屏蔽:

    • 基于mybatis-plus wrapper的单表查询操作处理还好说点,可以自定义改写,或框架提供相应的处理。
    • 对于xml文件中的单表查询(特别是动态sql),貌似可以直接拼接"and is_deleted = 'n'",但如何确保拼接的sql是绝对没有语法问题的?
  • 对于多表联查的屏蔽:

    • 即便能给最外层拼接“and is_deleted = 'n'”,对于内层子查询,没过滤被删数据,会导致查询量增大

    • 更进一步的处理?例如分析sql语句,生成AST抽象语法树,从而做到对内外层查询均添加"and is_deleted = 'n'"

4.2 分析+处理

项目中进行查询的方式有很多种,主要分为使用BaseMapper.select*()、使用xml文件等方式,需要逐一确认。

4.2.1 BaseMapper.select*()方法

IService.select*()IService.count*()IService.lambdaQuery()IService.page*() 等方法底层均是调用 BaseMapper.select*()

下面就以 com.baomidou.mybatisplus.core.injector.methods.SelectList 类为例进行说明 ( 通过查看源码,BaseMapper.select*() 均已实现自动屏蔽逻辑删除的数据 )。

public class SelectList extends AbstractMethod {

    /**
     * ##### 关键方法是super.sqlWhereEntityWrapper() -- where条件语句 #####
     */
    @Override
    public MappedStatement injectMappedStatement(Class mapperClass, Class modelClass, TableInfo tableInfo) {
        SqlMethod sqlMethod = SqlMethod.SELECT_LIST;
        String sql = String.format(sqlMethod.getSql(), 
            ??,
            ??,
            ??,
            sqlWhereEntityWrapper(true, tableInfo), sqlComment());
        // ...
    }

    /**
     * ##### 将父类的方法放到这里,方便查看 #####
     * 从下面的条件判断就可以知道,如果相关的实体类上有@TableLogic,就会启用逻辑删过滤
     */
    protected String sqlWhereEntityWrapper(boolean newLine, TableInfo table) {
        if (table.isLogicDelete()) {
            // ...
                } else {
            // ...
        }
    }

}

4.2.2 基于XML的SQL处理

SEELECT SQL写法可以很复杂,为了确保SQL修饰处理的准确性,基本都会先将SQL解析为AST ( 抽象语法树 ),再进行处理。Druid有自己的AST结构,Mybatis-Plus则使用了JSqlParser进行SQL解析。

Mybatis-Plus官网文档中"多租户SQL解析器"章节就是一个使用AST进行SQL改写的示例,给了我们很好的启示。以下是实现代码:

/**
 * @author gdzwk
 */
@Cofiguration
public class DbConfiguration {

    /**
     * 分页拦截器
     */
    @Bean
    public PaginationInterceptor paginationInterceptor() {
        PaginationInterceptor paginationInterceptor = new PaginationInterceptor();

        List sqlParserList = new ArrayList<>();
        // 添加对SELECT类型语句过滤删除数据的处理
        sqlParserList.add(this.logicNotDeleteSqlParser());

        paginationInterceptor.setSqlParserList(sqlParserList);
        return paginationInterceptor;
    }

    /**
     * select时过滤掉删除的数据
     */
    private ISqlParser logicNotDeleteSqlParser() {
        TenantSqlParser logicDeleteSqlParser = new TenantSqlParser() {

            /**
             * 解析处理仅对SELECT类型语句生效
             */
            @Override
            public boolean doFilter(final MetaObject metaObject, final String sql) {
                MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
                return SqlCommandType.SELECT == mappedStatement.getSqlCommandType();
            }

        };
        logicDeleteSqlParser.setTenantHandler(new TenantHandler() {
            @Override
            public Expression getTenantId(boolean where) {
                return new StringValue("n");
            }

            @Override
            public String getTenantIdColumn() {
                return "is_deleted";
            }

            @Override
            public boolean doTableFilter(String tableName) {
                return false;
            }
        });

        return logicDeleteSqlParser;
    }

}

注意上面代码的 logicNotDeleteSqlParser() 方法,重写了 TenantSqlParser.doFilter() 方法,使得该解析器仅对所有SELECT类型的语句生效 ( 可以根据需求进一步缩小或扩大解析范围 )。

4.3 注意事项

使用JSqlParser对语句进行修饰改造时,需要注意以下几点:

  • 考察哪些语句是JSqlParser无法解析的

  • 将SQL解析为抽象语法树的性能消耗是否会对系统造成影响 ( 因为这里是几乎所有查询均会进行解析 )

  • 如果某些mapper方法需要查询被删除的数据,可以在 mapper.method?? 方法上添加注解 @SqlParser(filter = true),但需要注意:这会使得 mapper.method?? 方法上所有的语句解析修饰处理均失效,例如该查询方法需要查询被删除的数据 + 多租户功能。

    遇到这种矛盾的情况,可以另外添加自定义注解,参考 MybatisMapperAnnotationBuilder.parse() 方法中的 SqlParserHelper.initSqlParserInfoCache(type) 进行改写即可。改写思路:额外的注解用于标识对哪些sql解析器失效。

    /**
     * 类上的注解这里使用了methodName,可以标记BaseMapper中的某个方法需要进行过滤
     * @author gdzwk
     */
    @XxFilter(methodName = "xxxx.xx.UserMapper.selectOne", filter = {...})
    @XxFilter(methodName = "xxxx.xx.UserMapper.selectList", filter = {...})
    public interface UserMapper extends BaseMapper {
        /**
         * 查询所有的用户信息,包括被逻辑删除的
         * @XxFilter注解用于指定该语句不进行哪些SQL解析处理(特定的ISqlParser不会处理该sql)
         * 在下面的示例中,MySqlParser1、MySqlParser2均不会对该查询的SQL进行处理
         */
        @XxFilter(filter = {MySqlParser1.class, MySqlParser2.class})
        List listAllUsers();
    }
    // MySqlParser1.class、MySqlParser2.class均继承TenantSqlParser,并添加到拦截器中。
    
  • Mybatis-Plus的SQL解析器的执行模式让我感到困惑的一点是,假如设置了多个SqlParser,每个SqlParser均会将SQL文本解析为抽象语法树,然后才处理,最后转换为SQl文本。

    这样的操作非链式处理,会导致重复的"解析为抽象语法树"动作,其实完全可以将抽象语法树对象向下传递,链式处理完后才返回最终修饰好的SQL文本。

    根据需求可以考虑进行改写。

5 总结

本文讨论并给出了基于Mybatis-Plus的逻辑删处理方案,但逻辑删本身也不算一个很好的保留历史无用数据的方法,有条件可以从非应用层面进行处理。

6 参考

  • 逻辑删除真的不是一个好的设计

你可能感兴趣的:(基于mybatis-plus的逻辑删)