Mybatis 拦截器 及 PageHelper分析

Mybatis 提供了 插件 的机制,使得开发者可以侵入 Mybatis 工作流程,读完前几篇文章,相信大家已经对于 Mybatis,已经有了大致介绍了认识。

本文将从以下几个问题出发:

  1. Mybatis 可以实现哪几种拦截器?
  2. Mybatis 中拦截器的使用。
  3. 这几种拦截器是如何工作的?
  4. PageHelper 怎么用的?
  5. PageHelper 如何基于拦截器进行工作的?

用法

由前面文章分析可知,使用Mybatis ,有下面几个流程:

  1. 构建SqlSession
  2. 填充参数
  3. 执行查询
  4. 封装结果

而对于Mybatis 的拦截器,就是可以利用 提供的拦截器机制对 这四个过程做文章,即拦截这几个过程,实现自己代码逻辑。

  1. 在 xml 中配置:
    <plugins>
        <plugin interceptor="anla.learn.mybatis.interceptor.config.SqlStatementHandlerInterceptor">
            <property name="dialect" value="mysql"/>
        </plugin>
    </plugins>
  1. 使用类实现 org.apache.ibatis.plugin.Interceptor,并使用相应注解说明拦截的类别:
// 说明拦截器是 StatementHandler,拦截方法为 prepare
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class SqlStatementHandlerInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
        BoundSql boundSql = statementHandler.getBoundSql();
        String sql = boundSql.getSql();
        log.info("mybatis intercept sql:{}", sql);
        return invocation.proceed();
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
        String dialect = properties.getProperty("dialect");
        log.info("mybatis intercept dialect:{}", dialect);
    }
}

上面代码有以下要点:

  1. 增加 plugin 标签
  2. 实现 Interceptor,并增加 @Intercepts 注解,并使用 @Signature 说明拦截类型
    @Signature 有以下可选参数
  • type:只拦截器类型,可选 ExecutorResultSetHandlerStatementHandlerParameterHandler
  • method:指的是 上面四个类型里面的方法,当然不同的类可以有不同。
  • args:指 拦截的方法,里面的参数类型。
    上述简单拦截器在 setProperties 中,设置了相关方言并打印,而主 拦截器 intercept 方法 仅仅打印sql,而后执行 invocation.proceed(); 继续执行Mybatis 自有逻辑。
    具体可拦截对象如下:

Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
ParameterHandler (getParameterObject, setParameters)
ResultSetHandler (handleResultSets, handleOutputParameters)
StatementHandler (prepare, parameterize, batch, update, query)

具体使用例子可以看博主项目例子:https://github.com/anLA7856/mybatislearn

拦截器分析

下面来具体分析拦截器原理,先看看 Interceptor 定义:

public interface Interceptor {
  Object intercept(Invocation invocation) throws Throwable;
  default Object plugin(Object target) {
    return Plugin.wrap(target, this);
  }
  default void setProperties(Properties properties) {
  }
}

Interceptor 本身只是 一个接口,提供了 三个方法 interceptpluginsetProperties

  • intercept : 拦截过滤器,对于被拦截方法,都会先执行intercept,然后再会执行确定方法
  • plugin:提供了默认实现方法,主要是对下一层过滤器或者具体拦截对象进一步封装
  • setProperties:设置属性,即 <plugin> 标签中 <property> 子标签

分析一个类原理,首先从该类初始化,而后再从其调用上来分析。
本文将从以下几个点分析拦截器:

  1. 拦截器初始化:拦截器何时被Mybatis 加载
  2. 四种拦截器使用点:具体拦截器以怎样方式被初始化并调用?
拦截器初始化

当 Mybatis 机制被加载时,<plugins> 节点内容会被加载并放到 Configuration 中,具体就是加载到 变量 InterceptorChain 中:
protected final InterceptorChain interceptorChain = new InterceptorChain();
XMLConfigBuilderpluginElement 方法将xml中配置的插件加载到 InterceptorChain

  private void pluginElement(XNode parent) throws Exception {
    if (parent != null) {
      for (XNode child : parent.getChildren()) {
        String interceptor = child.getStringAttribute("interceptor");
        Properties properties = child.getChildrenAsProperties();
        Interceptor interceptorInstance = (Interceptor) resolveClass(interceptor).newInstance();
        interceptorInstance.setProperties(properties);
        configuration.addInterceptor(interceptorInstance);
      }
    }
  }

