分享一個完整的Mybatis分頁解決方案
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類物件會以“信使”的身份出現在全部與分頁相關的地方。
Java程式碼- /**
- * 封裝分頁資料
- */
- 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;
- publicclass Page {
- private
- privatestatic ObjectMapper mapper = new ObjectMapper();
- publicstatic String DEFAULT_PAGESIZE = "10";
- privateint pageNo; //當前頁碼
- privateint pageSize; //每頁行數
- privateint totalRecord; //總記錄數
- privateint 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 = "";
- }
- publicstatic 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;
- }
- /**
- * 總件數變化時,更新總頁數並計算顯示樣式
- */
- privatevoid 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);
- }
- }elseif ( pageNo < 7 ){
- for (int i=1; i<=8; i++){
- pageDisplays.add(i);
- }
- pageDisplays.add(0);// 0 表示 省略部分(下同)
- pageDisplays.add(totalPage-1);
- pageDisplays.add(totalPage);
- }elseif ( 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());
- }
- publicint getPageNo() {
- return pageNo;
- }
- publicvoid setPageNo(int pageNo) {
- this.pageNo = pageNo;
- }
- publicint getPageSize() {
- return pageSize;
- }
- publicvoid setPageSize(int pageSize) {
- this.pageSize = pageSize;
- }
- publicint getTotalRecord() {
- return totalRecord;
- }
- publicvoid setTotalRecord(int totalRecord) {
- this.totalRecord = totalRecord;
- refreshPage();
- }
- publicint getTotalPage() {
- return totalPage;
- }
- publicvoid setTotalPage(int totalPage) {
- this.totalPage = totalPage;
- }
- public Map<String, String> getParams() {
- return params;
- }
- publicvoid setParams(Map<String, String> params) {
- this.params = params;
- }
- public Map<String, List<String>> getParamLists() {
- return paramLists;
- }
- publicvoid setParamLists(Map<String, List<String>> paramLists) {
- this.paramLists = paramLists;
- }
- public String getSearchUrl() {
- return searchUrl;
- }
- publicvoid setSearchUrl(String searchUrl) {
- this.searchUrl = searchUrl;
- }
- public String getPageNoDisp() {
- return pageNoDisp;
- }
- publicvoid setPageNoDisp(String pageNoDisp) {
- this.pageNoDisp = pageNoDisp;
- }
- }
然後是最核心的攔截器了。涉及到了mybatis的核心功能,期間閱讀大量mybatis原始碼幾經修改重構,辛苦自不必說。
核心思想是將攔截到的select語句,改裝成select count(*)語句,執行之得到,總資料數。再根據page中的當前頁號算出limit值,拼接到select語句後。
為簡化程式碼使用了Commons JXPath 包,做物件查詢。
Java程式碼- /**
- * 分頁用攔截器
- */
- 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 })})
- publicclass 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;
- }
- }
- returnnull;
- }
- /**
- * 複製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";
- }
- publicclass 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);
- }
- publicvoid setProperties(Properties arg0) {
- }
- }
到展示層終於可以輕鬆些了,使用了檔案標籤來簡化前臺開發。
採用臨時表單提交,CSS使用了Bootstrap。
Html程式碼- <%@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());
- %>
- <scripttype="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('<formid="post' + id + '"name="post'+ id +'"action="' + action + '"method="post">');
- for (var key in values) {
- document.write('<inputtype="hidden"name="' + key + '"value="' + values[key] + '"/>');
- }
- for (var key2 in valueLists) {
- for (var index in valueLists[key2]) {
- document.write('<inputtype="hidden"name="' + key2 + '"value="' + valueLists[key2][index] + '"/>');
- }
- }
- document.write('</form>');
- document.getElementById('post' + id).submit();
- }
- //傳送POST
- jsPost("<%=page.getSearchUrl()%>", paraJson, paraListJson);
- }
- </script>
- <divclass="page-pull-right">
- <% if (current!=1 && end!=0){%>
- <buttonclass="btn btn-default btn-sm"onclick="pageClick(1)">首頁</button>
- <buttonclass="btn btn-default btn-sm"onclick="pageClick(${current-1})">前頁</button>
- <%}else{%>
- <buttonclass="btn btn-default btn-sm">首頁</button>
- <buttonclass="btn btn-default btn-sm">前頁</button>
- <%} %>
- <c:forTokensitems="${pList}"delims="|"var="pNo">
- <c:choose>
- <c:whentest="${pNo == 0}">
- <labelstyle="font-size: 10px; width: 20px; text-align: center;">•••</label>
- </c:when>
- <c:whentest="${pNo != current}">
- <buttonclass="btn btn-default btn-sm"onclick="pageClick(${pNo})">${pNo}</button>
- </c:when>
- <c:otherwise>
- <buttonclass="btn btn-primary btn-sm"style="font-weight:bold;">${pNo}</button>
- </c:otherwise>
- </c:choose>
- </c:forTokens>
- <% if (current<end && end!=0){%>
- <buttonclass="btn btn-default btn-sm"onclick="pageClick(${current+1})">後頁</button>
- <buttonclass="btn btn-default btn-sm"onclick="pageClick(${end})">末頁</button>
- <%}else{%>
- <buttonclass="btn btn-default btn-sm">後頁</button>
- <buttonclass="btn btn-default btn-sm">末頁</button>
- <%} %>
- </div>
注意“信使”在這裡使出了渾身解數,7個主要的get方法全部用上了。
Java程式碼- page.getPageNo() //當前頁號
- page.getTotalPage() //總頁數
- page.getPageNoDisp() //可以顯示的頁號
- page.getParaJson() //查詢條件
- page.getParaListJson() //陣列查詢條件
- page.getPageSize() //每頁行數
- page.getSearchUrl() //Url地址(作為action名稱)
到這裡三個核心模組完成了。然後是攔截器的註冊。
【攔截器的註冊】
需要在mybatis-config.xml 中加入攔截器的配置
Java程式碼- <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中
修改前
Java程式碼- @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;
- }
修改後
Java程式碼- @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則比較簡單,以下是例子。
Java程式碼- @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);
- }
前臺頁面方面,由於使用了標籤,在適當的位置加一句就夠了。
Html程式碼- <tags:pagepage="${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 類中提取入殘,減低了分頁程式碼的侵入性,詳細參看文章 http://duanhengbin.iteye.com/blog/2001142
===================================== 分割線 (1/23)=======================================
再次改善,使用ThreadLocal類封裝Page物件,讓Service層等無需傳Page物件,減小了侵入性。攔截器也省去了查詢Page物件的動作,效能也同時改善。整體程式碼改動不大。
===================================== 分割線 (2/21)=======================================
今天比較閒,順便聊下這個分頁的最終版,當然從來只有不斷變化的需求,沒有完美的方案,這裡所說的最終版其實是一個優化後的“零侵入”的方案。為避免程式碼混亂還是隻介紹思路。在上一個版本(1/23版)基礎上有兩點改動:
一是增加一個配置檔案,按Url 配置初始的每頁行數。如下面這樣(pagesize 指的是每頁行數):
Xml程式碼- <pagerurl="/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會喜歡,如下面的樣子:
Java程式碼- @RequestMapping(value = "/user/users")
- @Pagination(size=10)
- public String list(
- ...
同樣與過濾器配合使用,只是註解本身多少還是有“侵入性”。在初始行數基本不會變更時,這個比較直觀的方案也是不錯的選擇。大家自行決定吧。