Mybatis框架允许用户通过自定义拦截器来改变SQL的执行行为,例如在SQL执行追加分页语句、统计SQL执行耗时等。自定义拦截器也被称为Mybatis插件,插件是 MyBatis 扩展核心功能的常用方式。
注:本文中源码来自mybatis 3.4.x版本,地址https://github.com/mybatis/mybatis-3.git
一 插件实现原理
1.1 配置插件信息
在Mybatis主配置文件中,通过<plugins>标签来注册自定义插件。如
XML
<plugins>
<plugin interceptor="com.example.MyCustomPlugin">
<!-- 可选:传递属性给插件
<property name="name" value="page"/>
</plugin>
</plugins>
在XMLConfigBuilder#parseConfiguration中,会解析plugins标签内容,创建Interceptor并注册到Configuration的InterceptorChain interceptorChain。

1.2 声明拦截器
MyBatis 插件本质是基于动态代理实现的,只能拦截四大核心对象的方法:
Executor:执行器(负责 SQL 执行、缓存管理)StatementHandler:SQL 语句处理器(负责 SQL 构建、参数设置)ParameterHandler:参数处理器(负责参数解析)ResultSetHandler:结果集处理器(负责结果集映射)
为什么是这4大组件呢?Configuration作为这些组件的创建工厂,会应用拦截器并返回代理对象(以StatementHandler为例)


自定义插件需要实现Interceptor接口

其中,Invocation封装了目标对象、方法及参数信息,通过它来获取上述4个组件对象的所有信息。
java
public class Invocation {
private final Object target;
private final Method method;
private final Object[] args;
// 回调原方法
public Object proceed() throws InvocationTargetException, IllegalAccessException {
return method.invoke(target, args);
}
}
1.3 创建代理对象
为了方便创建Executor等的代理对象,Mybatis提供了一个Plugin工具类。关键代码如下:

Plugin类实现了InvocationHandler接口,即采用JDK动态代理来创建代理对象。当Executor执行某个方法时,如果方法被指定需要增强,就会先执行Interceptor#intercept方法。
Plugin#getSignatureMap方法,会解析Interceptor实现类的Intercepts注解信息,得到拦截范围。
java
// @Intercepts 注解:指定拦截的对象和方法(核心)
@Intercepts({
// @Signature:单个拦截规则
// type:拦截的核心对象(Executor/StatementHandler 等)
// method:拦截的方法名(比如 Executor 的 query 方法)
// args:拦截方法的参数类型(用于区分重载方法)
@Signature(
type = Executor.class,
method = "query",
args = {org.apache.ibatis.mapping.MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
)
})
public class MyCustomPlugin implements Interceptor {
}
1.4 自定义插件示例
我们只需要实现Interceptor接口,在intercept()中编写增强逻辑,通过plugin()返回一个代理对象,就能自定义一个Mybatis插件了。
java
@Intercepts({
@Signature(
type = Executor.class,
method = "query",
args = {org.apache.ibatis.mapping.MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
)
})
public class MyCustomPlugin implements Interceptor {
private String name;
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
System.out.println("SQL 执行参数:" + args[1]);
Object result = invocation.proceed();
System.out.println("===== query 方法执行完成 =====");
return result;
}
// 生成代理对象
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
this.name = properties.getProperty("name");
}
}
二 自定义分页插件
2.1 Mybatis自带分页功能
其实,Mybatis支持分页查询,只可惜是内存分页,仍然会查询全部数据,只是在内存中过滤。
使用RowBounds类,通过 offset(偏移量)和 limit(限制数量)控制所要处理的结果集范围,相当于执行了 resultList.subList(offset, offset + limit)。
java
// SqlSession中
<E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds);

在处理ResultSet时,跳过offset条,限制最多处理limit条。

