用好Interceptor,你也能写一个MybatisPlus

Interceptor框架

Interceptor,顾名思义就是一个拦截器,用于拦截某些动作的。而在Mybatis中,能有的动作就只有一个:执行SQL语句
那么在Mybatis中,是怎么拦截这些操作的呢?
其中,主要由两个重要的类组成:

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

Configuration初始化时就会创建一个InterceptorChain对象,用于保存所有的拦截器并提供一个pulginAll方法运行拦截器中的plugin方法。
image.png
image.png
于是乎,Mybatis每次创建ParameterHandler、ResultSetHandler、StatementHandler、Executor时,都会调用每个拦截器的plugin方法了。
Interceptor接口提供了一个默认的plugin方法。
image.png
该方法会进入到wrap方法中,该方法决定了是否需要代理该对象,用于拦截对象的每一个方法调用。
image.png
那么在什么情况下需要代理对象呢?此时与Mybatis的Interceptor息息相关的两个注解出现了:

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

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

扩展点

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

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

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

多租户开发

我们已经了解到Mybatis的Interceptor的原理以及作用了,那么我们能否用Mybatis的这个能力,写一个简单的多租户DEMO呢?答案肯定是可以的。
在开始之前,让我们先构思一下整一块的逻辑。
首先我们肯定是要有一个租户的标识,以及在程序的线程中全局存放的地方。不然的话,都不能获取到具体的租户值是多少。
其次就要处理如何将租户这个查询条件插入到要执行的SQL中,否则就无法筛选到具体租户的数据了。
上面这两步,我们就总结出一下的步骤了

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

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

多租户标识

关于租户的标识获取,在这个简单的DEMO中,使用到请求头的X-tenant-id来作为租户的标识。当然了,在正常的框架中,是不可能使用这么简单来区分不同租户的,这一点要注意。
如何获取到请求头中的属性并且存放在一个能够全局获取值的地方呢?
在这一步,使用到了Spring的InterceptorThreadLocal来获取并存放租户标识。
其中,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来替换。
image.png
需要处理的对象找到了,那么又出现了一个新的问题。

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

此时,我们就需要看一下StatementHandler这个类,有什么方法可以使用到Mybatis的Interceptor进行拦截了。
image.png
可以看到,在StatementHandler中,有一个prepare方法。从命名中以及返回值就能看出,这是一个初始化获取数据库Statement的方法。
image.png
那么我们就能在执行这个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语句。在实际使用中肯定会有问题出现的。

运行结果

查询语句:
image.png
插入语句:
image.png
代码仓库:https://github.com/AzirZsk/MyBatis-Interceptor

总结

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

以下是一个使用MyBatis Plus和Redis作为二级缓存的示例代码: 1. 配置RedisTemplate ```java @Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory); redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); redisTemplate.setHashKeySerializer(new StringRedisSerializer()); redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); return redisTemplate; } } ``` 2. 配置MyBatis Plus ```java @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor(RedisTemplate<String, Object> redisTemplate) { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 分页插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // Redis 二级缓存插件 RedisCache redisCache = new RedisCache(redisTemplate); Properties properties = new Properties(); properties.setProperty("redisCache.expire", "3600"); properties.setProperty("redisCache.cacheNullObject", "true"); RedisCacheInterceptor redisCacheInterceptor = new RedisCacheInterceptor(redisCache, properties); interceptor.addInnerInterceptor(redisCacheInterceptor); return interceptor; } @Bean public ConfigurationCustomizer configurationCustomizer(MybatisPlusInterceptor mybatisPlusInterceptor) { return configuration -> configuration.addInterceptor(mybatisPlusInterceptor); } } ``` 3. 使用@CacheNamespace注解开启缓存 ```java @CacheNamespace(implementation = RedisCache.class, eviction = RedisCache.class) public interface UserMapper extends BaseMapper<User> { @Cacheable(value = "user:id", key = "#id") User getUserById(Long id); @CachePut(value = "user:id", key = "#user.id") int updateUserById(User user); @CacheEvict(value = "user:id", key = "#id") int deleteUserById(Long id); } ``` 在这里,@CacheNamespace注解用于开启Redis缓存,并指定了缓存的实现类和清理策略。 @Cacheable注解用于查询缓存中的数据,@CachePut注解用于更新缓存中的数据,@CacheEvict注解用于删除缓存中的数据。 还可以在@Cacheable注解中指定缓存的名称和缓存的键值,以及在@CachePut和@CacheEvict注解中指定缓存的键值。 注意:在使用Redis作为二级缓存时,需要在mapper中使用@Cacheable注解进行数据的缓存和查询,同时要保证实体类的序列化和反序列化能够正确进行。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值