Mybatis 进行插件各个流程时,会执行 interceptorChain.pluginAll 对组件进行进一步封装:
在这里插入图片描述
所以在 四个组件初始化时候,进行一步封装,下面看看pluginAll方法:

  public Object pluginAll(Object target) {
    for (Interceptor interceptor : interceptors) {
      target = interceptor.plugin(target);
    }
    return target;
  }

将 传入的 组件执行其 interceptor.plugin ,而 基本上开发者不用重写这个方法,它在 Interceptor 中有默认的实现,主要目的是 使用 interceptor 包装一层 target 返回一个代理对象:

  default Object plugin(Object target) {
    return Plugin.wrap(target, this);
  }

wrap 方法

  public static Object wrap(Object target, Interceptor interceptor) {
  // 获取注解中的方法以及配置了的拦截签名
    Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
    // 获取目标类
    Class<?> type = target.getClass();
    // 获取所有的 涉及到的 拦截器 接口名
    Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
    if (interfaces.length > 0) {
      return Proxy.newProxyInstance(
          type.getClassLoader(),
          interfaces,
          new Plugin(target, interceptor, signatureMap));
    }
	// 返回生成的代理对象
    return target;
  }

最后一个 return Proxy.newProxyInstance(type.getClassLoader(),interfaces,new Plugin(target, interceptor, signatureMap));
返回一个代理类,由于 PluginInvocationHandler 的子类, 最后每当方法调用时,都会经过其invoke 方法。

实际上 , invoke 方法会拦截对应代理对象所有方法,但是会通过传入的 signatureMap 进行一层过滤,只有注解配置过得方法才会被拦截:

  @Override
  public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
      Set<Method> methods = signatureMap.get(method.getDeclaringClass());
      if (methods != null && methods.contains(method)) {
      // 是 signaltureMap 中方法,才会执行 intercept 方法
        return interceptor.intercept(new Invocation(target, method, args));
      }
	// 如果不是,则会直接 执行对应方法
      return method.invoke(target, args);
    } catch (Exception e) {
      throw ExceptionUtil.unwrapThrowable(e);
    }
  }

对于 过滤器链装载顺序,则是以 的方式进行组装:
例如如果有如下相同的 基于 Executor 的拦截器

<plugins>
    <plugin interceptor="com.anla.learn.ExecutorQueryInterceptor1"/>
    <plugin interceptor="com.anla.learn.ExecutorQueryInterceptor2"/>
    <plugin interceptor="com.anla.learn.ExecutorQueryInterceptor3"/>
</plugins>

通过 interceptorChain.pluginAll 方法之后,代理结构如下:

Interceptor3:{
    Interceptor2: {
        Interceptor1: {
            target: Executor
        }
    }
}

而最终执行 则是按照 3>2>1>Executor>1>2>3 顺序执行,类似于递归式执行。

拦截器调用点

其实拦截器的调用点很多,因为Mybatis 内置组件的每一个方法都可以是调用点,只要配置了拦截方法。
所以起始只需要了解四个内置组件的使用顺序,这样 当组件使用时,就是拦截器被触发是:

  1. Executor 包装时,executor = (Executor) interceptorChain.pluginAll(executor),而后和 SqlSession 一并返回
  2. 在拦截器获取 StatementHandler时,同样会通过 statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler),进行一层包装,而后StatementHandler 每一个方法都会进行过滤。
  3. ParameterHandler 初始化,同样会有过滤器 parameterHandler = (ParameterHandler) interceptorChain.pluginAll(parameterHandler);
  4. 以及 最后结果集 resultSetHandler = (ResultSetHandler) interceptorChain.pluginAll(resultSetHandler);

对于 拦截器方法和 Mybatis 内置方法,是会优先执行 拦截器方法,即你可以只执行拦截器,而不执行 Mybatis 方法,
正如 Plugininvoke 方法:

  if (methods != null && methods.contains(method)) {
      // 是 signaltureMap 中方法,才会执行 intercept 方法
        return interceptor.intercept(new Invocation(target, method, args));
      }
	// 如果不是,则会直接 执行对应方法
	 return method.invoke(target, args);

当然,也可以执行完拦截器后,继续执行 Mybatis 正常逻辑流程:
return invocation.proceed();
而 invocation 为调用时 传入 的 Invocation

