MyBatis分页插件PageHelper封装以及遇到的bug

PageHelper链接:https://github.com/pagehelper/Mybatis-PageHelper
项目中使用到了一个注解,叫做PageAble,这是一个对PageHelper的封装注解。这个注解有一个非常显著的问题就是,不能在这个方法里面执行两次SQL查询(原因将在后续中慢慢分析)。使用方法如下:

@PageAble
public Object method(int page, int size) {
	。。。
}

注解的内容比较简单,就是定义了两个参数,分别为这两个参数设置了默认的名字、以及默认值。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PageAble {
	 String pageSizeName() default "size";
	 String pageNumName() default "page";
	 int pageSize() default 20;
	 int pageNum() default 1;
}

然后得到的返回值是一个叫做ResultPageView的类,是对分页情况的一个封装,其中的内容如下:

public class ResultPageView<T> {
	 private Long total = 0l;
	 private Integer current = 1;
	 private Integer pageCount = 0;
	 private List<T> list;
	 // 省略一些构造方法、getter/setter方法
}

所以,最重要的问题当然是@PageAble注解的方法是怎样执行的。显然这里是利用了Spring AOP,在这个方法的前后,加上了自定义的处理方法,如下:

private static final String PAGE_ABLE = "@annotation(com.xxxxxo0o0.baseservice.annotation.PageAble)";
@Around(PAGE_ABLE)
public Object doAroundAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
	logger.info("execute method : " + proceedingJoinPoint.getSignature().getName());
	try {
		// 准备开始分页
		prepare(proceedingJoinPoint);
		// 执行被注解的方法
		Object obj = proceedingJoinPoint.proceed();
		// 装饰被注解方法返回的值
		Object result = after(obj);
		return result;
	} catch (Throwable throwable) {
		logger.error("aspect execute error : ", throwable);
		throw throwable;
	} finally {
		// 先忽略这个finally里面的内容
		//PageHelper.clearPage();
	}
}

在被注解方法执行前的准备活动中,执行了什么操作?代码如下:

private void prepare(ProceedingJoinPoint point) throws Exception {
	Signature signature = point.getSignature();
	MethodSignature methodSignature = (MethodSignature) signature;
	Method targetMethod = methodSignature.getMethod();
	PageAble pageAble = targetMethod.getAnnotation(PageAble.class);
	// 获取参数名称
	String numName = pageAble.pageNumName();
	String sizeName = pageAble.pageSizeName();
	// 先获取page和size的默认值
	int pageNo = pageAble.pageNum();
	int pageSize = pageAble.pageSize();
	// 获取参数值列表
	Object[] paramValues = point.getArgs();
	// 获取参数名列表
	String[] paramNames = methodSignature.getParameterNames();
	int length = paramNames.length;
	// 对参数列表进行遍历
	for (int i = 0; i < length; i++) {
		// 如果参数名 == 注解中写入的页数参数名
		if (paramNames[i].equals(numName)) {
			// 从参数值列表中取出值,赋值给页数
			pageNo = (Integer) paramValues[i];
		// 如果参数名 == 注解中写入的每页数量的参数名
		} else if (paramNames[i].equals(sizeName)) {
			// // 从参数值列表中取出值,赋值为每页尺寸
			pageSize = (Integer) paramValues[i];
		}
	}
	// 调用PageHelper的分页
	PageHelper.startPage(pageNo, pageSize);
}

先忽略其中的细节,看看被注解方法后面执行的方法做了什么事情,代码如下:

private Object after(Object obj) {
    assert obj instanceof List;
    PageInfo<?> pageInfo = new PageInfo((List<?>) obj);
    // 从某个地方获取的分页的信息。(其实是ThreadLocal,先忽略)
    Page<Object> localPage = PageHelper.getLocalPage();
    // 获取分页参数
    long total = localPage.getTotal();
    int pageNum = localPage.getPageNum();
    int pages = localPage.getPages();
    List<?> list = (List<?>) obj;
    try {
	    List<Map> mapList = new ArrayList<>();
	    for (Object o : list) {
	    	// 将一个对象按照原来的字段名转成map
			HashMap<String, Object> map = MapUtil.convertObj2Map(o);
			if (o instanceof BaseModel) {
				BaseModel baseModel = (BaseModel) o;
				map.put("id", baseModel.getId());
			}
			ReflectionUtils
			    .doWithFields(o.getClass(), new InnerFieldCallback(map, o), new InnerFieldFilter());
			mapList.add(map);
	    }
	    list = mapList;
    } catch (Exception e) {
		logger.error("convert obj to map occurred error ", e);
    }
    pageInfo = new PageInfo((list));
    ResultPageView<?> resultPageView;
    resultPageView = new ResultPageView<>(total, pageNum, pages, pageInfo.getList());
    // 清除分页信息
    PageHelper.clearPage();
    return resultPageView;
  }

PageHelper.start()做了什么

一路往父类翻到start()的实现代码如下:

