MyBatis分页插件实例
在上一篇文章中我们学习了 MyBatis 插件实现原理,这次我们根据所学的知识自己动手来实现一个简单的分页插件。
1、确定需要拦截的签名
MyBatis 运行拦截四大对象中的任意一个对象,通过 Plugin 的源码,我们也看到了需要先注册签名才能使用插件,所以首先要确定需要拦截的对象,才能进一步确定需要配置什么样的签名,进而完成拦截的方法逻辑。
1.1、确定需要拦截的对象
先来看一下四大对象的功能
- Executor 是执行 SQL 的全过程,包括组装参数、执行 SQL 过程和组装结果集返回,都可以拦截,较为广泛,一般用的不算太多。根据是否启动缓存参数,决定它是否使用 CachingExecutor 进行封装,这是拦截该对象时我们需要注意的地方。
- StatementHandler 是执行 SQL 的过程,我们可以重写执行 SQL 的过程,它是最常用的拦截对象
- ParameterHandler 主要拦截执行 SQL 的参数组装,我们可以重写组装参数的规则
- ResultSetHandler 用于拦截执行结果的组装,我们可以重写组装结果的规则
我们的分页插件要拦截的是 StatementHandler 对象,在预编译 SQL 之前修改 SQL,使得结果返回数量被限制。
1.2、拦截方法和参数
确定需要拦截的对象后,接下需要确定拦截的方法以及方法的参数,这些都是在理解了 MyBatis 四大对象运作的基础上才能确定的。
查询的过程是通过 Executor 调度 StatementHandler 来完成的。调度 StatementHandler 的 prepare 方法预编译 SQL,所以我们要拦截的方法便是 prepare 方法,在此方法调用之前完成对 SQL 的改写。先看看 StatementHandler 接口的定义,代码如下:
public interface StatementHandler {
//预编译SQL
Statement prepare(Connection connection, Integer transactionTimeout)
throws SQLException;
//调用 ParameterHandler 组装参数
void parameterize(Statement statement)
throws SQLException;
void batch(Statement statement)
throws SQLException;
int update(Statement statement)
throws SQLException;
//执行SQL查询,并使用 resultHandler 对结果集进行处理
<E> List<E> query(Statement statement, ResultHandler resultHandler)
throws SQLException;
<E> Cursor<E> queryCursor(Statement statement)
throws SQLException;
BoundSql getBoundSql();
ParameterHandler getParameterHandler();
}
以上的任何方法都可以被拦截,一些主要的方法我都加上了注释,通过上面的接口定义代码,可以确定 prepare 方法有一个参数 Connection 对象,所以按照以下的代码来设计我们的拦截器。
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class MyPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
return null;
}
}
其中,@Intercepters 说明该类是一个拦截器。@Signature 是注册拦截器签名的地方,只有签名满足条件才能拦截。type 可以是四大对象中的一个,我们这里拦截的是 StatementHandler 。method 代表要拦截该对象中的方法名,而 args 则表示该方法的参数,这里我们根据 StatementHandler 中的 Statement prepare(Connection connection, Integer transactionTimeout) 方法进行设置。
2、实现拦截方法
上面对原理进行了分析,下面给出一个最简单的插件实现方法,如下:
package com.vnbear.mybatis;
import org.apache.ibatis.executor.statement.StatementHandler;
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.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import java.sql.Connection;
import java.util.Properties;
import lombok.extern.slf4j.Slf4j;
/**
* @Auther: VNBear
* @Date: 2020/12/17 14:29
* @Description:
*/
@Slf4j
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class MyPlugin implements Interceptor {
private Properties properties;
/**
* 插件的核心方法,它将替代 statementHandler 的 prepare 方法
*
* @param invocation
* @return 返回预编译后的 PreparedStatement
* @throws Throwable
*/
@Override
public Object intercept(Invocation invocation) throws Throwable {
//获取代理的对象
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
//使用 MetaObject 工具绑定 StatementHandler,便于后续修改StatementHandler 的属性值
MetaObject metaStatementHandler = SystemMetaObject.forObject(statementHandler);
//分离代理对象链(由于目标类可能被多个插件拦截,形成多次代理,通过循环分离出原始对象)
Object object = null;
//如果是代理对象java.lang.reflect.Proxy,其中存在一个属性名为 h 的InvocationHandler类型的属性
while (metaStatementHandler.hasGetter("h")) {
object = metaStatementHandler.getValue("h");
metaStatementHandler = SystemMetaObject.forObject(object);
}
//获取原始sql
String sql = (String) metaStatementHandler.getValue("delegate.boundSql.sql");
//获取sql的参数
Object parameterObject = metaStatementHandler.getValue("delegate.boundSql.parameterObject");
log.info("执行的SQL为:{}", sql);
log.info("参数为:{}", parameterObject);
log.info("before =======>>");
//如果当前代理的是一个非代理对象,那么回调的是真实对象的方法
//如果不是,那么回调的是下一个插件代理对象的invoke方法
Object proceed = invocation.proceed();
log.info("after <<=======");
return proceed;
}
/**
* 生成代理对象
* @param target
* @return
*/
@Override
public Object plugin(Object target) {
//采用Mybatis默认提供的 Plugin.wrap() 方法生成代理对象
return Plugin.wrap(target, this);
}
/**
* 设置参数,mybatis 初始化的时候就会生成插件实例,然后回调该方法
* @param properties
*/
@Override
public void setProperties(Properties properties) {
this.properties = properties;
}
}
这个插件首选会分离代理对象,然后使用 MetaObject 获取了执行的 SQL 和参数,在反射调度被代理对象的真实方法前后打印了日志,这意味着我们可以在 Object proceed = invocation.proceed() 这一行代码前后加入我们自己的处理逻辑,从而满足需求。
3、配置和运行
如果是使用 SpringBoot 可以使用如下方式进行插件的配置:
package com.vnbear.mybatis;
import org.mybatis.spring.boot.autoconfigure.ConfigurationCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Properties;
/**
* @Auther: VNBear
* @Date: 2020/12/17 15:08
* @Description:
*/
@Configuration
public class PluginConfig {
/**
* 方法1
* @return
*/
@Bean
public MyPlugin myPlugin() {
MyPlugin myPlugin = new MyPlugin();
Properties properties = new Properties();
properties.setProperty("pluginKey", "helloWorld");
myPlugin.setProperties(properties);
return myPlugin;
}
/**
* 方法2
* @return
*/
@Bean
public ConfigurationCustomizer configurationCustomizer() {
return new ConfigurationCustomizer() {
@Override
public void customize(org.apache.ibatis.session.Configuration configuration) {
//插件拦截链采用了责任链模式,执行顺序和加入连接链的顺序有关
MyPlugin myPlugin = new MyPlugin();
Properties properties = new Properties();
properties.setProperty("pluginKey", "helloWorld");
myPlugin.setProperties(properties);
configuration.addInterceptor(myPlugin);
}
};
}
}
如果使用 xml 配置文件的方式,参考如下配置:
<plugins>
<plugin interceptor= ” com.vnbear.mybatis.MyPlugin ” >
<property name= ”pluginKey” value= ”helloWorld” />
</plugin>
</plugins>
4、分页插件实例
在Mybatis 中存在一个 RowBounds 参数用于分页,但是它的分页原理是基于第一次查询结果的再分页,也就是先让 SQL 查出所有的记录,然后分页,这是典型的物理分页,性能不高。当然也可以通过两条 SQl,一条用于当前页查询,一条用于查询记录总数,但是如果每一个查询都这样,过于繁琐,增加了工作量。而查询 SQL 往往存在一定规律,查询的 SQL 通过加入分页参数,可以查询当前页,查询的 SQL 也可以通过改造变为统计总数的 SQL。
通过上面的学习,我们已经做好自己动手实现分页插件的准备,下面开始自己动手造一个轮子。
4.1、分页参数
定义一个POJO,用于设置分页的各种参数,代码如下:
package com.vnbear.mybatis;
import lombok.Data;
/**
* @Auther: VNBear
* @Date: 2020/12/17 15:41
* @Description:
*/
@Data
public class PageParams {
/**
* 当前页码
*/
private Integer page;
/**
* 每页限制条数
*/
private Integer pageSize;
/**
* 是否启动插件
*/
private Boolean useFlag;
/**
* 是否检测页码的有效性,如果true,当页码大于最大页数时,抛出异常
*/
private Boolean checkFlag;
/**
* 是否清除 order by 后面的语句
*/
private Boolean cleanOrderBy;
/**
* 总条数,插件回填该值
*/
private Integer total;
/**
* 总页数,插件回填该值
*/
private Integer totalPage;
}
POJO 中的各参数都已经加上了注释,值得注意的是 total 和 totalPage 这两个属性需要插件进行回填,这样使用者使用该分页 POJO 传递分页信息时,最后也可以通过该分页 POJO 获得记录的总条数和分页总数。
在 MyBatis 中传递参数可以是单个参数,也可以是多个,或者使用 map。有了这些规则,为了使用方便,定义只要满足下列条件之一,就可以启用分页参数(PageParams)。
- 传递单个 PageParmas 或者其子对象。
- map 中存在一个值为 PageParmas 或者其子对象的参数。
- 在 MyBatis 中传递多个参数,但其中之一为 PageParams 或者其子对象。
- 传递单个 POJO 参数,该 POJO 有一个属性为 PageParams 或者其子对象,且提供了 setter 和 getter方法。
4.2、确定拦截对象和方法
我们可以通过拦截 StatementHandler 的 prepare 方法,进而从 BoundSql 中获取 SQL 以及参数规则的描述,从而获取我们的分页插件参数。
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
4.3、实现拦截方法
- Plugin方法:我们使用 Plugin 类的静态方法 wrap 生成代理对象。当 PageParmas 的 useFlag 属性为 false 时,也就是禁用此分页插件是,就无需生成代理对象了,因为使用代理会造成性能下降。
- setProperties方法:用它给 PageParams 属性定义默认值。
- intercept方法:该方法是我们的核心
根据以上思路,给出如下的实现代码:
package com.vnbear.mybatis.plugin;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
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.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.apache.ibatis.scripting.defaults.DefaultParameterHandler;
import org.apache.ibatis.session.Configuration;
import java.beans.IntrospectionException;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import lombok.Data;
/**
* @Auther: VNBear
* @Date: 2020/12/17 16:08
* @Description:
*/
@Data
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class PagePlugin implements Interceptor {
/**
* 默认页码
*/
private Integer defaultPage;
/**
* 默认每页数量
*/
private Integer defaultPageSize;
/**
* 默认是否启用插件
*/
private Boolean defaultUseFlag;
/**
* 默认是否检测页码参数
*/
private Boolean defaultCheckFlag;
/**
* 默认是否清除最后一个 order by后的语句
*/
private Boolean defaultCleanOrderBy;
private static final String KEY_SQL = "delegate.boundSql.sql";
private static final String KEY_BOUND_SQL = "delegate.boundSql";
private static final String KEY_MAPPED_STATEMENT = "delegate.mappedStatement";
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) getUnProxyObject(invocation.getTarget());
MetaObject metaStatementHandler = SystemMetaObject.forObject(statementHandler);
String sql = (String) metaStatementHandler.getValue(KEY_SQL);
MappedStatement mappedStatement = (MappedStatement) metaStatementHandler.getValue(KEY_MAPPED_STATEMENT);
//如果不是 select 语句,直接执行被代理对象的方法
if (!checkSql(sql)) {
return invocation.proceed();
}
BoundSql boundSql = (BoundSql) metaStatementHandler.getValue(KEY_BOUND_SQL);
Object parameterObject = boundSql.getParameterObject();
//获取分页参数
PageParams pageParams = getPageParamsFromParamObj(parameterObject);
//无法获取分页参数,不进行分页
if (pageParams == null) {
return invocation.proceed();
}
//判断是否启用分页功能
Boolean useFlag = pageParams.getUseFlag() == null ? this.defaultUseFlag : pageParams.getUseFlag();
if (!useFlag) {
//不进行分页
return invocation.proceed();
}
//获取相关配置的参数
Integer pageNum = pageParams.getPage() == null ? defaultPage : pageParams.getPage();
Integer pageSize = pageParams.getPageSize() == null ? defaultPageSize : pageParams.getPageSize();
Boolean checkFlag = pageParams.getCheckFlag() == null ? defaultCheckFlag : pageParams.getCheckFlag();
Boolean cleanOrderBy = pageParams.getCleanOrderBy() == null ? defaultCleanOrderBy : pageParams.getCleanOrderBy();
//计算总条数
int total = getTotal(invocation, metaStatementHandler, boundSql, cleanOrderBy);
//回填总条数到分页参数
pageParams.setTotal(total);
//计算总页数
int totalPage = total % pageSize == 0 ? total / pageSize : total / pageSize + 1;
//回填总页数到分页参数
pageParams.setTotalPage(totalPage);
//检查当前页码的有效性
checkPage(checkFlag, pageNum, totalPage);
//修改sql
return preparedSQL(invocation, metaStatementHandler, boundSql, pageNum, pageSize);
}
@Override
public Object plugin(Object target) {
//生成代理对象
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties props) {
//从配置中获取参数
String strDefaultPage = props.getProperty("default.page", "1");
String strDefaultPageSize = props.getProperty("default.pageSize", "20");
String strDefaultUseFlag = props.getProperty("default.useFlag", "false");
String strDefaultCheckFlag = props.getProperty("default.checkFlag", "false");
String StringDefaultCleanOrderBy = props.getProperty("default.cleanOrderBy", "false");
//设置默认参数.
this.defaultPage = Integer.parseInt(strDefaultPage);
this.defaultPageSize = Integer.parseInt(strDefaultPageSize);
this.defaultUseFlag = Boolean.parseBoolean(strDefaultUseFlag);
this.defaultCheckFlag = Boolean.parseBoolean(strDefaultCheckFlag);
this.defaultCleanOrderBy = Boolean.parseBoolean(StringDefaultCleanOrderBy);
}
/**
* 从代理对象中分离出真实对象
*
* @param target
* @return 非代理的 StatementHandler对象
*/
private Object getUnProxyObject(Object target) {
MetaObject metaStatementHandler = SystemMetaObject.forObject(target);
//分离代理对象链(由于目标类可能被多个插件拦截,形成多次代理,通过循环分离出原始对象)
Object object = null;
//如果是代理对象java.lang.reflect.Proxy,其中存在一个属性名为 h 的InvocationHandler类型的属性
while (metaStatementHandler.hasGetter("h")) {
object = metaStatementHandler.getValue("h");
metaStatementHandler = SystemMetaObject.forObject(object);
}
if (object == null) {
return target;
}
return object;
}
/**
* 判断是否是 select 语句
*
* @param sql
* @return
*/
private boolean checkSql(String sql) {
String trimSql = sql.trim();
int index = trimSql.toLowerCase().indexOf("select");
return index == 0;
}
/**
* 从sql参数中获取分页参数
*
* @param parameterObject
* @return
*/
private PageParams getPageParamsFromParamObj(Object parameterObject) throws IntrospectionException, InvocationTargetException, IllegalAccessException {
PageParams pageParams = null;
if (parameterObject == null) {
return null;
}
//处理map参数,多个匿名参数和@param 注解参数都是map
if (parameterObject instanceof Map) {
Map<String, Object> paramMap = (Map<String, Object>) parameterObject;
Set<String> keySet = paramMap.keySet();
Iterator<String> iterator = keySet.iterator();
while (iterator.hasNext()) {
String key = iterator.next();
Object value = paramMap.get(key);
if (value instanceof PageParams) {
return (PageParams) value;
}
}
} else if (parameterObject instanceof PageParams) {
//参数是PageParams 或者是继承了 PageParams
return (PageParams) parameterObject;
} else {
//从POJO属性尝试读取分页参数
Field[] fields = parameterObject.getClass().getDeclaredFields();
for (Field field : fields) {
if (field.getType() == PageParams.class) {
PropertyDescriptor pd = new PropertyDescriptor(field.getName(), parameterObject.getClass());
Method readMethod = pd.getReadMethod();
//反射调用
return (PageParams) readMethod.invoke(parameterObject);
}
}
}
return pageParams;
}
/**
* 获取总条数
*
* @param ivt
* @param metaStatementHandler
* @param boundSql
* @param cleanOrderBy
* @return sql 查询总数
* @throws Throwable
*/
private int getTotal(Invocation ivt, MetaObject metaStatementHandler,
BoundSql boundSql, Boolean cleanOrderBy) throws Throwable {
//获取当前的mappedStatement
MappedStatement mappedStatement = (MappedStatement) metaStatementHandler.getValue(KEY_MAPPED_STATEMENT);
//获取配置对象
Configuration configuration = mappedStatement.getConfiguration();
//当前需要执行的SQL
String sql = (String) metaStatementHandler.getValue(KEY_SQL);
//去除最后的order by语句
if (cleanOrderBy) {
sql = this.cleanOrderByForSql(sql);
}
//改为统计总数的SQL
String countSql = "select count(*) as total from (" + sql + ") vnbear_paging";
//获取拦截方法参数,根据插件签名,知道是Connection对象
Connection connection = (Connection) ivt.getArgs()[0];
PreparedStatement ps = null;
int total = 0;
try {
//预编译总计总数的SQL
ps = connection.prepareStatement(countSql);
//构建统计总数的BoundSQL
BoundSql countBoundSql = new BoundSql(configuration, countSql, boundSql.getParameterMappings(), boundSql.getParameterObject());
//构建MyBatis的 ParameterHandler 用来设置总数 SQL 的参数
DefaultParameterHandler handler = new DefaultParameterHandler(mappedStatement, countBoundSql.getParameterObject(), countBoundSql);
//设置总数SQL参数
handler.setParameters(ps);
//执行查询
ResultSet resultSet = ps.executeQuery();
while (resultSet.next()) {
total = resultSet.getInt("total");
}
} finally {
//这里不能关闭connection,否则后面的sql无法执行
if (ps != null) {
ps.close();
}
}
return total;
}
private String cleanOrderByForSql(String sql) {
StringBuilder stringBuilder = new StringBuilder(sql);
String newSql = sql.toLowerCase();
int index = newSql.lastIndexOf("order");
//没有order 语句,直接返回
if (index == -1) {
return sql;
}
return stringBuilder.substring(0, index).toString();
}
/**
* 检测当前页码的有效性
*
* @param checkFlag
* @param pageNum
* @param pageTotal
*/
private void checkPage(Boolean checkFlag, Integer pageNum, Integer pageTotal) {
if (checkFlag) {
//检查页码是否合法
if (pageNum > pageTotal) {
throw new IllegalArgumentException("查询失败,查询页码 " + pageNum + " 大于总页数 " + pageTotal);
}
}
}
/**
* 预编译改写后的SQL,并设置分页参数
*
* @param invocation
* @param metaStatementHandler
* @param boundSql
* @param pageNum
* @param pageSize
* @return
*/
private Object preparedSQL(Invocation invocation, MetaObject metaStatementHandler, BoundSql boundSql, int pageNum, int pageSize) throws Exception {
//当前需要执行的SQL
String sql = boundSql.getSql();
//分页SQL
String newSql = "select * from (" + sql + ") vnbear_paging_table limit ?,?";
//修改当前需要执行的sql
metaStatementHandler.setValue(KEY_SQL, newSql);
//执行编译,调度被代理对象的真实方法,相当于 StatementHandler 执行了 prepare 方法
PreparedStatement ps = (PreparedStatement) invocation.proceed();
//prepared 方法编译 SQL后 ,由于 MyBatis 上下文没有分页参数的信息,所以这里需要设置这两个参数
//获取需要设置的参数个数,由于参数是最后的两个,所以很容易得到其位
int idx = ps.getParameterMetaData().getParameterCount();
//最后两个是分页参数,注意这里的parameterIndex是从1开始算得
ps.setInt(idx - 1, (pageNum - 1) * pageSize); //开始行
ps.setInt(idx, pageSize); //限制条数
return ps;
}
}
逻辑已经在代码里注释的很清楚了,小伙伴们可以自己慢慢看,有问题可以留言。
4.3、配置启用分页插件
package com.vnbear.mybatis.plugin;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Properties;
/**
* @Auther: VNBear
* @Date: 2020/12/17 17:31
* @Description:
*/
@Configuration
public class PagePluginConfig {
@Bean
public PagePlugin pagePlugin() {
PagePlugin pagePlugin = new PagePlugin();
Properties properties = new Properties();
//默认页码
properties.setProperty("default.page", "1");
//默认每页条数
properties.setProperty("default.pageSize", "20");
//是否启动插件功能
properties.setProperty("default.useFlag", "false");
//是否检查页码有效性
properties.setProperty("default.checkFlag", "false");
//是否去掉最后一个 order by 以后的语句,以便提高查询效率
properties.setProperty("default.cleanOrderBy", "false");
pagePlugin.setProperties(properties);
return pagePlugin;
}
}
总结
通过以上的内容,我们分析了分页插件的实现原理以及实现插件时所需要处理的步骤,并进行了分页插件的实际代码编写。但是插件是 MyBatis 中最强大的组件,同时也是最危险的组件,必须清楚 MyBatis 内部的运行原理,才能避免编写插件时出错,否则可能摧毁整个 MyBatis 的功能。