基于mybatis-plus的邏輯刪

1 前言

本文介紹基于mybatis-plus( 版本:3.3.1 ) 的邏輯刪處理辦法,同時考慮到對關(guān)系數(shù)據(jù)庫唯一索引的兼容技矮。

許多公司都要求保留歷史數(shù)據(jù)不能真的徹底刪掉,像我所待過的公司,就要求使用邏輯刪。邏輯刪指的類似添加 is_deleted 字段呵曹,通過 n/y 來標識是否被刪除。邏輯刪的出發(fā)點是好的何暮,但如果被刪除的數(shù)據(jù)還保留在關(guān)系數(shù)據(jù)庫原表時,事情就開始變復雜了铐殃。

關(guān)系數(shù)據(jù)庫中許多表都會添加唯一索引海洼,以確保業(yè)務相關(guān)數(shù)據(jù)的唯一性 ( 由數(shù)據(jù)庫確保業(yè)務數(shù)據(jù)唯一性是最省事簡單的 )。當相同的數(shù)據(jù)被刪除后再次新增富腊,然后再次刪除坏逢,會導致數(shù)據(jù)庫報錯:唯一鍵重復。也就是說赘被,僅靠 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='用戶表';

# 下面這樣的數(shù)據(jù)理應是正常的,但由于唯一索引的存在民假,導致業(yè)務本身受到影響
--------------------------------------
|  id  | name | id_card | is_deleted |
--------------------------------------
|  xx1 |  z1  | xxx001  |      n     |  # 有效
--------------------------------------
|  xx1 |  z1  | xxx001  |      y     |  # 已刪除
--------------------------------------
|  xx1 |  z1  | xxx001  |      y     |  # 已刪除

唯一索引是必須的浮入,阿里java規(guī)約有這樣的說明:

【強制】業(yè)務上具有唯一特性的字段,即使是多個字段的組合羊异,也必須建成唯一索引事秀。

說明:不要以為唯一索引影響了 insert 速度,這個速度損耗可以忽略野舶,但提高查找速度是明顯的易迹;另外,即使在應用層做了非常完善的校驗控制平道,只要沒有唯一索引睹欲,根據(jù)墨菲定律,必然有臟數(shù)據(jù)產(chǎn)生一屋。

2 兼容邏輯刪和唯一索引的思路

2.1 修改刪除標志的賦值

當數(shù)據(jù)被邏輯刪后窘疮,不再使用 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執(zhí)行流程

3.1.1 流程圖

mybatis-plus在springboot中的加載流程

mybatisplus-process

3.1.2 關(guān)鍵流程分析

經(jīng)過上面的流程圖,可以大致了解(mybatis身冬、mybatis-plus)的加載流程衅胀。我們使用基于mybatis-plus的刪除操作時,一般會直接或間接使用 BaseMapper.delete*() 方法酥筝。

mybatis-plus官方文檔的邏輯刪章節(jié)提到:使用Sql注入器注入 LogicDeleteByIdWithFill 并使用(推薦)滚躯。因此我們可以參考該實現(xiàn)進行改寫。

上圖中紅色部分是我們需要進行改造的流程點嘿歌。它的作用是構(gòu)造一個與 xxMapper.method? 對應的 MappedStatement 對象掸掏,存放到 MybatisConfiguration 中,以便后續(xù)由業(yè)務調(diào)用 xxMapper.method? 時使用宙帝。

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

    /**
     * 可以看出丧凤,集合中的每個對象,都對應BaseMapper<T>接口的一個方法定義
     */
    @Override
    public List<AbstractMethod> 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注入器:

上圖中有介紹調(diào)用流程: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節(jié)點
            loadXmlResource();
            // ...
            // 獲取xxMapper<T>接口的方法定義
            Method[] methods = type.getMethods();
            for (Method method : methods) {
                try {
                    // issue #237
                    if (!method.isBridge()) {
                        // 如果該方法存在對應的xml sql節(jié)點,則解析節(jié)點靴患,添加MappedStatement
                        parseStatement(method);
                        // ...
                    }
                } catch (IncompleteElementException e) {
                    // ...
                }
            }
            // ########## 關(guān)鍵方法(.inspectInject()) ##########
            // 如果xxMapper繼承了Mapper<T>接口仍侥,則使用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<AbstractMethod> methodList = this.getMethodList(mapperClass);
        // 構(gòu)建實體對應的table信息,該類有個關(guān)鍵方法相嵌,后續(xù)會用到:TableInfo.getLogicDeleteSql
        TableInfo tableInfo = TableInfoHelper.initTableInfo(builderAssistant, modelClass);
        // ########## 關(guān)鍵方法(m.inject) ##########
        // 循環(huán)注入自定義方法腿时,構(gòu)建MappedStatement,最終添加到MybatisConfiguratiion.mappedStatements
        methodList.forEach(m -> m.inject(builderAssistant, mapperClass, modelClass, tableInfo));
        // ...
    }

}

