用好Interceptor,你也能写一个MybatisPlus

Interceptor框架

Interceptor,顾名思义就是一个拦截器,用于拦截某些动作 的。而在Mybatis中,能有的动作就只有一个:执行SQL语句

那么在Mybatis中,是怎么拦截这些操作的呢?

其中,主要由两个重要的类组成:

  • InterceptorChain:负责存放所有的拦截器以及链式调用
  • Plugin:判断该拦截器是否需要拦截这个Handler的方法,如果需要拦截,则返回一个代理对象。

Configuration初始化时就会创建一个InterceptorChain对象,用于保存所有的拦截器并提供一个pulginAll方法运行拦截器中的plugin方法。

于是乎,Mybatis每次创建ParameterHandler、ResultSetHandler、StatementHandler、Executor时,都会调用每个拦截器的plugin方法了。

Interceptor接口提供了一个默认的plugin方法。

该方法会进入到wrap方法中,该方法决定了是否需要代理该对象 ,用于拦截对象的每一个方法调用。

那么在什么情况下需要代理对象呢?此时与Mybatis的Interceptor息息相关的两个注解出现了:

  • @Intercepts:用于标识这是一个Mybatis的拦截器
  • @Signature:声明需要代理类的方法,可配置多个。

使用起来就像这样子:

是否需要代理这个逻辑简单来说就是获取**@Signature注解上的 类**、方法名参数类型,根据这三要数与传入的对象相匹配,若是匹配成功,则代理该对象。这段匹配逻辑比较简单,就不展开讲了,有兴趣的可以到这个类(org.apache.ibatis.plugin.Plugin)上看看。

扩展点

在Mybatis中,一共提供了四个类的扩展点

  1. ParameterHandler
  2. ResultSetHandler
  3. StatementHandler
  4. Executor

这四个类也分别对应了一条SQL执行过程中的查询参数处理结果集处理SQL处理及执行 以及**整个SQL的处理过程,**囊括了SQL执行的全程生命周期。

多租户开发

我们已经了解到Mybatis的Interceptor的原理以及作用了,那么我们能否用Mybatis的这个能力,写一个简单的多租户DEMO呢?答案肯定是可以的。

在开始之前,让我们先构思一下整一块的逻辑。

首先我们肯定是要有一个租户的标识,以及在程序的线程中全局存放的地方。不然的话,都不能获取到具体的租户值是多少。

其次就要处理如何将租户这个查询条件插入到要执行的SQL中,否则就无法筛选到具体租户的数据了。

上面这两步,我们就总结出一下的步骤了

  1. 处理多租户标识:获取、全局取值
  2. 处理执行SQL:在实际执行前插入租户查询条件。

按照这个步骤我们就能开始我们的多租户拦截器的开发了。

多租户标识

关于租户的标识获取,在这个简单的DEMO中,使用到请求头的X-tenant-id来作为租户的标识。当然了,在正常的框架中,是不可能使用这么简单来区分不同租户的,这一点要注意。

如何获取到请求头中的属性并且存放在一个能够全局获取值 的地方呢?

在这一步,使用到了Spring的InterceptorThreadLocal来获取并存放租户标识。

其中,Spring的Interceptor和Mybatis的Interceptor十分相似 ,只是两者的作用域 不一样而已。Spring作用在每一次请求 中,Mybatis作用在每一次SQL查询 中。

总共新增了两个类:

  • TenantSpringInterceptor:拦截每一次请求,获取请求头中的租户标识。
  • TenantContext:使用ThreadLocal存放每一次请求中获取到的租户标识。
java 复制代码
package com.azir.mybatisinterceptor.interceptor;

import com.azir.mybatisinterceptor.TenantContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

/**
 * @author zhangshukun
 * @date 2024/8/4
 */
@Component
public class TenantSpringInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String header = request.getHeader("X-tenant-id");
        if (header != null) {
            TenantContext.setTenantId(Integer.valueOf(header));
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        TenantContext.clear();
    }
}
java 复制代码
package com.azir.mybatisinterceptor;

public class TenantContext {

    private static final ThreadLocal<Integer> CURRENT_TENANT = new ThreadLocal<>();

    public static void setTenantId(Integer tenantId) {
        CURRENT_TENANT.set(tenantId);
    }

    public static Integer getTenantId() {
        return CURRENT_TENANT.get();
    }

    public static void clear() {
        CURRENT_TENANT.remove();
    }
}

替换执行SQL

在上面,我们已经能够获取到租户的值了。那么此时就需要思考一下,如何替换 提供给数据库执行的SQL 呢?

我们都知道在Mybatis中,每执行一条SQL,都会创建一个StatementHandler并且还会存放一个BoundSql的对象。该对象中存放的SQL就是要执行的SQL,所以我们可以通过修改这个对象的SQL来替换。