return interceptor.intercept(new Invocation(target, method, args));

实际上就是调用 method.invoke 即包装类的目标方法。

PageHelper

PageHelper 是国人写的一个优秀的Mybatis分页插件 ,
简介:https://github.com/pagehelper/Mybatis-PageHelper

例如 通过下面两句即可轻松完成分页:

        // 设置当前上下文
        PageHelper.startPage(1, 10);
        List<User> list = mapper.getAllUsers();

比如一个查询,你并不需要每次查询都要写一个 countpage 方法,对于 PageHelper 来说,只需要写一个page 方法即可,PageHelper 会自动帮你完成一次 count 查询,当查询出来的 count 有值时,才会进行第二步的page 操作。

具体例子可以看博主Test:https://github.com/anLA7856/mybatislearn/blob/master/mybatis-interceptor/src/test/java/MybatisPageHelperTest.java

PageHelper 逻辑性原理比较简单,相信大家看了 PageHelper 测试例子后,估计也就能懂了个大半。
那么现在就是看看PageHelper 原理

QueryInterceptor

PageHelper 所有骚操作起点都是 PageInterceptor ,它负责拦截 Executorquery 方法:

@Intercepts(
        {
                @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
                @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
        }
)
public class PageInterceptor implements Interceptor {
	...
}

由于 不同数据库的分页语句不一样,所以 PageHelper 中存在 Dialect (数据库方言)概念,这个选取是从 jdbc url 中获取:

jdbc:mysql://127.0.0.1/df?useUnicode=true

例如以上 数据库就是 mysql,这样一来就可以使用 Mysql 方式进行分页。

下面主要看看 PageInterceptorintercept 方法:

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        try {
        // 从 invocation 中获取参数,拦截的方法有几个参数,就会获取几个参数
            Object[] args = invocation.getArgs();
            // 这样就能
            MappedStatement ms = (MappedStatement) args[0];
            Object parameter = args[1];
            RowBounds rowBounds = (RowBounds) args[2];
            ResultHandler resultHandler = (ResultHandler) args[3];
            // 获取拦截的 对象
            Executor executor = (Executor) invocation.getTarget();
            CacheKey cacheKey;
            BoundSql boundSql;
            // 判断参数个数,从而 获取 boundSql 和 cacheKey
            if (args.length == 4) {
                //4 个参数时
                boundSql = ms.getBoundSql(parameter);
                cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
            } else {
                //6 个参数时
                cacheKey = (CacheKey) args[4];
                boundSql = (BoundSql) args[5];
            }
            // 判断方言是否存在,即是否配置了 dialect 中知道数据库类型
            checkDialectExists();

            List resultList;
            //调用方法判断是否需要进行分页,如果不需要,直接返回结果,其中包括从MappedStatement中获取数据库方言类型
            if (!dialect.skip(ms, parameter, rowBounds)) {
                //判断是否需要进行 count 查询,
                if (dialect.beforeCount(ms, parameter, rowBounds)) {
                    //查询总数
                    Long count = count(executor, ms, parameter, rowBounds, resultHandler, boundSql);
                    //处理查询总数,返回 true 时继续分页查询,false 时直接返回
                    if (!dialect.afterCount(count, parameter, rowBounds)) {
                        //当查询总数为 0 时,直接返回空的结果
                        return dialect.afterPage(new ArrayList(), parameter, rowBounds);
                    }
                }
                resultList = ExecutorUtil.pageQuery(dialect, executor,
                        ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
            } else {
                //rowBounds用参数值,不使用分页插件处理时,仍然支持默认的内存分页
                resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
            }
            return dialect.afterPage(resultList, parameter, rowBounds);
        } finally {
            if(dialect != null){
                dialect.afterAll();
            }
        }
    }