使用方式如下。RowBounds只适合数据量不大的场景,生产环境通常使用物理分页插件替代。
java
SqlSession session = sqlSessionFactory.openSession();
UserMapper mapper = session.getMapper(UserMapper.class);
// 跳过前10条,返回最多20条记录
RowBounds rowBounds = new RowBounds(10, 20);
List<User> user = mapper.selectByAge(20, rowBounds);
2.2 自定义分页插件
基于我们掌握的插件原理,完全可以自行定义一个分页插件。
首先,我们声明一个Page类来封装分页参数
java
public class Page<T> {
private int pageNo = 1; // 当前页码
private int pageSize = 10; // 每页大小
private long total; // 总记录数
private long totalPage; // 总页数
// 省略getter/setter
}
再创建拦截器,拦截 StatementHandler 的 prepare 方法,改写 SQL 添加分页语句,并额外查询一次总记录数。
java
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.List;
import java.util.Properties;
import org.apache.ibatis.executor.statement.RoutingStatementHandler;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ParameterMapping;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.apache.ibatis.scripting.defaults.DefaultParameterHandler;
/**
* MySQL 分页插件
* 拦截 StatementHandler 的 prepare 方法,改写 SQL 添加分页语句
*/
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class PageInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
RoutingStatementHandler handler = (RoutingStatementHandler) invocation.getTarget();
StatementHandler delegate = (StatementHandler) SystemMetaObject.forObject(handler).getValue("delegate");
MappedStatement mappedStatement = (MappedStatement) SystemMetaObject.forObject(delegate).getValue("mappedStatement");
// 获取 BoundSql
BoundSql boundSql = delegate.getBoundSql();
Object parameterObject = boundSql.getParameterObject();
// 判断参数是否为 Page 对象
if (parameterObject instanceof Page) {
Page<?> page = (Page<?>) parameterObject;
String originalSql = boundSql.getSql();
// 1. 查询总记录数
Connection connection = (Connection) invocation.getArgs()[0];
long total = this.queryTotal(connection, page, mappedStatement);
page.setTotal(total);
page.setTotalPage(total % page.getPageSize() == 0 ? total / page.getPageSize() : total / page.getPageSize() + 1);
// 2. 改写 SQL,添加 LIMIT 分页
String pageSql = originalSql + " LIMIT " + page.getOffset() + ", " + page.getPageSize();
// 通过反射修改 BoundSql 中的 SQL
SystemMetaObject.forObject(boundSql).setValue("sql", pageSql);
}
return invocation.proceed();
}
/**
* 查询总记录数
*/
private long queryTotal(Connection connection, Object parameterObject, MappedStatement mappedStatement) throws Exception {
BoundSql boundSql = mappedStatement.getBoundSql(parameterObject);
String sql = "SELECT COUNT(*) FROM (" + boundSql.getSql() + ") tmp_count";
PreparedStatement ps = null;
ResultSet rs = null;
try {
ps = connection.prepareStatement(sql);
// 设置参数(如果有)
if (boundSql.getParameterMappings() != null && !boundSql.getParameterMappings().isEmpty()) {
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
BoundSql countSql = new BoundSql(mappedStatement.getConfiguration(), sql, parameterMappings, parameterObject);
DefaultParameterHandler parameterHandler = new DefaultParameterHandler(mappedStatement, parameterObject, countSql);
parameterHandler.setParameters(ps);
}
rs = ps.executeQuery();
if (rs.next()) {
return rs.getLong(1);
}
return 0;
} finally {
if (rs != null) {
rs.close();
}
if (ps != null) {
ps.close();
}
}
}
@Override
public Object plugin(Object target) {
// 只拦截 StatementHandler
if (target instanceof StatementHandler) {
return Plugin.wrap(target, this);
}
return target;
}
@Override
public void setProperties(Properties properties) {
}
}
在Mybatis主配置中添加插件。
XML
<plugins>
<plugin interceptor="com.example.PageInterceptor">
</plugin>
</plugins>
创建UserQuery类,封装查询条件。执行查询后,就能从UserQuery中获取total等。
java
public class UserQuery extends Page<User> {
private String name; // 姓名(模糊查询)
private Integer minAge; // 最小年龄
private Integer maxAge; // 最大年龄
private String sex;
// 省略getter/setter
}
java
UserQuery query = new UserQuery();
query.setPageNum(1);
query.setPageSize(20);
query.setName("张三");
query.setMinAge(20);
query.setMaxAge(40);
List<User> users = userMapper.selectByPage(query);
long total = query.getTotal();
long totalPage = query.getTotalPage();