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中,一共提供了四个类的扩展点
- ParameterHandler
- ResultSetHandler
- StatementHandler
- Executor
这四个类也分别对应了一条SQL执行过程中的查询参数处理 、结果集处理 、SQL处理及执行 以及**整个SQL的处理过程,**囊括了SQL执行的全程生命周期。
多租户开发
我们已经了解到Mybatis的Interceptor的原理以及作用了,那么我们能否用Mybatis的这个能力,写一个简单的多租户DEMO呢?答案肯定是可以的。
在开始之前,让我们先构思一下整一块的逻辑。
首先我们肯定是要有一个租户的标识,以及在程序的线程中全局存放的地方。不然的话,都不能获取到具体的租户值是多少。
其次就要处理如何将租户这个查询条件插入到要执行的SQL中,否则就无法筛选到具体租户的数据了。
上面这两步,我们就总结出一下的步骤了
- 处理多租户标识:获取、全局取值
- 处理执行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的解析等等。但是也足够了,能够在我们工作当中处理大多数需求了。