需要处理的对象找到了,那么又出现了一个新的问题。

该在什么阶段,对BoundSql进行SQL的替换呢?

此时,我们就需要看一下StatementHandler这个类,有什么方法可以使用到Mybatis的Interceptor进行拦截了。

可以看到,在StatementHandler中,有一个prepare方法。从命名中以及返回值就能看出,这是一个初始化获取 数据库Statement的方法。

那么我们就能在执行这个prepare方法的时候,修改BoundSql绑定的SQL了。

思路清晰,那么就能开始动手写代码了。

java 复制代码
package com.azir.mybatisinterceptor.interceptor;

import com.azir.mybatisinterceptor.TenantContext;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.*;
import org.springframework.stereotype.Component;

import java.lang.reflect.Field;
import java.sql.Connection;

/**
 * @author zhangshukun
 * @since 2024/08/02
 */
@Slf4j
@Component
@Intercepts({
        @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class TenantMybatisInterceptor implements Interceptor {

    private static final Field SQL_FIELD;

    static {
        try {
            SQL_FIELD = BoundSql.class.getDeclaredField("sql");
            SQL_FIELD.setAccessible(true);
        } catch (NoSuchFieldException e) {
            log.warn("无法获取BoundSql的sql字段");
            throw new RuntimeException(e);
        }
    }

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
        BoundSql boundSql = statementHandler.getBoundSql();
        String sql = boundSql.getSql();
        log.info("原始SQL:{}", sql);
        // 修改SQL,添加租户信息。假设每个表都有一个tenant_id字段
        String modifiedSql = addTenantFilter(sql, TenantContext.getTenantId());
        log.info("修改后的SQL:{}", modifiedSql);
        updateBoundSql(boundSql, modifiedSql);
        return invocation.proceed();
    }

    private void updateBoundSql(BoundSql boundSql, String sql) {
        try {
            // 使用静态变量反射修改sql,避免每次调用都重新获取对应字段
            SQL_FIELD.set(boundSql, sql);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private String addTenantFilter(String sql, Integer tenantId) {
        if (tenantId == null) {
            log.warn("租户ID为空,无法添加租户过滤条件");
            return sql;
        }
        // 更新操作,在正常的框架中,是会使用jsqlparser解析sql,然后在插入租户的查询条件的。
        // 这里只是简单的示例
        if (sql.contains("where")) {
            int where = sql.lastIndexOf("where");
            if (sql.contains("and")) {
                return sql.substring(0, where) + " and tenant_id = '" + tenantId + "' and " + sql.substring(where);
            }
            return sql.substring(0, where) + " and tenant_id = '" + tenantId + "'";
        } else if (sql.contains("insert")) {
            int i = sql.indexOf(")");
            sql = sql.substring(0, i) + ",tenant_id" + sql.substring(i);
            int i1 = sql.lastIndexOf(")");
            sql = sql.substring(0, i1) + "," +  tenantId + sql.substring(i1);
            return sql;
        } else {
            return sql + " where tenant_id = '" + tenantId + "'";
        }
    }

}

注意一点:上面的SQL插入查询条件执行一个简单的替换,并没有考虑复杂的SQL语句。在实际使用中肯定会有问题出现的。

运行结果

查询语句:

插入语句:

代码仓库:https://github.com/AzirZsk/MyBatis-Interceptor

总结

利用好Interceptor,你就能在Mybatis执行SQL时做你想做的事情,但是呢,想写一个新的Mybtis-Plus还是不行的。因为Interceptor只能在执行SQL时进行拦截并处理,但是执行SQL前的一些准备工作就不太行了,比如实体类的解析、SQL的解析等等。但是也足够了,能够在我们工作当中处理大多数需求了。

相关推荐
魔道不误砍柴功2 小时前
Java 中如何巧妙应用 Function 让方法复用性更强
java·开发语言·python
NiNg_1_2342 小时前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
闲晨2 小时前
C++ 继承:代码传承的魔法棒,开启奇幻编程之旅
java·c语言·开发语言·c++·经验分享
测开小菜鸟4 小时前
使用python向钉钉群聊发送消息
java·python·钉钉
P.H. Infinity4 小时前
【RabbitMQ】04-发送者可靠性
java·rabbitmq·java-rabbitmq
生命几十年3万天5 小时前
java的threadlocal为何内存泄漏
java
caridle5 小时前
教程:使用 InterBase Express 访问数据库(五):TIBTransaction
java·数据库·express
^velpro^5 小时前
数据库连接池的创建
java·开发语言·数据库
苹果醋35 小时前
Java8->Java19的初步探索
java·运维·spring boot·mysql·nginx
秋の花5 小时前
【JAVA基础】Java集合基础
java·开发语言·windows