Mybatis 提供了 插件 的机制,使得开发者可以侵入 Mybatis 工作流程,读完前几篇文章,相信大家已经对于 Mybatis,已经有了大致介绍了认识。
本文将从以下几个问题出发:
- Mybatis 可以实现哪几种拦截器?
- Mybatis 中拦截器的使用。
- 这几种拦截器是如何工作的?
- PageHelper 怎么用的?
- PageHelper 如何基于拦截器进行工作的?
用法
由前面文章分析可知,使用Mybatis ,有下面几个流程:
- 构建SqlSession
- 填充参数
- 执行查询
- 封装结果
而对于Mybatis 的拦截器,就是可以利用 提供的拦截器机制对 这四个过程做文章,即拦截这几个过程,实现自己代码逻辑。
- 在 xml 中配置:
<plugins>
<plugin interceptor="anla.learn.mybatis.interceptor.config.SqlStatementHandlerInterceptor">
<property name="dialect" value="mysql"/>
</plugin>
</plugins>
- 使用类实现
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);
}
}
上面代码有以下要点:
- 增加
plugin
标签 - 实现
Interceptor
,并增加@Intercepts
注解,并使用@Signature
说明拦截类型
@Signature
有以下可选参数
- type:只拦截器类型,可选
Executor
、ResultSetHandler
、StatementHandler
、ParameterHandler
。 - 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
本身只是 一个接口,提供了 三个方法 intercept
、plugin
、setProperties
。
intercept
: 拦截过滤器,对于被拦截方法,都会先执行intercept
,然后再会执行确定方法plugin
:提供了默认实现方法,主要是对下一层过滤器或者具体拦截对象进一步封装setProperties
:设置属性,即<plugin>
标签中<property>
子标签
分析一个类原理,首先从该类初始化,而后再从其调用上来分析。
本文将从以下几个点分析拦截器:
- 拦截器初始化:拦截器何时被Mybatis 加载
- 四种拦截器使用点:具体拦截器以怎样方式被初始化并调用?
拦截器初始化
当 Mybatis 机制被加载时,<plugins>
节点内容会被加载并放到 Configuration
中,具体就是加载到 变量 InterceptorChain
中:
protected final InterceptorChain interceptorChain = new InterceptorChain();
在 XMLConfigBuilder
中 pluginElement
方法将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));
返回一个代理类,由于 Plugin
是InvocationHandler
的子类, 最后每当方法调用时,都会经过其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
内置组件的每一个方法都可以是调用点,只要配置了拦截方法。
所以起始只需要了解四个内置组件的使用顺序,这样 当组件使用时,就是拦截器被触发是:
Executor
包装时,executor = (Executor) interceptorChain.pluginAll(executor)
,而后和SqlSession
一并返回- 在拦截器获取
StatementHandler
时,同样会通过statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler)
,进行一层包装,而后StatementHandler
每一个方法都会进行过滤。 ParameterHandler
初始化,同样会有过滤器parameterHandler = (ParameterHandler) interceptorChain.pluginAll(parameterHandler);
- 以及 最后结果集
resultSetHandler = (ResultSetHandler) interceptorChain.pluginAll(resultSetHandler);
对于 拦截器方法和 Mybatis
内置方法,是会优先执行 拦截器方法,即你可以只执行拦截器,而不执行 Mybatis
方法,
正如 Plugin
的 invoke
方法:
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();
比如一个查询,你并不需要每次查询都要写一个 count
和 page
方法,对于 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
,它负责拦截 Executor
的 query
方法:
@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
方式进行分页。
下面主要看看 PageInterceptor
的 intercept
方法:
@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();
}
}
}
以上逻辑有以下逻辑:
- 通过不同的拦截方法,从而定位到
invocation.getArgs
中 参数个数,再通过 数组索引方式获取参数。 - 拦截器初始配置时,会尝试去寻找
properties
节点下dialect
配置,如果 有配置则将dialect
设置为对应方言节点。 - 如果没有找到
dialect
配置,则会默认使用com.github.pagehelper.PageHelper
来生成初始化 的 dialect 。
PageHelper
类其实类似一个操作类,作为一个装饰器模式 + 门面模式,里面 维护分页参数以及分页方言对象PageAutoDialect
。
所有操作都可以基于PageHelper
进行,而里面实际调用则是调用PageAutoDialect
方法。
上面两个子类节点分别代表使用PageHelper
进行分页,还是使用Mybatis
中自带RowBounds
进行分页 - 在
!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>>
加载出不同的类实例。 - 如果有有分页,那么就尝试首先查询初 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;
}
- 通过
dialect.afterCount
判断是否需要返回,如果为 0 时候,则直接返回查询结果,不进行下一次具体分页查询。 - 如果需要分页,且否则直接执行
ExecutorUtil.pageQuery(dialect, executor,ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
返回分页查询结果。 - 如果最终配置的不需要非呢也,则直接调用
Mybatis
的executor.query
进行下面查询操作。 - 在本次查询完后,会执行
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: