手写MyBatis分页插件,一点也不难
目标
- 通过源码分析MyBatis允许被拦截的四大对象
- 学习插件原理的同时手写自己的插件
MyBatis插件又称拦截器(Interceptor)
MyBatis使用责任链模式,通过动态代理组织多个插件(拦截器),通过插件可以改变MyBatis的默认行为(例如SQL重写或结果集重写),由于插件会深入到MyBatis的核心,因此在编写自己的插件前最好先了解下它的原理,以便写出安全高效的插件。
MyBatis允许在已映射语句执行过程中的某一点进行拦截调用,默认情况下MyBatis允许使用插件来拦截的四大对象:
- Executor: 执行增删改查操作
- StatementHandler: 处理SQL预编译,设置参数等相关工作
- ParameterHandler: 设置预编译参数用的
- ResultSetHandler: 处理结果集
允许使用插件来拦截的四大对象在MyBatis的执行流程如下图所示:
MyBatis四大核心接口对象.jpg从MyBatis的源码中我们可以在Configuration.java文件中看到以上四个对象的创建过程,创建之后会以插件的形式加入到拦截器链
拦截器链.png实现MyBatis插件
MyBatis定义插件要实现Interceptor接口,这个接口只声明了三个方法:
- intercept:定义拦截的时候要执行的方法
- setProperties: 在MyBatis进行配置插件的时候可以配置自定义相关属性
- plugin: 插件用于封装目标对象,通过该方法我们可以返回目标对象本身,也可以返回一个它的代理,可以决定是否要进行拦截进而决定要返回一个什么样的目标对象,官方默认是:
return Plugin.wrap.(target, this);
这个方法其实是MyBatis简化我们插件实现的工具方法,其实是根据当前拦截的对象创建了一个动态代理对象。
官方推荐使用 @Intercepts 注解来实现拦截器插件的定义,例如:
@Intercepts(
@Signature(
type=StatementHandler.class,
method="prepare",
args={Connection.class, Integer.class}
)
)
注解和属性 | 释义 |
---|---|
@Intercepts | 只有通过Intercepts注解指定的方法才会执行我们定义的intercept方法 |
@Signature | 定义拦截器需要拦截的方法签名 |
type | 拦截器需要拦截的方法所属的类,上述四大可拦截对象 |
method | 拦截器需要拦截的方法名称 |
args | 拦截器需要拦截的方法参数列表 |
分页插件思路
基于以上对源码和原理的基本分析,我们来看一下要在MyBatis中实现分页插件要如何做。
由于分页查询需要先知道记录总数,需要先写selectCount获取总数,再根据前端传入的页码(pageNo)和每页记录数(pageSize),得出分页部分的值(以MySQL为例,limit from, to),最后再得到selectPage的完整语句。现在使用分页插件我们想要达到的效果是只通过一个方法就可以实现。
思路:拦截并获取查询的原始SQL,然后拼装成countSQL和pageSQL,再进行查找取值。而如何获取查询的SQL,就需要使用我们上面分析的拦截器来获取了。
分页插件实现
分析完了原理和思路之后,我们看一下具体的实现步骤:
- 写一个拦截器用来拦截SQL请求,实现Interceptor接口
- 设置拦截器要拦截的类
- 重写Interceptor接口的三个方法
- 把拼装了分页部分的pageSQL赋给boundSQL
- 最后在配置文件中添加拦截器配置
二话不说,先上代码
@Intercepts(
@Signature(
type = StatementHandler.class,
method = "prepare",
args = {Connection.class, Integer.class}
)
)
public class MyPagePlugin implements Interceptor {
// 插件的核心业务
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 把执行流程交给mybatis
return invocation.proceed();
}
// 把自定义的插件加入到mybatis中去执行
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
// 设置属性
@Override
public void setProperties(Properties properties) {
// TODO 用于适配不同的数据库
String type = properties.getProperty("type");
}
}
如上代码,根据前面的分析,我们已经把架子搭好,这部分已经实现了上述步骤的1、2、3,重点和难点是步骤4,我们先对步骤4进一步分解,可以得到:
- 拿到原始的SQL语句
seletc * from t_user;
- 增加分页子句
seletc * from t_user limit 5, 10;
- 基于原始SQL包装查询总数信息
select count(0) from (seletc * from t_user) temp;
梦想照进现实,以上三步对应的代码实现如下
@Override
public Object intercept(Invocation invocation) throws Throwable {
/**
* 1、拿到原始的SQL语句 (seletc * from t_user)
* 2、基于原始SQL包装查询总数信息 (select count(0) from (seletc * from t_user) temp)
* 3、增加分页子句 (seletc * from t_user limit 5, 10)
*/
// 从invocation拿到StatementHandler对象
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
// 从StatementHandler对象拿原始的SQL语句和分页参数
BoundSql boundSql = statementHandler.getBoundSql();
String sql = boundSql.getSql();
Object paramObj = boundSql.getParameterObject();
// 这一步可以理解为在Spring中使用context.getBean("userBean")
MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
// 获取mapper接口中的方法名称,比如:selectUserByPage
String mapperMethodName = mappedStatement.getId();
// 我们约定只对ByPage结尾的查询进行分页处理
if (mapperMethodName.matches(".*ByPage$")) {
// 业务层通过Map将参数传进来,分页信息使用PageInfo对象
Map<String, Object> params = (Map<String, Object>) paramObj;
PageInfo pageInfo = (PageInfo) params.get("page");
// 基于原始SQL(select * from t_user)获取记录总数
String countSql = "select count(0) from (" + sql + ") temp";
// 为什么这里要使用JDBC ?
// 应用每一个invocation,只能执行一个SQL语句,要留着执行最后的分页语句
Connection connection = (Connection) invocation.getArgs()[0];
PreparedStatement countStatement = connection.prepareStatement(countSql);
ParameterHandler parameterHandler = (ParameterHandler) metaObject.getValue("delegate.parameterHandler");
parameterHandler.setParameters(countStatement);
ResultSet rs = countStatement.executeQuery();
if (rs.next()) {
pageInfo.setTotalNumber(rs.getInt(1));
}
rs.close();
countStatement.close();
// 增加分页子句
String pageSql = this.generaterPageSql(sql, pageInfo);
// 把拼装了分页部分的pageSQL赋给boundSQL
metaObject.setValue("delegate.boundSql.sql", pageSql);
}
// 把执行流程交给mybatis
return invocation.proceed();
}
generaterPageSql 如下:
// 根据原始SQL生成带分页子句的完整语句,以MySQL为例,其他的可以根据类型适配
public String generaterPageSql(String sql, PageInfo pageInfo) {
StringBuffer sb = new StringBuffer();
sb.append(sql);
sb.append(" limit " + pageInfo.getStartIndex() + " , " + pageInfo.getTotalSelect());
return sb.toString();
}
最后,将我们的MyPagePlugin配置到MyBatis插件中,即可对ByPage结尾的查询进行分页处理。