接下來就是具體的執(zhí)行方法體根據(jù)各自需求來構(gòu)建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();
        // ########## 關(guān)鍵方法 ##########
        injectMappedStatement(mapperClass, modelClass, tableInfo);
    }

    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        String sql;
        // 重要的常量看铆,標明了delete sql的構(gòu)造格式 "<script>UPDATE %s %s %s %s</script>"
        // "<script>UPDATE {tableName} {set語句} {where語句} {注釋}</script>"
        SqlMethod sqlMethod = SqlMethod.LOGIC_DELETE;
        // 如果使用邏輯刪
        if (tableInfo.isLogicDelete()) {
            // 
            sql = String.format(
                sqlMethod.getSql(),                      // 字符串模板
                tableInfo.getTableName(),                // tableName
                sqlLogicSet(tableInfo),                  // set語句  ####關(guān)鍵部分####
                sqlWhereEntityWrapper(true, tableInfo),  // where語句
                sqlComment());                           // 注釋
            // 像讀取xml的sql節(jié)點一樣徽鼎,解析該xml文本,構(gòu)造sqlSource
            SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass);
            // 構(gòu)造MappedStatement
            return addUpdateMappedStatement(mapperClass, modelClass, getMethod(sqlMethod), sqlSource);
        } else {
            // ...
        }
    }

}

參考上述默認的mybatis-plus.Delete對象弹惦,可以了解其構(gòu)造MappedStatement的過程否淤。邏輯刪的關(guān)鍵部分是 sqlLogicSet(tableInfo)我們可以通過對其進行改寫棠隐,從而達到兼容唯一索引的目的石抡。

3.2 改造分析

通過章節(jié)3.1,對mybatis-plus的整個加載流程有了大致的了解助泽,也為后續(xù)的改造指明了方向啰扛。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() 都有一個共同的特點嚎京,構(gòu)造sql語句時調(diào)用了 sqlLogicSet(tableInfo)。我們只需要重構(gòu)該方法的調(diào)用隐解。

3.3 完整代碼

以章節(jié)2中的第二種思路 ( 添加輔助刪除標識字段 ) 為例鞍帝,兩種思路寫法非常類似。

注意事項:該代碼暫不支持基于 UpdateWrapper 等方式的邏輯刪處理煞茫。要么使用UpdateWrapper時手動處理帕涌,要么進一步改寫Sql注入器中的 Update*

代碼結(jié)構(gòu)預覽:

|---- config
|   |---- MybatisPlusConfiguration.java
|   |---- mybatisplus
|       |---- BaseEntityFieldsFillHandler.java     // 實體類字段適配
|       |---- MySqlInjector.java                   // 自定義sql注入器
|       |---- LogicDeleteSqlWrapper.java           // 邏輯刪包裝處理類
|       |
|       |---- method                               // 自定義sql方法實現(xiàn)
|       |   |---- 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']
     *      唯一索引: 業(yè)務字段 + 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<AbstractMethod> 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<TableFieldInfo> 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<TableFieldInfo>) 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)}钦扭,
     * 沒有任何變化辟躏,僅為方便調(diào)用
     */
    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方法實現(xiàn)

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;