/**
 * 开始分页
 *
 * @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);
    //当已经执行过orderBy的时候
    Page<E> oldPage = getLocalPage();
    if (oldPage != null && oldPage.isOrderByOnly()) {
        page.setOrderBy(oldPage.getOrderBy());
    }
    setLocalPage(page);
    return page;
}

setLocalPage()与之前的after()中的getLocalPage()是一对get/set方法,他们的目的是从当前线程中获取/设置分页信息。其实现如下(关于ThreadLocal的具体实现,可以去参考其他博客):
MyBatis分页插件PageHelper封装以及遇到的bug
既然startPage()只是在线程中塞了一个关于分页的信息,那么真正读取这个分页信息的动作一定是在处理SQL语句的地方,也就是Interceptor。PageHelper的官方使用文档链接:
https://github.com/pagehelper/Mybatis-PageHelper/blob/master/wikis/zh/HowToUse.md
其中也有一块,是对不安全分页的说明:

PageHelper 方法使用了静态的 ThreadLocal 参数,分页参数和线程是绑定的。只要你可以保证在 PageHelper 方法调用后紧跟 MyBatis 查询方法,这就是安全的。因为 PageHelperfinally 代码段中自动清除了 ThreadLocal 存储的对象。如果代码在进入 Executor 前发生异常,就会导致线程不可用,这属于人为的 Bug(例如接口方法和 XML 中的不匹配,导致找不到 MappedStatement 时),这种情况由于线程不可用,也不会导致 ThreadLocal 参数被错误的使用。但是如果你写出下面这样的代码,就是不安全的用法:

PageHelper.startPage(1, 10);
List<Country> list;
if(param1 != null){
    list = countryMapper.selectIf(param1);
} else {
    list = new ArrayList<Country>();
}

这种情况下由于 param1 存在 null 的情况,就会导致 PageHelper 生产了一个分页参数,但是没有被消费,这个参数就会一直保留在这个线程上。当这个线程再次被使用时,就可能导致不该分页的方法去消费这个分页参数,这就产生了莫名其妙的分页。

因此打开项目中的MyPageInterceptor,它的功能就是充当Mybatis的拦截器,还有一部分自定义的功能,比如说输出sql执行时间、打印sql语句。这个类与PageHelper的拦截器关键的代码基本一致,可以说是copy吧,其中关键的一个地方是intercept()方法中,有一个进行判断,是否需要分页的语句。
MyBatis分页插件PageHelper封装以及遇到的bug
在查询完毕后,finally方法回执行一次清除动作:
MyBatis分页插件PageHelper封装以及遇到的bug
这个dialect是一个本地的类,继承自PageHelper这个类,覆盖了其中的afterAll()方法,如下:

public class MyPageHelper extends PageHelper {

	@Override
	public void afterAll() {
		// 获取分页信息
		Page<Object> localPage = getLocalPage();
		// 调用父类方法,即清除分页信息
		super.afterAll();
		// 又将分页信息塞回线程中。
		// 为什么要这样做?为了让在切面中,加入分页的详细信息。
		setLocalPage(localPage);
	}
}

这里的代码执行完成后,不论查询的结果是成功还是失败,分页信息都会存在当前线程中(如果直接调用父类的方法,不自定义这个方法,就能保证执行完一次查询,分页信息不会保存在当前线程中)。问题就出在这里。因为interceptor处理过后,当前线程中还存在分页的信息,并且这个分页的信息需要以来切面的处理方法来完成。

@Override
public boolean skip(MappedStatement ms, Object parameterObject, RowBounds rowBounds) {
    if(ms.getId().endsWith(MSUtils.COUNT)){
        throw new RuntimeException("在系统中发现了多个分页插件,请检查系统配置!");
    }
    Page page = pageParams.getPage(parameterObject, rowBounds);
    if (page == null) {
        return true;
    } else {
        //设置默认的 count 列
        if(StringUtil.isEmpty(page.getCountColumn())){
            page.setCountColumn(pageParams.getCountColumn());
        }
        autoDialect.initDelegateDialect(ms);
        return false;
    }
}

其中获取Page的代码是这样的,说到底还是从当前线程中去取:
MyBatis分页插件PageHelper封装以及遇到的bug

bug描述与分析

执行一个分页查询,让查询故意报错,多执行几次,然后再进行一次普通查询,得到ClassCastException异常。
因此,问题已经出来了。
MyBatis分页插件PageHelper封装以及遇到的bug

解决

因此加上finally后,无论是否报错,那么分页信息都将会被在线程中清除。问题就解决了,所以把涂掉的finally加上清除分页信息的处理,即可解决此问题。

后记

批量发送请求的脚本,用来引发bug用:
main.py

import requests
import json

host1 = 'localhost:9999'
host2 = 'xxxxxxxx'
host = host1
MAX = 100

def vehicle():
    parse_response(send_get_req('http://'+host+'/nemt/driver/get'))
    parse_response(send_get_req('http://'+host+'/nemt/vehicles?page=1&size=20'))
    parse_response(send_get_req('http://'+host+'/nemt/vehicleType/all'))
    pass


def app_version():
    parse_response(send_get_req('http://'+host+'/nemt/driver-apps'))


def parse_response(resp):
    s = json.loads(resp[0].content)
    if s['code'] == 500:
        # print()
        print("x " + s['message'] + ' --> ' + resp[1])
    else:
        print("o")
        pass


def send_get_req(url):
    # print('url --> '+url)
    return requests.get(url), url


def main():
    i = 0
    while i <= MAX:
        app_version()
        vehicle()
        i = i + 1


if __name__ == '__main__':
    main()

*****.sh

#!/usr/bin/env bash
#if [$1 -eq ""]; then
#    max=1
#else
#    max=$1
#fi
for i in $(seq 1 $1):
do
curl 'http://localhost:9999/nemt/orders?page=1&size=20' -H 'Accept-Encoding: gzip, deflate' -H 'Accept-Language: zh,en;q=0.9,ja;q=0.8,zh-TW;q=0.7,fr;q=0.6,zh-CN;q=0.5' -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36' -H 'Accept: application/json, text/plain, */*' -H 'userId: 453' -H 'Connection: keep-alive' -H 'token: 48143d9154e7c42face53855826f5ffa' --compressed
echo ''
done