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存放每一次请求中获取到的租户标识。
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();
}
}
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了。
思路清晰,那么就能开始动手写代码了。
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的解析等等。但是也足够了,能够在我们工作当中处理大多数需求了。