用好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的Interceptor和ThreadLocal来获取并存放租户标识。

其中,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的解析等等。但是也足够了,能够在我们工作当中处理大多数需求了。

相关推荐
system_error_x11 分钟前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
驭渊的小故事41 分钟前
Spring AI Alibaba 实战:从maven依赖治理到声明式工具调用
java·人工智能·spring
Elastic 中国社区官方博客1 小时前
我们如何通过 70 多个拉取请求,将 OpenTelemetry Java agent 迁移到 invokedynamic
java·大数据·开发语言·elasticsearch·搜索引擎·全文检索·es
tyn1881 小时前
记录本地安装nacos服务的方法及异常
java·开发语言
步行cgn2 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
y1su2 小时前
Leetcode 字符串模板
java·数据结构·算法·leetcode·职场和发展·动态规划
我命由我123453 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
苜蓿muxu4 小时前
Java 字符串拼接:+、StringBuilder、StringBuffer的区别
java
摇滚侠4 小时前
《Java并发编程之美》第 4 章 Java 并发包中原子操作类原理剖析 阅读笔记 7
java·笔记·python