/**
 * 根據(jù) entity 條件刪除記錄
 * PS: 參照{(diào)@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;

/**
 * 根據(jù) entity 條件刪除記錄
 * PS: 參照{(diào)@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;

/**
 * 根據(jù) entity 條件刪除記錄
 * PS: 參照{(diào)@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;

/**
 * 根據(jù) entity 條件刪除記錄
 * PS: 參照{(diào)@link com.baomidou.mybatisplus.core.injector.methods.DeleteBatchByIds},并改寫sqlLogic
 * PS: 雖然批量刪除時,由指定了相同的UUID土全,但就該批次數(shù)據(jù)來說,不會和別的有沖突
 * @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 邏輯刪后的查詢處理

前面的章節(jié)已完成對兼容唯一索引的邏輯刪分析與實現(xiàn)会涎,但一個完整系統(tǒng)中裹匙,除了邏輯刪除這個動作外,還需要考慮到查詢時如何屏蔽掉被刪除的數(shù)據(jù)末秃。

4.1 闡述疑問

  • 對單表的屏蔽:

    • 基于mybatis-plus wrapper的單表查詢操作處理還好說點概页,可以自定義改寫,或框架提供相應的處理练慕。
    • 對于xml文件中的單表查詢(特別是動態(tài)sql)惰匙,貌似可以直接拼接"and is_deleted = 'n'",但如何確保拼接的sql是絕對沒有語法問題的铃将?
  • 對于多表聯(lián)查的屏蔽:

    • 即便能給最外層拼接“and is_deleted = 'n'”项鬼,對于內(nèi)層子查詢,沒過濾被刪數(shù)據(jù)劲阎,會導致查詢量增大

    • 更進一步的處理绘盟?例如分析sql語句,生成AST抽象語法樹悯仙,從而做到對內(nèi)外層查詢均添加"and is_deleted = 'n'"

4.2 分析+處理

項目中進行查詢的方式有很多種龄毡,主要分為使用BaseMapper.select*()、使用xml文件等方式锡垄,需要逐一確認沦零。

4.2.1 BaseMapper.select*()方法

IService.select*()IService.count*()货岭、IService.lambdaQuery()路操、IService.page*() 等方法底層均是調(diào)用 BaseMapper.select*()疾渴。

下面就以 com.baomidou.mybatisplus.core.injector.methods.SelectList 類為例進行說明 ( 通過查看源碼,BaseMapper.select*() 均已實現(xiàn)自動屏蔽邏輯刪除的數(shù)據(jù) )寻拂。

public class SelectList extends AbstractMethod {

    /**
     * ##### 關(guān)鍵方法是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());
        // ...
    }

    /**
     * ##### 將父類的方法放到這里程奠,方便查看 #####
     * 從下面的條件判斷就可以知道,如果相關(guān)的實體類上有@TableLogic祭钉,就會啟用邏輯刪過濾
     */
    protected String sqlWhereEntityWrapper(boolean newLine, TableInfo table) {
        if (table.isLogicDelete()) {
            // ...
                } else {
            // ...
        }
    }

}

4.2.2 基于XML的SQL處理

SEELECT SQL寫法可以很復雜瞄沙,為了確保SQL修飾處理的準確性,基本都會先將SQL解析為AST ( 抽象語法樹 )慌核,再進行處理距境。Druid有自己的AST結(jié)構(gòu),Mybatis-Plus則使用了JSqlParser進行SQL解析垮卓。

Mybatis-Plus官網(wǎng)文檔中"多租戶SQL解析器"章節(jié)就是一個使用AST進行SQL改寫的示例垫桂,給了我們很好的啟示。以下是實現(xiàn)代碼:

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

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

        List<ISqlParser> sqlParserList = new ArrayList<>();
        // 添加對SELECT類型語句過濾刪除數(shù)據(jù)的處理
        sqlParserList.add(this.logicNotDeleteSqlParser());

        paginationInterceptor.setSqlParserList(sqlParserList);
        return paginationInterceptor;
    }

    /**
     * select時過濾掉刪除的數(shù)據(jù)
     */
    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類型的語句生效 ( 可以根據(jù)需求進一步縮小或擴大解析范圍 )。

4.3 注意事項

