当前位置:网站首页>多年以后「PageHelper」又深深的给我上了一课
多年以后「PageHelper」又深深的给我上了一课
2022-07-30 17:13:00 【YYniannian】
自从七月份入职新公司以来,一直没有找到写文章的状态,一来是工作确实较忙,另外还在适应环境,经过20天的尝试,总算找到了舒服的写作姿势,文章继续肝起来.
下面进入正题,多年不用
PageHelper
了,最近新入职的公司,采用了此工具集成的框架,作为一个独立紧急项目开发的基础。项目开发起来,还是手到擒来的,但是没想到,最终测试的时候,深深的给我上了一课
。
我的项目发生了哪些奇葩现象?
一切的问题都要从我接受的项目开始说起, 在开发这个项目的过程中,发生了各种奇葩的事情, 下面我简单说给你们听听:
账号重复注册?
你肯定在想这是什么意思? 就是字面意思,已经注册的账号,可以再次注册成功!!!
else if (UserConstants.NOT_UNIQUE.equals(userService.checkUserNameUnique(username))
||"匿名用户".equals(username)){
// 注册用户已存在
msg = "注册用户'" + username + "'失败";
}
复制代码
如上所示: checkUserNameUnique(username)
用来验证数据库是否存在用户名:
<select id="checkUserNameUnique" parameterType="String" resultType="int">
select count(1) from sys_user where user_name = #{userName} limit 1
</select>
复制代码
正常来说,是不会有问题的,那么原因我们后面讲,接着看下一个问题。
查询全部分类的下拉列表只能查出5条数据?
如上所示,明明有十多个结果,怎么只能返回5个?我也没有添加分页参数啊?
相信用过PageHelper的同学已经知道问题出在哪里了。
修改用户密码报错?
当管理员在后台界面重置用户的密码的时候,居然报错了??
报错信息清晰的告诉了我:sql语句异常,update语句不认识 “Limit 5”
到此为止,报错信息已经告诉了我,我的sql被拼接了该死的“limit”分页参数
。
小结
上面提到的几个只是冰山一角,在我使用的过程中,还有各种涉及到sql的地方,会因为这个分页参数导致的问题,我可以分为两种:
1)直接导致报错的:明确报错原因的
比如insert、update语句等,不支持limit,会直接报错。
2)导致业务逻辑错误,但是代码没有错误提示
- 如我上面提到的用户可以重复注册,却没有报错,实际在代码当中是有报错的,但是当前方法对异常进行了throw,最终被全局异常捕获了。
- 不分页的sql被拼接了limit,导致没有报错,但是数据返回量错误。
注意:异常不是每次出现,是有一定纪律的,但是触发几率较高,原因在后面会逐渐脱出。
PageHelper是怎么做到上面的问题的?
PageHelper使用
我这里只讲解项目基于的框架的使用方式。
代码如下:
@GetMapping("/cms/cmsEssayList")
public TableDataInfo cmsEssayList(CmsBlog cmsBlog) {
//状态为发布
cmsBlog.setStatus("1");
startPage();
List<CmsBlog> list = cmsBlogService.selectCmsBlogList(cmsBlog);
return getDataTable(list);
}
复制代码
使用起来还是很简单的,通过 startPage()
指定分页参数,通过getDataTable(list)
对结果数据封装成分页的格式。
有些同学会问,这也没没传分页参数啊,并且实体类当中也没有,这就是比较有意思的点,下一小结就来聊聊源码。
startPage()干啥了?
protected void startPage(){
// 通过request去获取前端传递的分页参数,不需控制器要显示接收
PageDomain pageDomain = TableSupport.buildPageRequest();
Integer pageNum = pageDomain.getPageNum();
Integer pageSize = pageDomain.getPageSize();
if (StringUtils.isNotNull(pageNum) && StringUtils.isNotNull(pageSize))
{
String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
Boolean reasonable = pageDomain.getReasonable();
// 真正使用pageHelper进行分页的位置
PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable);
}
}
复制代码
PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable)
的参数分别是:
- pageNum:页数
- pageSize:每页数据量
- orderBy:排序
- reasonable:分页合理化,对于不合理的分页参数自动处理,比如传递pageNum是小于0,会默认设置为1.
继续跟踪,连续点击startpage构造方法到达如下位置:
/**
* 开始分页
*
* @param pageNum 页码
* @param pageSize 每页显示数量
* @param count 是否进行count查询
* @param reasonable 分页合理化,null时用默认配置
* @param pageSizeZero true且pageSize=0时返回全部结果,false时分页,null时用默认配置
*/
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
Page<E> page = new Page<E>(pageNum, pageSize, count);
page.setReasonable(reasonable);
page.setPageSizeZero(pageSizeZero);
// 1、获取本地分页
Page<E> oldPage = getLocalPage();
if (oldPage != null && oldPage.isOrderByOnly()) {
page.setOrderBy(oldPage.getOrderBy());
}
// 2、设置本地分页
setLocalPage(page);
return page;
}
复制代码
到达终点位置了,分别是:getLocalPage()
和setLocalPage(page)
,分别来看下:
getLocalPage()
进入方法:
/**
* 获取 Page 参数
*
* @return
*/
public static <T> Page<T> getLocalPage() {
return LOCAL_PAGE.get();
}
复制代码
看看常量LOCAL_PAGE
是个什么路数?
protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();
复制代码
好家伙,是ThreadLocal
,学过java基础的都知道吧,独属于每个线程的本地缓存对象。
当一个请求来的时候,会获取持有当前请求的线程的ThreadLocal,调用LOCAL_PAGE.get()
,查看当前线程是否有未执行的分页配置。
setLocalPage(page)
此方法显而易见,设置线程的分页配置:
protected static void setLocalPage(Page page) {
LOCAL_PAGE.set(page);
}
复制代码
小结
经过前面的分析,我们发现,问题似乎就是这个ThreadLocal导致的。
是否在使用完之后没有进行清理?导致下一次此线程再次处理请求时,还在使用之前的配置?
我们带着疑问,看看mybatis时如何使用pageHelper的。
mybatis使用pageHelper分析
我们需要关注的就是mybatis在何时使用的这个ThreadLocal,也就是何时将分页餐数获取到的。
前面提到过,通过PageHelper的startPage()方法进行page缓存的设置,当程序执行sql接口mapper的方法时,就会被拦截器PageInterceptor
拦截到。
PageHelper其实就是mybatis的分页插件,其实现原理就是通过拦截器的方式,pageHelper通PageInterceptor
实现分页效果,我们只关注intercept
方法:
@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];
}
checkDialectExists();
//对 boundSql 的拦截处理
if (dialect instanceof BoundSqlInterceptor.Chain) {
boundSql = ((BoundSqlInterceptor.Chain) dialect).doBoundSql(BoundSqlInterceptor.Type.ORIGINAL, boundSql, cacheKey);
}
List resultList;
//调用方法判断是否需要进行分页,如果不需要,直接返回结果
if (!dialect.skip(ms, parameter, rowBounds)) {
//判断是否需要进行 count 查询
if (dialect.beforeCount(ms, parameter, rowBounds)) {
//查询总数
Long count = count(executor, ms, parameter, rowBounds, null, boundSql);
//处理查询总数,返回 true 时继续分页查询,false 时直接返回
if (!dialect.afterCount(count, parameter, rowBounds)) {
//当查询总数为 0 时,直接返回空的结果
return dialect.afterPage(new ArrayList(), parameter, rowBounds);
}
}
resultList = ExecutorUtil.pageQuery(dialect, executor,
ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
} else {
//rowBounds用参数值,不使用分页插件处理时,仍然支持默认的内存分页
resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
}
return dialect.afterPage(resultList, parameter, rowBounds);
} finally {
if(dialect != null){
dialect.afterAll();
}
}
}
复制代码
如上所示是intecept的全部代码,我们下面只关注几个终点位置:
设置分页:dialect.skip(ms, parameter, rowBounds)
此处的skip方法
进行设置分页参数,内部调用方法:
Page page = pageParams.getPage(parameterObject, rowBounds);
复制代码
继续跟踪getPage()
,发现此方法的第一行就获取了ThreadLocal的值:
Page page = PageHelper.getLocalPage();
复制代码
统计数量:dialect.beforeCount(ms, parameter, rowBounds)
我们都知道,分页需要获取记录总数,所以,这个拦截器会在分页前先进行count操作。
如果count为0,则直接返回,不进行分页:
//处理查询总数,返回 true 时继续分页查询,false 时直接返回
if (!dialect.afterCount(count, parameter, rowBounds)) {
//当查询总数为 0 时,直接返回空的结果
return dialect.afterPage(new ArrayList(), parameter, rowBounds);
}
复制代码
afterPage其实是对分页结果的封装方法,即使不分页,也会执行,只不过返回空列表。
分页:ExecutorUtil.pageQuery
在处理完count方法后,就是真正的进行分页了:
resultList = ExecutorUtil.pageQuery(dialect, executor,
ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
复制代码
此方法在执行分页之前,会判断是否执行分页,依据就是前面我们通过ThreadLocal的获取的page。
当然,不分页的查询,以及新增和更新不会走到这个方法当中。
非分页:executor.query
而是会走到下面的这个分支:
resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
复制代码
我们可以思考一下,如果ThreadLoad在使用后没有被清除,当执行非分页的方法时,那么就会将Limit拼接到sql后面。
为什么不分也得也会拼接?我们回头看下前面提到的dialect.skip(ms, parameter, rowBounds)
:
如上所示,只要page被获取到了,那么这个sql,就会走前面提到的ExecutorUtil.pageQuery
分页逻辑,最终导致出现不可预料的情况。
其实PageHelper对于分页后的ThreaLocal是有清除处理的。
清除TheadLocal
在intercept方法的最后,会在sql方法执行完成后,清理page缓存:
finally {
if(dialect != null){
dialect.afterAll();
}
}
复制代码
看看这个afterAll方法():
@Override
public void afterAll() {
//这个方法即使不分页也会被执行,所以要判断 null
AbstractHelperDialect delegate = autoDialect.getDelegate();
if (delegate != null) {
delegate.afterAll();
autoDialect.clearDelegate();
}
clearPage();
}
复制代码
只关注 clearPage()
:
/**
* 移除本地变量
*/
public static void clearPage() {
LOCAL_PAGE.remove();
}
复制代码
小结
到此为止,关于PageHelper的使用方式就讲解完了。
整体看下来,似乎不会存在什么问题,但是我们可以考虑集中极端情况:
如果使用了startPage(),但是没有执行对应的sql,那么就表明,当前线程ThreadLocal被设置了分页参数,可是没有被使用,当下一个使用此线程的请求来时,就会出现问题。
如果程序在执行sql前,发生异常了,就没办法执行finally当中的clearPage()方法,也会造成线程的ThreadLocal被污染。
所以,官方给我们的建议,在使用PageHelper进行分页时,执行sql的代码要紧跟startPage()方法。
除此之外,我们可以手动调用clearPage()方法,在存在问题的方法之前。
需要注意:不要分页的方法前手动调用clearPage,将会导致你的分页出现问题。
还有人问为什么不是每次请求都出错?
这个其实取决于我们启动服务所使用的容器,比如tomcat,在其内部处理请求是通过线程池的方式。甚至现在的很多容器是基于netty的,都是通过线程池,复用线程来增加服务的并发量。
假设线程1持有没有被清除的page参数,不断调用同一个方法,后面两个请求使用的是线程2和线程3没有问题,再一个请求轮到线程1了,此时就会出现问题了。
总结
关于PageHelper的介绍就这么多,真的是折磨我好几天,要不是项目紧急,来不及替换,我一定不会使用这个组件。
莫名其妙的就会有个方法出现问题,一通排查,发现都是这个PageHelper导致的。虽然我已经全局搜索使用的地方,保证startPage()后紧跟sql命令,但是仍然有嫌犯潜逃,只能在有问题的方法使用clearPage()来打补丁。
虽然PageHelper给我带来一些困扰,耗费了一定的时间,但是定位问题的过程中,也学习了mybatis和pagehepler的实现方式,对于热爱源码阅读的同学来说还是有一定的提升的。
边栏推荐
猜你喜欢
592. Fraction Addition and Subtraction
牛客网刷题——运算符问题
有效的括号字符串[贪心练习]
[HarekazeCTF2019] Avatar Uploader 1
ERROR 2003 (HY000) Can‘t connect to MySQL server on ‘localhost3306‘ (10061)解决办法
Error EPERM operation not permitted, mkdir ‘Dsoftwarenodejsnode_cache_cacach两种解决办法
(18)[系统调用]追踪系统调用(服务表)
代码越写越乱?那是因为你没用责任链
How does the new retail saas applet explore the way to break the digital store?
Express框架连接MySQL及ORM框架
随机推荐
Analysis and Simulation of Short Circuit Fault in Power System Based on MATLAB
torch.optim.Adam() 函数用法
JMeter笔记3 | JMeter安装和环境说明
云厂商做生态需要“真连接、真赋能”,用“技术+真金实银”发展伙伴
Daily practice------Generate 13-digit bar, Ean-13 code rule: The thirteenth digit is the check code obtained by the calculation of the first twelve digits.
实现web实时消息推送的7种方案
将 APACHE 日志解析到 SQL 数据库中
Win11如何把d盘空间分给c盘?Win11d盘分盘出来给c盘的方法
【AAAI2020】阿里DMR:融合Matching思想的深度排序模型
Error EPERM operation not permitted, mkdir ‘Dsoftwarenodejsnode_cache_cacach两种解决办法
【云商店公告】关于7月30日帮助中心更新通知
每日一题:两数之和
向量检索基础方法总结
中文字符集编码Unicode ,gb2312 , cp936 ,GBK,GB18030
全球架构师峰会
592. Fraction Addition and Subtraction
Tensorflow模型量化(Quantization)原理及其实现方法
C# 跨程序传图(共享内存块传图)跨exe传图
C陷阱与缺陷 第7章 可移植性缺陷 7.1 应对C语言标准变更
Shell implementation based on stm32