前言
分页查询在日常开发中非常常见,实现的方式一般有两种:
第一种是从数据库中查询出所有满足条件的数据,然后通过应用程序进行分页处理,这种方式在数据量过大时效率比较低,而且可能会造成内存溢出,所以不太常用。
第二种是通过数据库提供的分页语句进行物理分页,这种该方式效率较高且查询数据量较少,所以是一种比较查用的分页方式。
本节基于数据库物理分页的方式编写一个MyBatis分页插件。
9.2 自定义一个MyBatis分页插件
9.2.1 自定义分页插件的编写
由于面向对象设计原则中提倡面向接口编程,因此首先可以编写一个接口,定义有关分页的一些基本方法。例如编写一个Paginable接口:
public interface Paginable<T> {
/** 总记录数 */
int getTotalCount();
/** 总页数 */
int getTotalPage();
/** 每页记录数 */
int getPageSize();
/** 当前页号 */
int getPageNo();
}
然后编写一个Page类实现Paginable接口,来具体描述分页信息:
public class Page<T> implements Paginable<T> {
/** 当前页面,默认第1页 */
private int pageNo = 1;
/** 每页记录数,默认10条 */
private int pageSize = 10;
/** 总记录数 */
private int totalCount = 0;
/** 总页数 */
private int totalPage = 0;
/** 查询时间戳 */
private long timestamp = 0;
/** 是否全量更新,若true,则会更新totalCount */
private boolean full = false;
@Override
public int getTotalCount() {
return totalCount;
}
public void setTotalCount(int totalCount) {
this.totalCount = totalCount;
// 设置总记录数totalCount时,根据一定的规则计算出总页数totalPage
int totalPage = totalCount % pageSize == 0 ? totalCount / pageSize : totalCount / pageSize + 1;
this.setTotalPage(totalPage);
}
// 其他getter、setter方法 ......
}
分页插件的基本思路是:在SQL语句执行前,从目标对象中将SQL取出来,替换成能查询总记录数或进行分页的SQL语句,再放回目标对象中。
既然要提取和重新设置目标对象的属性,那么可以先定义一个工具类,通过反射机制提取和设置Java对象的属性。
public abstract class ReflectionUtils {
/**
* 利用反射获取指定对象的指定属性
*
* @param target 目标对象
* @param fieldName 目标属性
* @return 目标属性的值
*/
public static Object getFieldValue(Object target, String fieldName) {
Object result = null;
Field field = ReflectionUtils.getField(target, fieldName);
if (field != null) {
field.setAccessible(true);
try {
result = field.get(target);
} catch (Exception e) {
e.printStackTrace();
}
}
return result;
}
/**
* 利用反射获取指定对象里面的指定属性
*
* @param target
* 目标对象
* @param fieldName
* 目标属性
* @return 目标字段
*/
private static Field getField(Object target, String fieldName) {
Field field = null;
for (Class<?> clazz = target.getClass(); clazz != Object.class; clazz = clazz.getSuperclass()) {
try {
field = clazz.getDeclaredField(fieldName);
break;
} catch (NoSuchFieldException e) {
// ignore
}
}
return field;
}
/**
* 利用反射设置指定对象的指定属性为指定的值
*
* @param target 目标对象
* @param fieldName 目标属性
* @param fieldValue 目标值
*/
public static void setFieldValue(Object target, String fieldName, String fieldValue) {
Field field = ReflectionUtils.getField(target, fieldName);
if (field != null) {
try {
field.setAccessible(true);
field.set(target, fieldValue);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
接下来正式编写分页插件:
// 拦截StatementHandler接口的prepare()方法
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class PageInterceptor implements Interceptor {
// 数据库类型
private String databaseType;
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
this.databaseType = properties.getProperty("databaseType");
}
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 获取拦截的目标对象
RoutingStatementHandler handler = (RoutingStatementHandler) invocation.getTarget();
// 获取目标对象组合的StatementHandler对象(delegate属性)
StatementHandler delegate = (StatementHandler) ReflectionUtils.getFieldValue(handler, "delegate");
// 获取StatementHandler对象中封装的BoundSql对象
BoundSql boundSql = delegate.getBoundSql();
// 获取BoundSql对象中的参数对象
Object parameterObject = boundSql.getParameterObject();
// 如果参数对象是Page类,才进行增强逻辑
if(parameterObject instanceof Page<?>) {
Page<?> page = (Page<?>) parameterObject;
// 获取StatementHandler对象中封装的MappedStatement对象
MappedStatement mappedStatement = (MappedStatement) ReflectionUtils.getFieldValue(delegate, "mappedStatement");
// 获取目标方法的参数之一:Connection对象
Connection connection = (Connection) invocation.getArgs()[0];
// 获取BoundSql对象中的SQL语句
String sql = boundSql.getSql();
System.out.println("原SQL语句:" + sql);
if(page.isFull()) {
// 获取记录总数
this.setTotalCount(page, mappedStatement, connection);
}
page.setTimestamp(System.currentTimeMillis());
// 根据原SQL语句和page对象的信息,获取分页SQL
String pageSql = this.getPageSql(page, sql);
System.out.println("分页SQL语句:" + pageSql);
// 替换BoundSql对象中的SQL语句
ReflectionUtils.setFieldValue(boundSql, "sql", pageSql);
}
// 继续指定原目标方法
return invocation.proceed();
}
private String getPageSql(Page<?> page, String sql) {
// todo
return null;
}
private void setTotalCount(Page<?> page, MappedStatement mappedStatement, Connection connection) {
// todo
}
}
MyBatis自定义插件类都必须实现Interceptor接口,还需要通过@Intercepts注解配置对哪些组件的哪些方法进行拦截。
在本案例中,指定对StatementHandler对象的prepare()
方法进行拦截,因此在调用StatementHandler对象的prepare()
方法之前,会调用PageInterceptor对象的intercept()
方法。
在PageInterceptor对象的intercept()
方法中,拦截逻辑的大致流程是:
(1)如果参数对象是Page类型(或是Page类的子类),则进入分页逻辑,通过反射机制获取BoundSql对象,从该对象中提取出要执行的SQL语句和参数对象;
(2)如果全量更新配置为true,则调用setTotalCount()
方法,将原SQL语句转换为查总记录数的SQL语句,查询出记录总数;
(3)接着调用getPageSql()
方法将原SQL语句转换为对应数据库类型格式的分页SQL语句;
(4)将分页SQL语句放回目标方法中,执行目标方法,底层则会执行分页SQL语句。
需要注意的是,进入分页逻辑的条件是:参数对象是Page类型。因此,Mapper方法的参数对象必须继承Page类。
PageInterceptor类中还有getPageSql()
方法、setTotalCount()
方法未完成,下面继续:
/**
* 给当前的参数对象page设置总记录数
*
* @param page Mapper映射语句对应的参数对象
* @param mappedStatement Mapper映射语句
* @param connection 当前的数据库连接
*/
private void setTotalCount(Page<?> page, MappedStatement mappedStatement, Connection connection) {
// 获取原SQL语句
BoundSql boundSql = mappedStatement.getBoundSql(page);
String sql = boundSql.getSql();
// 根据原SQL语句获取对应的查询记录总数的SQL语句
String countSql = "select count(1) " + sql.substring(sql.toLowerCase().indexOf("from"));
System.out.println("查询记录总数的SQL语句:" + countSql);
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
// 重新封装一个BoundSql对象和ParameterHandler对象
BoundSql countBoundSql = new BoundSql(mappedStatement.getConfiguration(), countSql, parameterMappings, page);
ParameterHandler parameterHandler = new DefaultParameterHandler(mappedStatement, page, countBoundSql);
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
// 执行查询记录总数的语句
pstmt = connection.prepareStatement(countSql);
parameterHandler.setParameters(pstmt);
rs = pstmt.executeQuery();
if(rs.next()) {
int totalCount = rs.getInt(1);
System.out.println("获得记录总数 = " + totalCount);
// 设置记录总数的同时已设置了总页数
page.setTotalCount(totalCount);
}
} catch (SQLException e) {
e.printStackTrace();
} finally {
if(rs != null) {
try {
rs.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if(pstmt != null) {
try {
pstmt.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
由setTotalCount()
方法的实现可知,查询记录总数是一次额外的查询,直接使用JDBC API操作数据库。 这也很好理解,因为用户并没有编写查询记录总数的SQL语句,但这个查询又是有需要的。
/**
* 根据page对象获取对应的分页查询Sql语句,
* 这里只做了三种数据库类型,Mysql、Oracle、HSQLDB
* 其它的数据库都没有进行分页
*
* @param page 分页对象
* @param sql 原始sql语句
* @return
*/
private String getPageSql(Page<?> page, String sql) {
StringBuffer sqlBuffer = new StringBuffer(sql);
if ("mysql".equalsIgnoreCase(databaseType)) {
return getMysqlPageSql(page, sqlBuffer);
} else if ("oracle".equalsIgnoreCase(databaseType)) {
return getOraclePageSql(page, sqlBuffer);
} else if ("hsqldb".equalsIgnoreCase(databaseType)) {
return getHSQLDBPageSql(page, sqlBuffer);
}
return sqlBuffer.toString();
}
/**
* 获取Mysql数据库的分页查询语句
*
* @param page 分页对象
* @param sqlBuffer 包含原sql语句的StringBuffer对象
* @return Mysql数据库分页语句
*/
private String getMysqlPageSql(Page<?> page, StringBuffer sqlBuffer) {
int offset = (page.getPageNo() - 1) * page.getPageSize();
sqlBuffer.append(" limit ").append(offset).append(",").append(page.getPageSize());
return sqlBuffer.toString();
}
/**
* 获取Oracle数据库的分页查询语句
*
* @param page 分页对象
* @param sqlBuffer 包含原sql语句的StringBuffer对象
* @return Oracle数据库的分页查询语句
*/
private String getOraclePageSql(Page<?> page, StringBuffer sqlBuffer) {
int offset = (page.getPageNo() - 1) * page.getPageSize() + 1;
sqlBuffer.insert(0, "select u.*, rownum r from (").append(") u where rownum < ")
.append(offset + page.getPageSize());
sqlBuffer.insert(0, "select * from (").append(") where r >= ").append(offset);
return sqlBuffer.toString();
}
/**
* 获取HSQLDB数据库的分页查询语句
*
* @param page 分页对象
* @param sqlBuffer 包含原sql语句的StringBuffer对象
* @return Oracle数据库的分页查询语句
*/
private String getHSQLDBPageSql(Page<?> page, StringBuffer sqlBuffer) {
int offset = (page.getPageNo() - 1) * page.getPageSize() + 1;
return "select limit " + offset + " " + page.getPageSize() + " * from (" + sqlBuffer.toString() + " )";
}
自定义的分页插件支持3种数据库厂商,分别是MySQL、Oracle、HSQLDB,它们的分页查询语句各不相同。
到此为止,分页插件已经编写好了。接下来编写测试代码,看看在实际开发中如何使用该插件。
9.2.2 自定义分页插件的使用
自定义插件后,需要在MyBatis主配置文件中对插件进行注册:
<!--mybatis-config.xml-->
<plugins>
<plugin interceptor="com.star.mybatis.page.PageInterceptor">
<property name="databaseType" value="mysql"/>
</plugin>
</plugins>
上面的配置中,通过databaseType属性指定数据库类型为MySQL。
由于仅当参数对象是Page的子类时才会执行分页逻辑,因此需要编写一个UserQuery类继承Page类:
public class UserQuery extends Page<User> {
}
接下来是Mapper方法:
public interface UserMapper {
@Select("select * from user")
List<User> selectUserPage(UserQuery userQuery);
}
Mapper方法selectUserPage()
中编写的SQL语句,只是一个简单的SELECT语句,并没有涉及到分页查询。具体的分页操作都是由自定义的插件来完成的。
最后,编写单元测试调用Mapper方法:
@Test
public void testPage() throws IOException {
Reader reader = Resources.getResourceAsReader("mybatis-config.xml");
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(reader);
SqlSession sqlSession = sqlSessionFactory.openSession();
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
UserQuery userQuery = new UserQuery();
userQuery.setPageNo(1);
userQuery.setPageSize(2);
userQuery.setFull(true);
List<User> userList = userMapper.selectUserPage(userQuery);
userList.forEach(System.out::println);
}
运行单元测试,控制台打印相关信息:
原SQL语句:select * from user
查询记录总数的SQL语句:select count(1) from user
获得记录总数 = 3
分页SQL语句:select * from user limit 0,2
User{id=1, name='孙悟空', age=1500, phone='18705464523', birthday=Thu Jan 01 00:00:00 CST 1}
User{id=2, name='猪八戒', age=1000, phone='15235468789', birthday=Fri Mar 10 00:00:00 CST 500}
由结果可知,自定义的分页插件会根据原SQL语句,构建出对应的查询记录总数SQL语句,以及分页查询SQL语句,使得最终查询的结果是分页后的数据。
9.3 自定义慢SQL统计插件
在实际项目中,有时会因为各种原因导致SQL执行耗时过长,从而影响服务性能。为了对耗时过长的SQL语句进行优化,就需要先把SQL语句找出来。
利用MyBatis插件功能,可以把执行时间超过某个设定的值的SQL语句输出到日志中,从而更有针对性地对SQL语句进行优化。
下面编写一个SlowSqlInterceptor类实现Interceptor接口,通过@Intercepts注解配置拦截StatementHandler对象的query()
、update()
、batch()
方法:
@Intercepts({
@Signature(type = StatementHandler.class, method = "query", args = {Statement.class, ResultHandler.class}),
@Signature(type = StatementHandler.class, method = "update", args = {Statement.class}),
@Signature(type = StatementHandler.class, method = "batch", args = {Statement.class})
})
public class SlowSqlInterceptor implements Interceptor {
// 超时时长(秒)
private Integer limitSecond;
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
String limitSecond = (String) properties.get("limitSecond");
this.limitSecond = Integer.parseInt(limitSecond);
}
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 记录开始执行时间
long beginTimeMillis = System.currentTimeMillis();
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
try {
// 执行SQL语句
return invocation.proceed();
} finally {
// 记录结束执行时间
long endTimeMillis = System.currentTimeMillis();
// 计算执行时间
long costTimeMillis = endTimeMillis - beginTimeMillis;
if (costTimeMillis > limitSecond * 1000) {
BoundSql boundSql = statementHandler.getBoundSql();
System.out.println("SQL语句【" + boundSql.getSql() + "】,执行耗时:" + costTimeMillis + "ms");
}
}
}
}
在intercept()
方法中,分别记录SQL语句执行前后的时间,如果两者的差超过了配置好的超时时间(limitSecond属性),则打印出这条SQL语句及其耗时。(为演示方便,这里只是将SQL语句打印在控制台,实际项目中可以输出到日志文件中)
最后,在MyBatis主配置文件中对插件进行注册:
<!--mybatis-config.xml-->
<plugins>
<plugin interceptor="com.star.mybatis.page.PageInterceptor">
<property name="databaseType" value="mysql"/>
</plugin>
<plugin interceptor="com.star.mybatis.page.SlowSqlInterceptor">
<property name="limitSecond" value="1"/>
</plugin>
</plugins>
再次执行【9.2 分页插件】的单元测试代码。为了模拟出慢SQL的效果,可以借助IDE在SlowSqlInterceptor类的intercept()
方法中的return invocation.proceed();
这一行代码上打一个断点,稍停几秒再放通。
控制台打印结果:
SQL语句【select * from user limit 0,2】,执行耗时:22120ms
这样,一个检验慢SQL的插件也生效了。
9.4 小结
第九章到此就梳理完毕了,本章的主题是:MyBatis拦截器原理及应用。回顾一下本章的梳理的内容:
(二十二)拦截器的实现原理与执行过程
(二十三)自定义分页插件、慢SQL插件
更多内容请查阅分类专栏:MyBatis3源码深度解析
第十章主要学习:MyBatis级联映射与懒加载。主要内容包括:
- MyBatis级联映射原理;
- MyBatis懒加载机制。