以上逻辑有以下逻辑:

  1. 通过不同的拦截方法,从而定位到 invocation.getArgs 中 参数个数,再通过 数组索引方式获取参数。
  2. 拦截器初始配置时,会尝试去寻找 properties 节点下dialect 配置,如果 有配置则将 dialect 设置为对应方言节点。
  3. 如果没有找到 dialect 配置,则会默认使用 com.github.pagehelper.PageHelper 来生成初始化 的 dialect 。
    PageHelper 类其实类似一个操作类,作为一个装饰器模式 + 门面模式,里面 维护分页参数以及分页方言对象 PageAutoDialect
    所有操作都可以基于 PageHelper 进行,而里面实际调用则是调用 PageAutoDialect 方法。
    在这里插入图片描述
    上面两个子类节点分别代表使用 PageHelper 进行分页,还是使用 Mybatis 中自带 RowBounds 进行分页
  4. !dialect.skip(ms, parameter, rowBounds) 中 会判断是否需要分页,这个方法只有在 PageHelper 有有效实现,其他两个仅给出默认实现,而 PageHelper 中 skip 逻辑,就是获取当前线程的设置的分页参数,如果有设置,则返回 false,进行分页。
    PageMethod 作为基础分页方法,里面维护这一个 ThreadLocal<Page> LOCAL_PAGE 代表当前线程分页参数。
    在这里插入图片描述
    另外,在 skip 中,会 尝试去初始化 PageAutoDialect 中维护的 具体 方言 delegate,会尝试从 MapperStatement 的 url中去寻找,最终通过 PageAutoDialect 维护的 Map<String, Class<? extends Dialect>> 加载出不同的类实例。
  5. 如果有有分页,那么就尝试首先查询初 count(0) 数量,有数量则进行 具体分页查询。在 count(executor, ms, parameter, rowBounds, resultHandler, boundSql); 中,会首先判断当前 mapper 中是否有count 类型的查询,如果有则直接调用该查询返回。
    如果没有,则会新建一个_COUNT 结尾的查询,最后执行查询并返回。
    private Long count(Executor executor, MappedStatement ms, Object parameter,
                       RowBounds rowBounds, ResultHandler resultHandler,
                       BoundSql boundSql) throws SQLException {
        String countMsId = ms.getId() + countSuffix;
        Long count;
        //先判断是否存在手写的 count 查询
        MappedStatement countMs = ExecutorUtil.getExistedMappedStatement(ms.getConfiguration(), countMsId);
        if (countMs != null) {
            count = ExecutorUtil.executeManualCount(executor, countMs, parameter, boundSql, resultHandler);
        } else {
            countMs = msCountMap.get(countMsId);
            //自动创建
            if (countMs == null) {
                //根据当前的 ms 创建一个返回值为 Long 类型的 ms
                countMs = MSUtils.newCountMappedStatement(ms, countMsId);
                msCountMap.put(countMsId, countMs);
            }
            count = ExecutorUtil.executeAutoCount(dialect, executor, countMs, parameter, boundSql, rowBounds, resultHandler);
        }
        return count;
    }
  1. 通过 dialect.afterCount 判断是否需要返回,如果为 0 时候,则直接返回查询结果,不进行下一次具体分页查询。
  2. 如果需要分页,且否则直接执行 ExecutorUtil.pageQuery(dialect, executor,ms, parameter, rowBounds, resultHandler, boundSql, cacheKey); 返回分页查询结果。
  3. 如果最终配置的不需要非呢也,则直接调用 Mybatisexecutor.query 进行下面查询操作。
  4. 在本次查询完后,会执行 dialect.afterPage(resultList, parameter, rowBounds); 用于清除当次查询遗留的本地线程信息。
总结

PageHelper 是一款优秀的分页插件,我们可以不用去编写 多余的 count 查询以及count 判断,也不用考虑不同数据库分页之间差别,这些 PageHelper 都可以帮我们解决。
另外,PageInterceptor 在 intercept 最后,并没有调用 invocation.proceed ,实际上就是 走完这个intercept方法,就会返回结果,但是实际上,PageInterceptor 里面查询逻辑,都是通过 Invocation 中传递过来参数,进行对Mybatis 流程调用,是使用的 executor.query,所以是很好的从Mybatis 插件切入,并且再一次无缝对接 入 Mybatis 的。

但是缺点就是学习成本以及对业务的侵入性,开发者往往不愿意去接纳一个不广泛的框架,而更愿意根据业务去造一个轮子。

后话

总体来说, Mybatis 拦截器 是 Mybatis 提供给开发者侵入 Mybatis 内部执行逻辑的方法,如果操作不当,侵入后可能会影响其本身逻辑。当然这就是见仁见智了。

觉得博主写的有用,不妨关注博主公众号: 六点A君。
哈哈哈,一起研究Mybatis:
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值