准备系列-Mybatis(十九) Mybatis拦截器实现租户隔离解决方案

1,022 阅读6分钟

1.业务发生的问题

项目最近出了一次线上事故,是因为 我们底层使用的Tkmybatis, 在SQL层查询参数为空, 导致没有做拦截异常, 查询全表的数据,拉取了400W的数据, 导致内存OOM, 很不应该发生的一次事故, 今天我们来讲一下 问题及解决方案

错误代码如下

image.png

事件发生有几个必要因素

  • 1. 租户隔离做的不严谨,应该用唯一标识 xxx 作为租户的隔离依据
  • 2. 代码层不规范, userId 业务层没有判空 异常判断, 流转到SQL层
  • 3.Example 用法不规范, 错误 Example example = new Example(UserInfoPO.class, true, true);  没有做入参字段及必填校验

今天我们从几个方面改进 来纠正这种错误, 实现租户隔离

2. 最不建议的方法 

最容易想到的也是改动最大的方法, 只要在manager层 做下拦截判空, 不要透传到底层SQL查询

这种方法不建议使用, 代码改动也是最多的,代码入侵较多,  需要对每一个Manager的 需要userId的方法 都加一行, 进行拦截

 参数错误 抛出异常即可,  写一个工具类 避免了再次出现这种问题

public class ParamCheckUtil {

    /**
     * 校验参数是否有值
     */
    public static void checkInputField(String... fields) {
        if (null == fields || fields.length == 0) {
            throw new XrxsException(PARAM_NOT_VALID, "SQL查询参数异常");
        }

        if (StringUtil.isAnyBlank(fields)) {
            throw new XrxsException(PARAM_NOT_VALID, "SQL查询参数异常");
        }
    }
}

3. Example 使用

不严谨的写法

 Example example = new Example(UserInfoPO.class);

正确的规范写法 , 加上 两个参数

 Example example = new Example(UserInfoPO.class, true, true);
  • 第二个参数 表示 exists 字段存在校验,  比如 你传入的是 userId, 单词字段拼错写成了大写 userID 这种都不能识别, 找不到字段信息, 抛出错误
  • 第三个参数 标识 notNull 字段不为空校验, 这就可以优先的避免 userId 状况出现, 如果入参为空, 都会进行拦截非空校验, 抛出错误

使用规范的用例, 可以有效的避免这种问题, 校验入参的非空, 但是notNull无法校验 in 参数中的 List 空列表, 这点写代码的时候要注意

4.Interceptor拦截器实现SQL注入

这也是我们今天要讲的主要内容, 一个个做校验 太麻烦, 我们可以统一用拦截器来做, 为每一个SQL查询都注入 userId的信息, 保证SQL层查询的租户隔离

Mybatis提供了一个入口,可以让你在语句执行过程中的某一点进行拦截调用。我们需要实现Interceptor接口, 默认情况下,MyBatis 允许使用插件来拦截的方法调用包括以下四个对象的方法:

  • Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed) 执行器

    • MyBatis 执行器,是 MyBatis 调度的核心,负责 SQL 语句的生成和查询缓存的维护
  • ParameterHandler (getParameterObject, setParameters) 参数处理器

    • 负责对用户传递的参数转换成 JDBC Statement 所需要的参数
  • ResultSetHandler (handleResultSets, handleOutputParameters) 结果处理器

    • 负责将 JDBC 返回的 ResultSet 结果集对象转换成 List 类型的集合;
  • StatementHandler (prepare, parameterize, batch, update, query) 流程处理器

    • 封装了 JDBC Statement 操作,负责对 JDBC statement 的操作,如设置参数、将 Statement 结果集转换成 List 集合

这四种处理器, 分别对应我们CRUD的几种操作

  1. StatementHandler、ParameterHandler 两种查询(单个/列表查询)、新增、修改、删除 5个方法都会执行 
  2. Executor、StatementHandler、ParameterHandler、ResultSetHandler 两种查询(单个/列表查询) 都会执行

所以我们需要针对特定的场景来 定义不同的执行器:

  • 如果想改入参,可以在ParameterHandler里面获取到入参 , 然后包装处理, 再执行;
  • 如果想改sql语句,可以在StatementHandler(prepare)里面获取到connection修改;
  • 如果是sql查询的语句, 也可以在Executor的query方法中获取connection修改;
  • 如果想对结果数据进行处理,比如脱敏,替换填充, 可以在ResultSetHandler的进行脱敏处理;

根据我们的需求, 我们是要改SQL语句, 添加填充参数信息, 所以放在 StatementHandler prepare中处理, 我们分为几个步骤

  1. ExampleHeadInterceptor 拦截 StatementHandler 过程处理器,  prepare 方法
  2. 针对拦截的mapper方法的 类进行处理, 判断哪些类需要拦截处理, 哪些放行 , 有些业务方法可能不需要userId
  3. 再获取执行的SQL,  metaObject.getValue("sql");  解析完毕,  拼接参数 userId , 修改SQL 语句
  4. 把拦截器 注入 SqlSessionFactoryBean , 设置拦截器执行顺序 Interceptor[] plugins = new Interceptor[]{onePlugin}; 

DataSourceConfig.java DB配置, 要定制拦截器顺序, 注入SqlSessionFactoryBean


@Autowired
private MySelfInterceptor mySelfPlugin;

