一. 面试题及剖析
1. 今日面试题
2. 题目剖析
在上一篇文章中,壹哥给大家讲解了MySQL中的分页效果是如何实现的,以及如何对大数据量时的分页进行优化。本文主要是结合经典的PageHelper分页插件,讲解分页插件的底层源码和执行原理。如果你没看过上一篇文章,请参考下面的链接:
高薪程序员&面试题精讲系列85之MySQL如何进行分页?
二. PageHelper基本使用
1. 简介
PageHelper是与Mybatis配合使用最方便的一款开源分页插件,具有如下特色:
2. 基本使用
PageHelper的使用其实很简单,引入必要的依赖包之后,在项目中进行简单的配置即可。
2.1 引入依赖包
在 pom.xml 文件中添加如下依赖即可:
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper</artifactId>
<version>版本号</version>
</dependency>
2.2 配置拦截器插件
特别注意,新版的拦截器插件是 com.github.pagehelper.PageInterceptor。
而之前旧版的com.github.pagehelper.PageHelper
现在是一个特殊的 dialect 实现类,是分页插件的默认实现类,提供了和以前相同的用法。以下壹哥是在mybatis的配置文件中进行了拦截器插件的配置,但如果我们的项目是SpringBoot项目,则直接在配置类中生成PageInterceptor类的Bean对象即可,更为简单。
<!--
plugins在配置文件中的位置必须符合要求,否则会报错,顺序如下:
properties?, settings?,
typeAliases?, typeHandlers?,
objectFactory?,objectWrapperFactory?,
plugins?,
environments?, databaseIdProvider?, mappers?
-->
<plugins>
<!-- com.github.pagehelper为PageHelper类所在包名 -->
<plugin interceptor="com.github.pagehelper.PageInterceptor">
<!-- 使用下面的方式配置参数,后面会有所有的参数介绍 -->
<property name="param1" value="value1"/>
</plugin>
</plugins>
至于该分页插件的属性详情,壹哥就不再详细讲解了,这些在我们的线下课程中都有详细讲解,只要各位跟着壹哥听过线下课,这些都已经了然于胸了。
2.3 代码实现
PageHelper给我们提供了多种分页代码实现方式,这里壹哥就简单讲解其中常用的3种实现方式,至于其他方式,大家可以来找我当面学习哦。
//第一种方式,RowBounds方式的调用
List<Country> list = sqlSession.selectList("x.y.selectIf", null, new RowBounds(0, 10));
//第二种方式,Mapper接口方式的调用,推荐这种使用方式。
//注意,这句话一定要放在分页查询语句之前
PageHelper.startPage(1, 10);
List<Country> list = countryMapper.selectIf(1);
//第三种方式,Mapper接口方式的调用,推荐这种使用方式。
//注意,这句话一定要放在分页查询语句之前
PageHelper.offsetPage(1, 10);
List<Country> list = countryMapper.selectIf(1);
经过以上3步,我们就可以很轻松的实现Mybatis环境下的分页查询了,而且效率也是杠杠的。
三. PageHelper分页原理
PageHelper的使用其实是非常简单的,但我们不仅要会用,还要知道为什么要这么用,所以接下来壹哥就分析一下PageHelper的分页原理。
1. Mybatis对插件的处理过程
要想彻底搞明白PageHelper的分页原理,我们得先来看看Mybatis对插件是怎么处理的,毕竟PageHelper就是一个插件,这个插件是要被安装插入到Mybatis体系里才会执行的。就好比PageHelper是一个移动硬盘,而Mybatis则提供了一个USB接口供其插入,但我们需要了解一下这个USB接口是怎么处理这些外部的硬件的。
在Mybatis中是通过拦截器来进行拦截处理的,内部真正执行Sql语句的有四个插件对象,分别如下:
Mybatis对以上4个对象的执行关系如下图所示,大家可以参考:
2. 自定义插件
MyBatis支持的所有插件,都是基于拦截以上四大对象来实现的。如果一个插件想要接入到Mybatis中,首先就要实现一个Interceptor接口,然后重写其中的三个方法(这里必须要实现Interceptor接口,否则无法被拦截)。比如下面壹哥自定义了一个Interceptor插件,代码如下:
@Intercepts({@Signature(type = Executor.class,method = "query",args = {MappedStatement.class,Object.class, RowBounds.class, ResultHandler.class})})
public class MyPlugin implements Interceptor {
/**
* 这个方法会直接覆盖原有方法
*/
@Override
public Object intercept(Invocation invocation) throws Throwable {
//调用原方法
System.out.println("成功拦截了Executor的query方法,在这里我可以做点什么");
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
//把被拦截对象生成一个代理对象
return Plugin.wrap(target,this);
}
@Override
public void setProperties(Properties properties) {
//可以自定义一些属性
System.out.println("自定义属性:name->" + properties.getProperty("name"));
}
}
@Intercepts注解用来声明当前类是一个拦截器,后面的@Signature用于标识需要拦截的方法签名,通过以下三个参数来确定:
然后我们在mybatis-config中对上述插件进行配置即可,如下:
<plugins>
<plugin interceptor="com.yyg.mybatis.plugin.MyPlugin">
<property name="name" value="一一哥"/>
</plugin>
</plugins>
如果在这里配置了property属性,我们就可以在setProperties中获取到。完成以上两步,我们就完成了一个插件的配置了。
3. PageInterceptor插件
PageHelper插件其实就是遵循了以上开发规则实现的一个第三方插件,它的运行也需要符合上述要求,这样Mybatis才能接受运行PageHelper。那么PageHelper中负责分页的插件是哪个呢?其实是PageInterceptor类!我们先来看看该拦截器的源码:
@SuppressWarnings({"rawtypes", "unchecked"})
@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 {
//缓存count查询的ms
protected Cache<String, MappedStatement> msCountMap = null;
private Dialect dialect;
//默认的分页处理器类
private String default_dialect_class = "com.github.pagehelper.PageHelper";
private Field additionalParametersField;
private String countSuffix = "_COUNT";
//拦截器的核心方法
@Override
public Object intercept(Invocation invocation) throws Throwable {
try {
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;
//由于逻辑关系,只会进入一次
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];
}
List resultList;
//调用方法判断是否需要进行分页,如果不需要,直接返回结果
if (!dialect.skip(ms, parameter, rowBounds)) {
//反射获取动态参数
String msId = ms.getId();
Configuration configuration = ms.getConfiguration();
Map<String, Object> additionalParameters = (Map<String, Object>) additionalParametersField.get(boundSql);
//判断是否需要进行 count 查询
if (dialect.beforeCount(ms, parameter, rowBounds)) {
String countMsId = msId + countSuffix;
Long count;
//先判断是否存在手写的 count 查询
MappedStatement countMs = getExistedMappedStatement(configuration, countMsId);
if(countMs != null){
count = 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 = executeAutoCount(executor, countMs, parameter, boundSql, rowBounds, resultHandler);
}
//处理查询总数
//返回 true 时继续分页查询,false 时直接返回
if (!dialect.afterCount(count, parameter, rowBounds)) {
//当查询总数为 0 时,直接返回空的结果
return dialect.afterPage(new ArrayList(), parameter, rowBounds);
}
}
//判断是否需要进行分页查询
if (dialect.beforePage(ms, parameter, rowBounds)) {
//生成分页的缓存 key
CacheKey pageKey = cacheKey;
//处理参数对象
parameter = dialect.processParameterObject(ms, parameter, boundSql, pageKey);
//调用方言获取分页 sql
String pageSql = dialect.getPageSql(ms, boundSql, parameter, rowBounds, pageKey);
BoundSql pageBoundSql = new BoundSql(configuration, pageSql, boundSql.getParameterMappings(), parameter);
//设置动态参数
for (String key : additionalParameters.keySet()) {
pageBoundSql.setAdditionalParameter(key, additionalParameters.get(key));
}
//执行分页查询
resultList = executor.query(ms, parameter, RowBounds.DEFAULT, resultHandler, pageKey, pageBoundSql);
} else {
//不执行分页的情况下,也不执行内存分页
resultList = executor.query(ms, parameter, RowBounds.DEFAULT, resultHandler, cacheKey, boundSql);
}
} else {
//rowBounds用参数值,不使用分页插件处理时,仍然支持默认的内存分页
resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
}
return dialect.afterPage(resultList, parameter, rowBounds);
} finally {
dialect.afterAll();
}
}
/**
* 执行手动设置的 count 查询,该查询支持的参数必须和被分页的方法相同
*/
private Long executeManualCount(Executor executor, MappedStatement countMs,
Object parameter, BoundSql boundSql,
ResultHandler resultHandler) throws IllegalAccessException, SQLException {
......
}
/**
* 执行自动生成的 count 查询
*/
private Long executeAutoCount(Executor executor, MappedStatement countMs,
Object parameter, BoundSql boundSql,
RowBounds rowBounds, ResultHandler resultHandler) throws IllegalAccessException, SQLException {
......
}
/**
* 尝试获取已经存在的在 MS,提供对手写count和page的支持
*/
private MappedStatement getExistedMappedStatement(Configuration configuration, String msId){
......
}
@Override
public Object plugin(Object target) {
......
}
@Override
public void setProperties(Properties properties) {
//缓存 count ms
msCountMap = CacheFactory.createCache(properties.getProperty("msCountCache"), "ms", properties);
String dialectClass = properties.getProperty("dialect");
if (StringUtil.isEmpty(dialectClass)) {
dialectClass = default_dialect_class;
}
try {
Class<?> aClass = Class.forName(dialectClass);
dialect = (Dialect) aClass.newInstance();
} catch (Exception e) {
throw new PageException(e);
}
dialect.setProperties(properties);
String countSuffix = properties.getProperty("countSuffix");
if (StringUtil.isNotEmpty(countSuffix)) {
this.countSuffix = countSuffix;
}
try {
//反射获取 BoundSql 中的 additionalParameters 属性
additionalParametersField = BoundSql.class.getDeclaredField("additionalParameters");
additionalParametersField.setAccessible(true);
} catch (NoSuchFieldException e) {
throw new PageException(e);
}
}
}
在上面源码的intercept()方法中,有如下一段代码:
其中的dialect.getPageSql()是用于生成分页查询sql的方法,该方法是在AbstractHelperDialect类中实现的。所以接下来我们看看分页的sql语句到底是怎么生成的。
4. getPageSql方法
这就是AbstractHelperDialect类中的getPageSql方法源码,用于生成分页查询sql语句。
@Override
public String getPageSql(MappedStatement ms, BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey pageKey) {
String sql = boundSql.getSql();
Page page = getLocalPage();
//支持 order by
String orderBy = page.getOrderBy();
if (StringUtil.isNotEmpty(orderBy)) {
pageKey.update(orderBy);
sql = OrderByParser.converToOrderBySql(sql, orderBy);
}
if (page.isOrderByOnly()) {
return sql;
}
return getPageSql(sql, page, pageKey);
}
从上面这段源码中,我们可以看出,会在分页sql语句中考虑添加order by操作。该方法的最后是返回getPageSql()方法,但这个方法确实一个抽象方法,如下图所示:
5. getPageSql抽象方法的实现类
既然是抽象方法,那就会有人实现该方法,在PageHelper插件中,那么到底有哪些地方实现了该抽象方法呢?我们看下图:
我们可以看出,在壹哥的这个版本中,有7个具体的实现类,我这里使用的是MySQL数据库,所以壹哥就找到MySqlDialect这个方言类来看看内部具体是怎么实现的。
6. 拼接分页语句
@Override
public String getPageSql(String sql, Page page, CacheKey pageKey) {
StringBuilder sqlBuilder = new StringBuilder(sql.length() + 14);
sqlBuilder.append(sql);
if (page.getStartRow() == 0) {
sqlBuilder.append(" LIMIT ? ");
} else {
sqlBuilder.append(" LIMIT ?, ? ");
}
pageKey.update(page.getPageSize());
return sqlBuilder.toString();
}
从上面的源码中可以看出,PageHelper就是在这里针对MySQL数据库进行分页语句拼接的,而且这里也没有使用什么高深的代码,就是用很普通的StringBuilder拼接的sql语句和limit关键字。所以由此看见,我们只需要在前端向后端传递过来正常的分页参数pageNum和pageSize的值,PageHelper分页插件就会自动把这两个参数,结合limit关键字自动拼接成分页语句。关于MySQL的分页实现,壹哥在上面的章节中刚讲完limit的用法和原理,相信你还没有忘记,这样就是PageHelper可以自动实现分页功能的底层原理!
7. PageHelper优化机制
虽然现在我们已经明白了PageHelper是怎么自动实现分页功能的了,但还有一些其他的地方需要我们注意,比如PageHelper对分页的优化机制。我们知道,PageHelper要求我们在分页之前,必须先调用PageHelper.startPage()
这样的代码,这是为什么呢?我们先来看看源码。
在startPage()方法的源码内部,我们可以看到getLocalPage()和setLocalPage()方法,这是干嘛的呢?我们看看源码:
我们可以看到在这里使用了本地线程容器ThreadLocal,用于保存分页数据。关于ThreadLocal,很多地方叫做线程本地变量,也有些地方叫做线程本地存储,它可以为变量在每个线程中都创建一个副本,每个线程都可以访问自己内部的副本变量,这里壹哥就不再专门讲解ThreadLocal了。
PageHelper首先将前端传递的参数保存到page这个对象中,接着将page的副本存放入ThreadLoacl中,这样可以保证分页的时候,参数互不影响,接着利用了mybatis提供的拦截器,取得ThreadLocal的值,重新拼装分页SQL,完成分页。
8. 分页流程小节(重点)
总之,PageHelper执行分页的流程大致如下:
以上就是壹哥给大家总结的PageHelper执行原理和流程,你明白了吗?
四. 结语
至此,壹哥就带各位把MySQL的分页功能复习了一遍,现在你对分页还有什么疑惑吗?可以在评论区给壹哥留言,或者私信我也可以的哦。下一篇,壹哥会讲解数据库中主键的生成及其注意事项,敬请关注。