Mybatis 的物理分页是应用中的一个难点,特别是配合检索和排序功能叠加时更是如此。
【背景】
项目框架是 SpringMVC+Mybatis, 需求是想采用自定义的分页标签,同时,要尽量少的影响业务程序开发的。
如果你已经使用了JS框架( 如:Ext,EasyUi等)自带的分页机能,是属于前端分页,不在本文讨论范围。
【关于问题】
大多数分页器会使用在查询页面,要考虑以下问题:
1)分页时是要随时带有最近一次查询条件
2)不能影响现有的sql,类似aop的效果
3)mybatis提供了通用的拦截接口,要选择适当的拦截方式和时点
4)尽量少的影响现有service等接口
【关于依赖库】
Google Guava 作为基础工具包
Commons JXPath 用于对象查询 (1/23日版改善后,不再需要)
Jackson 向前台传送Json格式数据转换用
【关于适用数据库】
现在只适用mysql
(如果需要用在其他数据库可参考 paginator的Dialect部分,改动都不大)
首先是Page类,比较简单,保存分页相关的所有信息,涉及到分页算法。虽然“其貌不扬”,但很重要。后面会看到这个page类对象会以“信使”的身份出现在全部与分页相关的地方。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 |
/** * 封装分页数据 */ import java.util.List; import java.util.Map; import org.codehaus.jackson.map.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.base.Joiner; import com.google.common.collect.Lists; import com.google.common.collect.Maps; public class Page { private static final Logger logger = LoggerFactory.getLogger(Page.class); private static ObjectMapper mapper = new ObjectMapper(); public static String DEFAULT_PAGESIZE = "10"; private int pageNo; //当前页码 private int pageSize; //每页行数 private int totalRecord; //总记录数 private int totalPage; //总页数 private Map<String, String> params; //查询条件 private Map<String, List<String>> paramLists; //数组查询条件 private String searchUrl; //Url地址 private String pageNoDisp; //可以显示的页号(分隔符"|",总页数变更时更新) private Page() { pageNo = 1; pageSize = Integer.valueOf(DEFAULT_PAGESIZE); totalRecord = 0; totalPage = 0; params = Maps.newHashMap(); paramLists = Maps.newHashMap(); searchUrl = ""; pageNoDisp = ""; } public static Page newBuilder(int pageNo, int pageSize, String url){ Page page = new Page(); page.setPageNo(pageNo); page.setPageSize(pageSize); page.setSearchUrl(url); return page; } /** * 查询条件转JSON */ public String getParaJson(){ Map<String, Object> map = Maps.newHashMap(); for (String key : params.keySet()){ if ( params.get(key) != null ){ map.put(key, params.get(key)); } } String json=""; try { json = mapper.writeValueAsString(map); } catch (Exception e) { logger.error("转换JSON失败", params, e); } return json; } /** * 数组查询条件转JSON */ public String getParaListJson(){ Map<String, Object> map = Maps.newHashMap(); for (String key : paramLists.keySet()){ List<String> lists = paramLists.get(key); if ( lists != null && lists.size()>0 ){ map.put(key, lists); } } String json=""; try { json = mapper.writeValueAsString(map); } catch (Exception e) { logger.error("转换JSON失败", params, e); } return json; } /** * 总件数变化时,更新总页数并计算显示样式 */ private void refreshPage(){ //总页数计算 totalPage = totalRecord%pageSize==0 ? totalRecord/pageSize : (totalRecord/pageSize + 1); //防止超出最末页(浏览途中数据被删除的情况) if ( pageNo > totalPage && totalPage!=0){ pageNo = totalPage; } pageNoDisp = computeDisplayStyleAndPage(); } /** * 计算页号显示样式 * 这里实现以下的分页样式("[]"代表当前页号),可根据项目需求调整 * [1],2,3,4,5,6,7,8..12,13 * 1,2..5,6,[7],8,9..12,13 * 1,2..6,7,8,9,10,11,12,[13] */ private String computeDisplayStyleAndPage(){ List<Integer> pageDisplays = Lists.newArrayList(); if ( totalPage <= 11 ){ for (int i=1; i<=totalPage; i++){ pageDisplays.add(i); } }else if ( pageNo < 7 ){ for (int i=1; i<=8; i++){ pageDisplays.add(i); } pageDisplays.add(0);// 0 表示 省略部分(下同) pageDisplays.add(totalPage-1); pageDisplays.add(totalPage); }else if ( pageNo> totalPage-6 ){ pageDisplays.add(1); pageDisplays.add(2); pageDisplays.add(0); for (int i=totalPage-7; i<=totalPage; i++){ pageDisplays.add(i); } }else{ pageDisplays.add(1); pageDisplays.add(2); pageDisplays.add(0); for (int i=pageNo-2; i<=pageNo+2; i++){ pageDisplays.add(i); } pageDisplays.add(0); pageDisplays.add(totalPage-1); pageDisplays.add(totalPage); } return Joiner.on("|").join(pageDisplays.toArray()); } public int getPageNo() { return pageNo; } public void setPageNo(int pageNo) { this.pageNo = pageNo; } public int getPageSize() { return pageSize; } public void setPageSize(int pageSize) { this.pageSize = pageSize; } public int getTotalRecord() { return totalRecord; } public void setTotalRecord(int totalRecord) { this.totalRecord = totalRecord; refreshPage(); } public int getTotalPage() { return totalPage; } public void setTotalPage(int totalPage) { this.totalPage = totalPage; } public Map<String, String> getParams() { return params; } public void setParams(Map<String, String> params) { this.params = params; } public Map<String, List<String>> getParamLists() { return paramLists; } public void setParamLists(Map<String, List<String>> paramLists) { this.paramLists = paramLists; } public String getSearchUrl() { return searchUrl; } public void setSearchUrl(String searchUrl) { this.searchUrl = searchUrl; } public String getPageNoDisp() { return pageNoDisp; } public void setPageNoDisp(String pageNoDisp) { this.pageNoDisp = pageNoDisp; } } |
然后是最核心的拦截器了。涉及到了mybatis的核心功能,期间阅读大量mybatis源码几经修改重构,辛苦自不必说。核心思想是将拦截到的select语句,改装成select count(*)语句,执行之得到,总数据数。再根据page中的当前页号算出limit值,拼接到select语句后。为简化代码使用了Commons JXPath 包,做对象查询。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 |
/** * 分页用拦截器 */ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.util.Properties; import org.apache.commons.jxpath.JXPathContext; import org.apache.commons.jxpath.JXPathNotFoundException; import org.apache.ibatis.executor.Executor; import org.apache.ibatis.executor.parameter.DefaultParameterHandler; import org.apache.ibatis.mapping.BoundSql; import org.apache.ibatis.mapping.MappedStatement; import org.apache.ibatis.mapping.MappedStatement.Builder; import org.apache.ibatis.mapping.ParameterMapping; import org.apache.ibatis.mapping.SqlSource; import org.apache.ibatis.plugin.Interceptor; import org.apache.ibatis.plugin.Intercepts; import org.apache.ibatis.plugin.Invocation; import org.apache.ibatis.plugin.Plugin; import org.apache.ibatis.plugin.Signature; import org.apache.ibatis.session.ResultHandler; import org.apache.ibatis.session.RowBounds; @Intercepts({@Signature(type=Executor.class,method="query",args={ MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class })}) public class PageInterceptor implements Interceptor{ public Object intercept(Invocation invocation) throws Throwable { //当前环境 MappedStatement,BoundSql,及sql取得 MappedStatement mappedStatement=(MappedStatement)invocation.getArgs()[0]; Object parameter = invocation.getArgs()[1]; BoundSql boundSql = mappedStatement.getBoundSql(parameter); String originalSql = boundSql.getSql().trim(); Object parameterObject = boundSql.getParameterObject(); //Page对象获取,“信使”到达拦截器! Page page = searchPageWithXpath(boundSql.getParameterObject(),".","page","*/page"); if(page!=null ){ //Page对象存在的场合,开始分页处理 String countSql = getCountSql(originalSql); Connection connection=mappedStatement.getConfiguration().getEnvironment().getDataSource().getConnection() ; PreparedStatement countStmt = connection.prepareStatement(countSql); BoundSql countBS = copyFromBoundSql(mappedStatement, boundSql, countSql); DefaultParameterHandler parameterHandler = new DefaultParameterHandler(mappedStatement, parameterObject, countBS); parameterHandler.setParameters(countStmt); ResultSet rs = countStmt.executeQuery(); int totpage=0; if (rs.next()) { totpage = rs.getInt(1); } rs.close(); countStmt.close(); connection.close(); //分页计算 page.setTotalRecord(totpage); //对原始Sql追加limit int offset = (page.getPageNo() - 1) * page.getPageSize(); StringBuffer sb = new StringBuffer(); sb.append(originalSql).append(" limit ").append(offset).append(",").append(page.getPageSize()); BoundSql newBoundSql = copyFromBoundSql(mappedStatement, boundSql, sb.toString()); MappedStatement newMs = copyFromMappedStatement(mappedStatement,new BoundSqlSqlSource(newBoundSql)); invocation.getArgs()[0]= newMs; } return invocation.proceed(); } /** * 根据给定的xpath查询Page对象 */ private Page searchPageWithXpath(Object o,String... xpaths) { JXPathContext context = JXPathContext.newContext(o); Object result; for(String xpath : xpaths){ try { result = context.selectSingleNode(xpath); } catch (JXPathNotFoundException e) { continue; } if ( result instanceof Page ){ return (Page)result; } } return null; } /** * 复制MappedStatement对象 */ private MappedStatement copyFromMappedStatement(MappedStatement ms,SqlSource newSqlSource) { Builder builder = new Builder(ms.getConfiguration(),ms.getId(),newSqlSource,ms.getSqlCommandType()); builder.resource(ms.getResource()); builder.fetchSize(ms.getFetchSize()); builder.statementType(ms.getStatementType()); builder.keyGenerator(ms.getKeyGenerator()); builder.keyProperty(ms.getKeyProperty()); builder.timeout(ms.getTimeout()); builder.parameterMap(ms.getParameterMap()); builder.resultMaps(ms.getResultMaps()); builder.resultSetType(ms.getResultSetType()); builder.cache(ms.getCache()); builder.flushCacheRequired(ms.isFlushCacheRequired()); builder.useCache(ms.isUseCache()); return builder.build(); } /** * 复制BoundSql对象 */ private BoundSql copyFromBoundSql(MappedStatement ms, BoundSql boundSql, String sql) { BoundSql newBoundSql = new BoundSql(ms.getConfiguration(),sql, boundSql.getParameterMappings(), boundSql.getParameterObject()); for (ParameterMapping mapping : boundSql.getParameterMappings()) { String prop = mapping.getProperty(); if (boundSql.hasAdditionalParameter(prop)) { newBoundSql.setAdditionalParameter(prop, boundSql.getAdditionalParameter(prop)); } } return newBoundSql; } /** * 根据原Sql语句获取对应的查询总记录数的Sql语句 */ private String getCountSql(String sql) { return "SELECT COUNT(*) FROM (" + sql + ") aliasForPage"; } public class BoundSqlSqlSource implements SqlSource { BoundSql boundSql; public BoundSqlSqlSource(BoundSql boundSql) { this.boundSql = boundSql; } public BoundSql getBoundSql(Object parameterObject) { return boundSql; } } public Object plugin(Object arg0) { return Plugin.wrap(arg0, this); } public void setProperties(Properties arg0) { } } |
到展示层终于可以轻松些了,使用了文件标签来简化前台开发。采用临时表单提交,CSS使用了Bootstrap。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
<%@tag pageEncoding="UTF-8"%> <%@ attribute name="page" type="cn.com.intasect.ots.common.utils.Page" required="true"%> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> <% int current = page.getPageNo(); int begin = 1; int end = page.getTotalPage(); request.setAttribute("current", current); request.setAttribute("begin", begin); request.setAttribute("end", end); request.setAttribute("pList", page.getPageNoDisp()); %> <script type="text/javascript"> var paras = '<%=page.getParaJson()%>'; var paraJson = eval('(' + paras + ')'); //将提交参数转换为JSON var paraLists = '<%=page.getParaListJson()%>'; var paraListJson = eval('(' + paraLists + ')'); function pageClick( pNo ){ paraJson["pageNo"] = pNo; paraJson["pageSize"] = "<%=page.getPageSize()%>"; var jsPost = function(action, values, valueLists) { var id = Math.random(); document.write('<form id="post' + id + '" name="post'+ id +'" action="' + action + '" method="post">'); for (var key in values) { document.write('<input type="hidden" name="' + key + '" value="' + values[key] + '" />'); } for (var key2 in valueLists) { for (var index in valueLists[key2]) { document.write('<input type="hidden" name="' + key2 + '" value="' + valueLists[key2][index] + '" />'); } } document.write('</form>'); document.getElementById('post' + id).submit(); } //发送POST jsPost("<%=page.getSearchUrl()%>", paraJson, paraListJson); } </script> <div class="page-pull-right"> <% if (current!=1 && end!=0){%> <button class="btn btn-default btn-sm" onclick="pageClick(1)">首页</button> <button class="btn btn-default btn-sm" onclick="pageClick(${current-1})">前页</button> <%}else{%> <button class="btn btn-default btn-sm" >首页</button> <button class="btn btn-default btn-sm" >前页</button> <%} %> <c:forTokens items="${pList}" delims="|" var="pNo"> <c:choose> <c:when test="${pNo == 0}"> <label style="font-size: 10px; width: 20px; text-align: center;">•••</label> </c:when> <c:when test="${pNo != current}"> <button class="btn btn-default btn-sm" onclick="pageClick(${pNo})">${pNo}</button> </c:when> <c:otherwise> <button class="btn btn-primary btn-sm" style="font-weight:bold;">${pNo}</button> </c:otherwise> </c:choose> </c:forTokens> <% if (current<end && end!=0){%> <button class="btn btn-default btn-sm" onclick="pageClick(${current+1})">后页</button> <button class="btn btn-default btn-sm" onclick="pageClick(${end})">末页</button> <%}else{%> <button class="btn btn-default btn-sm">后页</button> <button class="btn btn-default btn-sm">末页</button> <%} %> </div> |
注意“信使”在这里使出了浑身解数,7个主要的get方法全部用上了。
1 2 3 4 5 6 7 |
page.getPageNo() //当前页号 page.getTotalPage() //总页数 page.getPageNoDisp() //可以显示的页号 page.getParaJson() //查询条件 page.getParaListJson() //数组查询条件 page.getPageSize() //每页行数 page.getSearchUrl() //Url地址(作为action名称) |
到这里三个核心模块完成了。然后是拦截器的注册。
【拦截器的注册】需要在mybatis-config.xml 中加入拦截器的配置
1 2 3 4 |
<plugins> <plugin interceptor="cn.com.dingding.common.utils.PageInterceptor"> </plugin> </plugins> |
【相关代码修改】
首先是后台代码的修改,Controller层由于涉及到查询条件,需要修改的内容较多。
1)入参需增加 pageNo,pageSize 两个参数
2)根据pageNo,pageSize 及你的相对url构造page对象。(
3)最重要的是将你的其他入参(查询条件)保存到page中
4)Service层的方法需要带着page这个对象(最终目的是传递到sql执行的入参,让拦截器识别出该sql需要分页,同时传递页号)
5)将page对象传回Mode中
修改前后
1 2 3 4 5 6 7 8 9 10 |
@RequestMapping(value = "/user/users") public String list( @ModelAttribute("name") String name, @ModelAttribute("levelId") String levelId, @ModelAttribute("subjectId") String subjectId, Model model) { model.addAttribute("users",userService.selectByNameLevelSubject( name, levelId, subjectId)); return USER_LIST_JSP; } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
@RequestMapping(value = "/user/users") public String list( @RequestParam(required = false, defaultValue = "1") int pageNo, @RequestParam(required = false, defaultValue = "5") int pageSize, @ModelAttribute("name") String name, @ModelAttribute("levelId") String levelId, @ModelAttribute("subjectId") String subjectId, Model model) { // 这里是“信使”诞生之地,一出生就加载了很多重要信息! Page page = Page.newBuilder(pageNo, pageSize, "users"); page.getParams().put("name", name); //这里再保存查询条件 page.getParams().put("levelId", levelId); page.getParams().put("subjectId", subjectId); model.addAttribute("users",userService.selectByNameLevelSubject( name, levelId, subjectId, page)); model.addAttribute("page", page); //这里将page返回前台 return USER_LIST_JSP; } |
注意:pageSize的缺省值决定该分页的每页数据行数 ,实际项目更通用的方式是使用配置文件指定。Service层拦截器可以自动识别在Map或Bean中的Page对象。如果使用Bean需要在里面增加一个page项目,Map则比较简单,以下是例子。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@Override public List<UserDTO> selectByNameLevelSubject(String name, String levelId, String subjectId, Page page) { Map<String, Object> map = Maps.newHashMap(); levelId = DEFAULT_SELECTED.equals(levelId)?null: levelId; subjectId = DEFAULT_SELECTED.equals(subjectId)?null: subjectId; if (name != null && name.isEmpty()){ name = null; } map.put("name", name); map.put("levelId", levelId); map.put("subjectId", subjectId); map.put("page", page); //MAP的话加这一句就OK return userMapper.selectByNameLevelSubject(map); } |
前台页面方面,由于使用了标签,在适当的位置加一句就够了。
1 |
<tags:page page="${page}"/> |
“信使”page在这里进入标签,让分页按钮最终展现。至此,无需修改一句sql,完成分页自动化。
【效果图】
【总结】
现在回过头来看下最开始提出的几个问题:
1)分页时是要随时带有最近一次查询条件
回答:在改造Controller层时,通过将提交参数设置到 Page对象的 Map<String, String> params(单个基本型参数) 和 Map<String, List<String>> paramLists(数组基本型)解决。
顺便提一下,例子中没有涉及参数是Bean的情况,实际应用中应该比较常见。简单的方法是将Bean转换层Map后加入到params。
2)不能影响现有的sql,类似aop的效果
回答:利用Mybatis提供了 Interceptor 接口,拦截后改头换面去的件数并计算limit值,自然能神不知鬼不觉。
3)mybatis提供了通用的拦截接口,要选择适当的拦截方式和时点
回答:@Signature(method = “query”, type = Executor.class, args = { MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class }) 只拦截查询语句,其他增删改查不会影响。
4)尽量少的影响现有service等接口
回答:这个自认为本方案做的还不够好,主要是Controller层改造上,感觉代码量还比较大。如果有有识者知道更好的方案还请多指教。
【遗留问题】
1)一个“明显”的性能问题,是每次检索前都要去 select count(*)一次。在很多时候(数据变化不是特别敏感的场景)是不必要的。调整也不难,先Controller参数增加一个 totalRecord 总记录数 ,在稍加修改一下Page相关代码即可。
2)要排序怎么办?本文并未讨论排序,但是方法是类似的。以上面代码为基础,可以较容易地实现一个通用的排序标签。
===================================== 分割线 (1/8)=======================================
对于Controller层需要将入参传入Page对象的问题已经进行了改善,思路是自动从HttpServletRequest 类中提取入残,减低了分页代码的侵入性
再次改善,使用ThreadLocal类封装Page对象,让Service层等无需传Page对象,减小了侵入性。拦截器也省去了查找Page对象的动作,性能也同时改善。整体代码改动不大。
今天比较闲,顺便聊下这个分页的最终版,当然从来只有不断变化的需求,没有完美的方案,这里所说的最终版其实是一个优化后的“零侵入”的方案。为避免代码混乱还是只介绍思路。在上一个版本(1/23版)基础上有两点改动:
一是增加一个配置文件,按Url 配置初始的每页行数。如下面这样(pagesize 指的是每页行数):
1 |
<pager url="/user/users" pagesize="10" /> |
二是增加一个过滤器,并将剩下的位于Control类中 唯一侵入性的分页相关代码移入过滤器。发现当前的 Url 在配置文件中有匹配是就构造Page对象,并加入到Response中。
使用最终版后,对于开发者需要分页时,只要在配置文件中加一行,并在前端页面上加一个分页标签即可,其他代码,SQL等都不需要任何改动,可以说简化到了极限。
【技术列表】
总结下最终方案用到的技术:
- Mybatis 提供的拦截器接口实现(实现分页sql自动 select count 及limit 拼接)
- Servlet过滤器+ThreadLocal (生成线程共享的Page对象)
- 标签文件 (实现前端共通的分页效果)
- 临时表单提交 (减少页面体积)
【其他分页方案比较】
时下比较成熟的 JPA 的分页方案,(主要应用在 Hibernate + Spring Data 的场合),主要切入点在DAO层,而Controller等各层接口依然需要带着pageNumber,pageSize 这些的参数,另外框架开发者还要掌握一些必须的辅助类,如:
org.springframework.data.repository.PagingAndSortingRepository 可分页DAO基类
org.springframework.data.domain.Page 抽取结果封装类
org.springframework.data.domain.Pageable 分页信息类
比较来看 本方案 做到了分页与业务逻辑的完全解耦,开发者无需关注分页,全部通过配置实现。通过这个例子也可以反映出Mybatis在底层开发上有其独特的优势。
【备选方案】
最后再闲扯下,上面的最终案是基于 Url 配置的,其实也可以基于方法加自定义注解来做。这样配置文件省了,但是要增加一个注解解析类。注解中参数 为初始的每页行数。估计注解fans会喜欢,如下面的样子:
1 2 3 4 |
@RequestMapping(value = "/user/users") @Pagination(size=10) public String list( ... |
同样与过滤器配合使用,只是注解本身多少还是有“侵入性”。在初始行数基本不会变更时,这个比较直观的方案也是不错的选择。大家自行决定吧。