@Bean
public SqlSessionFactoryBean sqlSession() {
    SqlSessionFactoryBean sqlSession = new SqlSessionFactoryBean();
    sqlSession.setDataSource(dataSource());

    //自定义拦截器
    Interceptor[] plugins = new Interceptor[]{mySelfPlugin};
    sqlSession.setPlugins(plugins);

    try {
        Resource[] resources = new PathMatchingResourcePatternResolver().getResources("classpath:sqlmapper/*.xml");
        sqlSession.setMapperLocations(resources);

        return sqlSession;
    } catch (IOException e) {
        e.printStackTrace();
    }
    return null;
}

ExampleHeadInterceptor 拦截器实现

package com.jzj.tdmybatis.config.interceptor;

import com.jzj.tdmybatis.config.AutoBaseMapper;

import org.apache.ibatis.executor.statement.RoutingStatementHandler;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Plugin;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.reflection.DefaultReflectorFactory;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.springframework.stereotype.Component;

import java.sql.Connection;
import java.util.Properties;

import lombok.extern.slf4j.Slf4j;

@Intercepts({
        @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}),
})
@Component
@Slf4j
public class MySelfInterceptor implements Interceptor {


    public Object intercept(Invocation invocation) throws Throwable {
        // 拼装我们需要添加的 SQL信息 ,我们也可以从 threadLocal 中获取
        String myAddSql = " address = '北京' and ";

        //这里改sql,但是如果是对select的sql语句进行修改,建议实现Executor.class的plugin中进行,当前方式改select语句insert/update/delete都会走这个判断
        StatementHandler statementHandler = (RoutingStatementHandler) invocation.getTarget();

        //获取 MappedStatement信息
        MetaObject classMetaObject = MetaObject.forObject(statementHandler, SystemMetaObject.DEFAULT_OBJECT_FACTORY, SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY, new DefaultReflectorFactory());
        MappedStatement mappedStatement = (MappedStatement) classMetaObject.getValue("delegate.mappedStatement");

        //找到 类.方法(), mapId:com.jzj.tdmybatis.repository.mapper.UserInfoMapper.selectByExample_COUNT
        String mapperId = mappedStatement.getId();
        //取类信息
        int splitLocation = mapperId.lastIndexOf(".");
        String className = mapperId.substring(0, splitLocation);
        Class clazz = Class.forName(className);

        MetaObject metaObject = SystemMetaObject.forObject(statementHandler.getBoundSql());
        // 获取SQL信息 SELECT count(0) FROM user_info WHERE ((is_del = ?))
        String execSql = (String) metaObject.getValue("sql");

//      //拦截select 查询方法
        if ((execSql.startsWith("select ") || execSql.startsWith("SELECT "))) {
            //如果是查询请求, 切分where 拼装 userId信息
            StringBuffer sb = new StringBuffer();
            if (execSql.contains("where ") || execSql.contains("WHERE ")) {
                //只要包含where 的 语句, 全都拼上 需要的id信息
                String[] sqlWheres = execSql.split("where|WHERE");
                for (int i = 0; i < sqlWheres.length; i++) {
                    if (i == 0) {
                        //第一个不需要
                        sb.append(sqlWheres[i]);
                        continue;
                    }
                    String sql = sqlWheres[i];

                    sb.append(" where ").append(myAddSql);
                    sb.append(sql);
                }
            }
            metaObject.setValue("sql", sb.toString());
        }

        Object returnObject = invocation.proceed();
        return returnObject;
    }

    @Override
    public Object plugin(Object target) {
        //我们可以借助Plugin的wrap方法来使用当前的拦截器包装我们的目标对象
        Object wrap = Plugin.wrap(target, this);

        //返回的就是为当前target创建的动态代理
        return wrap;
    }

    public void setProperties(Properties properties) {

    }
}
测试结果

我们测试的是 注入的SQL是  添加地址查询条件  address = '北京' 

执行SQL 如下 ,没有任何地址信息

@Override
public List<UserInfoPO> queryAllUser() {
    Example example = new Example(UserInfoPO.class, true, true);
    Example.Criteria criteria = example.createCriteria();
    criteria.andEqualTo("isDel", 0);
    return mapper.selectByExample(example);
}

查询结果如下, 可以看到 SQL 结果,已经被拦截器 统一注入了 address = '北京' , 查询结果 的条件 

查询结果, 符合预期

2023-05-08 21:16:40.438  INFO 26129 --- [nio-8800-exec-1] c.j.tdmybatis.controller.TestController  :
[{"id":1,"userId":"556","userName":"aa","age":1,"address":"北京","orderIds":"[1, 2, 3]","goods":"{"deptId": 1, "deptName": "部门1", "deptLeaderId": 4}","sortOrder":0,"isDel":0,"addtime":0,"modtime":0},
{"id":6,"userId":"66","userName":"ff","age":34,"address":"北京","orderIds":"[10, 5, 6]","goods":"{"deptId": 2, "deptName": "部门2", "deptLeaderId": 4}","sortOrder":0,"isDel":0,"addtime":0,"modtime":0},
{"id":12,"userId":"5896","userName":"ll","age":14,"address":"北京","orderIds":"[28, 35, 36]","goods":"{"deptId": 4, "deptName": "部门4", "deptLeaderId": 4}","sortOrder":0,"isDel":0,"addtime":0,"modtime":0}]

 类似我们可以 注入 user_id = xx 的条件 , 来实现 无代码入侵的 统一拦截器来处理 租户隔离