乐趣区

关于java:多年以后PageHelper-又深深给我上了一课

多年不必 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>

失常来说, 是不会有问题的, 那么起因咱们前面讲, 接着看下一个问题。

举荐一个开源收费的 Spring Boot 实战我的项目:

https://github.com/javastacks/spring-boot-best-practice

查问全副分类的下拉列表只能查出 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 的实现形式,对于酷爱源码浏览的同学来说还是有肯定的晋升的。

原文:juejin.cn/post/7125356642366914596

更多文章举荐:

1.Spring Boot 3.x 教程,太全了!

2.2,000+ 道 Java 面试题及答案整顿(2024 最新版)

3. 收费获取 IDEA 激活码的 7 种形式(2024 最新版)

感觉不错,别忘了顺手点赞 + 转发哦!

退出移动版