使用JSqlParser對語句進行修飾改造時灭将,需要注意以下幾點:

  • 考察哪些語句是JSqlParser無法解析的

  • 將SQL解析為抽象語法樹的性能消耗是否會對系統(tǒng)造成影響 ( 因為這里是幾乎所有查詢均會進行解析 )

  • 如果某些mapper方法需要查詢被刪除的數(shù)據(jù)疼鸟,可以在 mapper.method?? 方法上添加注解 @SqlParser(filter = true),但需要注意:這會使得 mapper.method?? 方法上所有的語句解析修飾處理均失效庙曙,例如該查詢方法需要查詢被刪除的數(shù)據(jù) + 多租戶功能空镜。

    遇到這種矛盾的情況,可以另外添加自定義注解捌朴,參考 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<User> {
        /**
         * 查詢所有的用戶信息洼怔,包括被邏輯刪除的
         * @XxFilter注解用于指定該語句不進行哪些SQL解析處理(特定的ISqlParser不會處理該sql)
         * 在下面的示例中,MySqlParser1察皇、MySqlParser2均不會對該查詢的SQL進行處理
         */
        @XxFilter(filter = {MySqlParser1.class, MySqlParser2.class})
        List<User> listAllUsers();
    }
    // MySqlParser1.class茴厉、MySqlParser2.class均繼承TenantSqlParser,并添加到攔截器中什荣。
    
  • Mybatis-Plus的SQL解析器的執(zhí)行模式讓我感到困惑的一點是矾缓,假如設置了多個SqlParser,每個SqlParser均會將SQL文本解析為抽象語法樹稻爬,然后才處理嗜闻,最后轉(zhuǎn)換為SQl文本。

    這樣的操作非鏈式處理桅锄,會導致重復的"解析為抽象語法樹"動作琉雳,其實完全可以將抽象語法樹對象向下傳遞样眠,鏈式處理完后才返回最終修飾好的SQL文本。

    根據(jù)需求可以考慮進行改寫翠肘。

5 總結(jié)

本文討論并給出了基于Mybatis-Plus的邏輯刪處理方案檐束,但邏輯刪本身也不算一個很好的保留歷史無用數(shù)據(jù)的方法,有條件可以從非應用層面進行處理束倍。

6 參考

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末被丧,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子绪妹,更是在濱河造成了極大的恐慌甥桂,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,968評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件邮旷,死亡現(xiàn)場離奇詭異黄选,居然都是意外死亡,警方通過查閱死者的電腦和手機婶肩,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評論 2 382
  • 文/潘曉璐 我一進店門办陷,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人律歼,你說我怎么就攤上這事懂诗。” “怎么了苗膝?”我有些...
    開封第一講書人閱讀 153,220評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長植旧。 經(jīng)常有香客問我辱揭,道長,這世上最難降的妖魔是什么病附? 我笑而不...
    開封第一講書人閱讀 55,416評論 1 279
  • 正文 為了忘掉前任问窃,我火速辦了婚禮,結(jié)果婚禮上完沪,老公的妹妹穿的比我還像新娘域庇。我一直安慰自己,他們只是感情好覆积,可當我...
    茶點故事閱讀 64,425評論 5 374
  • 文/花漫 我一把揭開白布听皿。 她就那樣靜靜地躺著,像睡著了一般宽档。 火紅的嫁衣襯著肌膚如雪尉姨。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,144評論 1 285
  • 那天吗冤,我揣著相機與錄音又厉,去河邊找鬼九府。 笑死逝钥,一個胖子當著我的面吹牛浓冒,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播加矛,決...
    沈念sama閱讀 38,432評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼煌妈,長吁一口氣:“原來是場噩夢啊……” “哼儡羔!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起声旺,我...
    開封第一講書人閱讀 37,088評論 0 261
  • 序言:老撾萬榮一對情侶失蹤笔链,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后腮猖,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體鉴扫,經(jīng)...
    沈念sama閱讀 43,586評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,028評論 2 325
  • 正文 我和宋清朗相戀三年澈缺,在試婚紗的時候發(fā)現(xiàn)自己被綠了坪创。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,137評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡姐赡,死狀恐怖莱预,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情项滑,我是刑警寧澤依沮,帶...
    沈念sama閱讀 33,783評論 4 324
  • 正文 年R本政府宣布,位于F島的核電站枪狂,受9級特大地震影響危喉,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜州疾,卻給世界環(huán)境...
    茶點故事閱讀 39,343評論 3 307
  • 文/蒙蒙 一辜限、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧严蓖,春花似錦薄嫡、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至毒姨,卻和暖如春费什,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評論 1 262
  • 我被黑心中介騙來泰國打工鸳址, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留瘩蚪,地道東北人。 一個月前我還...
    沈念sama閱讀 45,595評論 2 355
  • 正文 我出身青樓稿黍,卻偏偏與公主長得像疹瘦,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子巡球,可洞房花燭夜當晚...
    茶點故事閱讀 42,901評論 2 345