Mybatis分頁處理
最近有使用Mybatis3作為項目的ORM框架,在處理分頁的時候,發現Mybatis本身自帶RowBounds類,貌似利用它可來實現分頁功能,到底效果如何,以及Mybatis內部是如何處理的,讓我們搞一個Demo項目跑一下便可知曉。
項目類型:Java 控制台項目
Maven依賴:
<dependencies> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.22</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.27</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.3</version> </dependency> </dependencies>
說明:lombok的引入是為了寫Domain類的時候可以偷懶。
Maven資源處理:
<build> <resources> <resource> <directory>src/main/resources</directory> <includes> <include>**/*.*</include> </includes> </resource> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> </resources> </build>
說明:我們的Mapper XML文件放在與Mapper Java 接口文件同一個目錄,因此需要把java目錄下的xml也做資源處理過去,否則運行時找不到Mapper XML文件報錯。
定義一個mybatis-config.xml
該文件為Mybatis的SqlSessionFactory實例化所必須的配置文件,放在resources目錄下。
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD CONFIG 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd"> <configuration> <settings> <setting name="logImpl" value="STDOUT_LOGGING"/> </settings> <environments default="dev"> <environment id="dev"> <transactionManager type="JDBC"></transactionManager> <dataSource type="POOLED"> <property name="driver" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/study?useSSL=false" /> <property name="username" value="****"/> <property name="password" value="****"/> </dataSource> </environment> </environments> <mappers> <mapper resource="org/alan/mybatis/consoleclient/dao/DepartmentMapper.xml" /> </mappers> </configuration>
說明:logImpl配置讓我們可以在項目運行的時候實時看到執行的SQL語句,方便調試。在生產環境上可以關閉或更換為SLF4J等日志實現方式。
Mapper XML文件需要在配置里說明具體在哪里。
定義運行的main方法:
public class Application { public static void main(String[] args) throws IOException { final String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); System.out.println("using queryDepartments0 ------- "); try (SqlSession sqlSession = sqlSessionFactory.openSession()) { DepartmentMapper departmentMapper = sqlSession.getMapper(DepartmentMapper.class); List<Department> departments = departmentMapper.queryDepartments0(null); for (Department department : departments) { System.out.println("===>" + department); } } System.out.println("using queryDepartments1 ------- "); try (SqlSession sqlSession = sqlSessionFactory.openSession()) { DepartmentMapper departmentMapper = sqlSession.getMapper(DepartmentMapper.class); RowBounds rowBounds = new RowBounds(3, 4); List<Department> departments = departmentMapper.queryDepartments1(null, rowBounds); for (Department department : departments) { System.out.println("===>" + department); } } } }
數據庫准備:
create table if not exists Department (id int, name varchar)
數據內容:
Row: 1, 銷售部 Row: 2, 研發部 Row: 3, 財務部 Row: 4, 人資部 Row: 5, 生產部 Row: 6, 工程部 Row: 7, 采購部 Row: 8, 后勤部 Row: 9, 流程與標准化部 Row: 10, 總裁辦
Domain類:
@Data @NoArgsConstructor @AllArgsConstructor public class Department { private Integer id; private String name; }
Dao類:
public interface DepartmentMapper { List<Department> queryDepartments0(String nameFuzzy); List<Department> queryDepartments1(String nameFuzzy, RowBounds rowBounds); }
Mapper XML文件:
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="org.alan.mybatis.consoleclient.dao.DepartmentMapper"> <resultMap id="BaseResultMap" type="org.alan.mybatis.consoleclient.domain.Department"> <id column="id" property="id" jdbcType="INTEGER" /> <result column="name" property="name" jdbcType="VARCHAR" /> </resultMap> <sql id="Base_Column_List"> id, name </sql> <select id="queryDepartments0" resultMap="BaseResultMap" parameterType="java.lang.String"> select <include refid="Base_Column_List"></include> from Department <where> <if test="param0 != null"> name like #{param0} </if> </where> </select> <select id="queryDepartments1" resultMap="BaseResultMap" parameterType="java.lang.String"> select <include refid="Base_Column_List"></include> from Department <where> <if test="param0 != null"> name like #{param0} </if> </where> </select> </mapper>
可以看到,在 Mapper XML 中定義的兩個select (queryDepartments0, queryDepartments1)內容其實是一摸一樣的,但是在 Java Mapper 的接口中定義的方法卻是不一樣的,一個為 queryDepartments0(String nameFuzzy),另一個為 queryDepartments1(String nameFuzzy, RowBounds rowBounds),明顯的是,后面一個帶了 RowBounds 參數。我就是想看看 RowBounds 參數會產生什么不一樣的效果。
對於 queryDepartments0 ,我們傳入fuzzy=null,實際上就是查詢所有記錄。運行我們的程序,可以看到結果與預期相符:
===>Department(id=1, name=銷售部) ===>Department(id=2, name=研發部) ===>Department(id=3, name=財務部) ===>Department(id=4, name=人資部) ===>Department(id=5, name=生產部) ===>Department(id=6, name=工程部) ===>Department(id=7, name=采購部) ===>Department(id=8, name=后勤部) ===>Department(id=9, name=流程與標准化部) ===>Department(id=10, name=總裁辦)
對於 queryDepartments1 ,我們傳入fuzzy=null,RowBounds為offset=3,limit=4,實際上就是查詢自第四條記錄開始(因為第一條記錄offset為0)的隨后四條(limit=4)記錄。運行我們的程序,可以看到結果與預期相符:
===>Department(id=4, name=人資部) ===>Department(id=5, name=生產部) ===>Department(id=6, name=工程部) ===>Department(id=7, name=采購部)
所以,我們知道了,如果想要查詢中間某段的記錄,我們可以在接口方法的最后一個參數加上RowBounds,Mybatis會自己幫我們返回合適的數據集,並不需要在Mapper XML中針對這個RowBounds參數做任何特殊的處理。那么Mybatis是如何做到的呢?我們可以看看Mybatis的日志輸出:
using queryDepartments1 ------- Opening JDBC Connection Checked out connection 210652080 from pool. Setting autocommit to false on JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@c8e4bb0] ==> Preparing: select id, name from Department ==> Parameters: <== Columns: id, name <== Row: 1, 銷售部 <== Row: 2, 研發部 <== Row: 3, 財務部 <== Row: 4, 人資部 <== Row: 5, 生產部 <== Row: 6, 工程部 <== Row: 7, 采購部
從這里,我們可以推測,Mybatis應該沒有特殊的去處理SQL,而是在SQL執行后,對數據庫返回的結果集做了一次過濾再返回給了我們。其中大家可以去看看Mybatis源碼 DefaultResultSetHandler,以及 DefaultCursor。
那么這種方式對於我們實際的生產應用說,應該是達不到要求的,尤其是在深分頁的時候。比如說我們的數據庫里有一萬條的記錄,我想查9800~9900的記錄,按這種方式,需要查出9900條記錄,而且前面9800條記錄是沒有用的。這種方式對於優化數據查詢沒有任何意義,同時對我們的程序來說,創建那么多不用的對象,加重了系統的資源負擔。我們期望的是,如果我們傳入RowBounds(9800, 100)的時候,Mybatis能自動幫我們把SQL加上Limit分頁查詢子句,這樣才能真正做到優化查詢,那么該如何做到呢?
這個時候Mybatis的插件就能幫上忙了。首先我們寫一個Statement攔截器,安裝在Mybatis中,以便我們可以在Mybatis執行前,根據當前執行主體內RowBounds的值來決定是否修正SQL(這里我們會用到一些反射的技術來獲取和設置對象的field值):
@Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = { Connection.class, Integer.class }) }) public class MybatisStatementHandlerInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { RoutingStatementHandler statement = (RoutingStatementHandler) invocation.getTarget(); StatementHandler handler = (StatementHandler) ReflectionUtil.getFieldValue(statement, "delegate"); BoundSql boundSql = statement.getBoundSql(); String sql = boundSql.getSql(); if (handler instanceof PreparedStatementHandler) { // 獲取RowBounds內部值,如果需要分頁的情況下,修正SQL語句 RowBounds rowBounds = (RowBounds) ReflectionUtil.getFieldValue(handler, "rowBounds"); if (rowBounds.getLimit() > 0 && rowBounds.getLimit() < RowBounds.NO_ROW_LIMIT) { System.out.println("------->yes fix sql with row bounds(" + rowBounds.getOffset() + "," + rowBounds.getLimit() + "): " + sql); String fixedSql = getLimitString(sql, rowBounds.getOffset(), rowBounds.getLimit()); ReflectionUtil.setFieldValue(boundSql, "sql", fixedSql); } } return invocation.proceed(); } private String getLimitString(String sql, int offset, int limit) { StringBuilder sqlBuilder = new StringBuilder(); sqlBuilder.append(sql.trim()); sqlBuilder.append(" LIMIT "); sqlBuilder.append(offset); sqlBuilder.append(", "); sqlBuilder.append(limit); return sqlBuilder.toString(); } }
然后我們在mybatis-config.xml中把該插件配置上去:
<plugins> <plugin interceptor="org.alan.mybatis.consoleclient.dao.MybatisStatementHandlerInterceptor"> </plugin> </plugins>
然后我們再運行一次 queryDepartments1 ,傳入fuzzy=null,RowBounds為offset=3,limit=4,看看執行結果,輸出為:
===>Department(id=7, name=采購部)
不對啊,按道理應該輸出 4,5,6,7 四個記錄才對啊,怎么只輸出了一個7呢?看看打印的SQL語句,SQL也是被修正過了的呀:
using queryDepartments1 ------- Opening JDBC Connection Checked out connection 1106131243 from pool. Setting autocommit to false on JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@41ee392b] ------->yes fix sql with row bounds(3,4): select id, name from Department ==> Preparing: select id, name from Department LIMIT 3, 4 ==> Parameters: <== Columns: id, name <== Row: 4, 人資部 <== Row: 5, 生產部 <== Row: 6, 工程部 <== Row: 7, 采購部 <== Total: 4
SQL語句也沒錯,SQL執行的輸出也毫無問題。怎么返回到客戶端的結果就只剩一個7記錄了呢?
回想一下前面我們說的,Mybatis處理RowBounds的方式是是在SQL執行后,對數據庫返回的結果集做了一次過濾再返回給了我們。而這個處理是在Mybatis內部的ResultSetHandler中完成的。因此,我們要修正這個帶來的意外效果,就是需要在Mybatis處理ResultSetHandler的階段之前,把RowBounds對象修改為一個無限制的RowBounds對象,Mybatis就不會施加這個過濾效果了。那怎么修改呢?還是 和 前面的 MybatisStatementHandlerInterceptor 一樣,使用攔截器完成,只不過這個攔截器攔截的是ResultSetHandler的handleResultSets方法:
@Intercepts({ @Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class}) }) public class MybatisResultSetHandlerInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { Object target = invocation.getTarget(); if (target instanceof DefaultResultSetHandler) { DefaultResultSetHandler resultSetHandler = (DefaultResultSetHandler)target; Object rowBounds = ReflectionUtil.getFieldValue(resultSetHandler, "rowBounds"); if (rowBounds instanceof RowBounds) { RowBounds castedRowBounds = (RowBounds)rowBounds; // 如果RowBounds對象表示需要進行分頁,那么表示就是需要去除這個待分頁的RowBounds,設置為默認不 // 需要分析的RowBounds對象。 if (castedRowBounds.getLimit() > 0 && castedRowBounds.getLimit() < RowBounds.NO_ROW_LIMIT) { System.out.println("---->yes " + castedRowBounds.toString() + " forget it!"); ReflectionUtil.setFieldValue(resultSetHandler, "rowBounds", new RowBounds()); } else { System.out.println("---->no " + castedRowBounds.toString()); } } } return invocation.proceed(); } }
然后我們在mybatis-config.xml中把該插件配置上去:
<plugins> <plugin interceptor="org.alan.mybatis.consoleclient.dao.MybatisStatementHandlerInterceptor"> </plugin> <plugin interceptor="org.alan.mybatis.consoleclient.dao.MybatisResultSetHandlerInterceptor"> </plugin> </plugins>
然后我們再運行一次 queryDepartments1 ,傳入fuzzy=null,RowBounds為offset=3,limit=4,看看執行結果,輸出為:
===>Department(id=4, name=人資部) ===>Department(id=5, name=生產部) ===>Department(id=6, name=工程部) ===>Department(id=7, name=采購部)
看看SQL輸出,
==> Preparing: select id, name from Department LIMIT 3, 4
這個SQL修正結果與執行返回到客戶端的數據集輸出果,正是我們想要的。
==========================================================================
綜上實驗結果,如果想要在基於Mybatis ORM的項目中實施分頁功能,只需要給安裝兩個Mybatis
插件即可:
一個攔截器用於修改SQL:MybatisStatementHandlerInterceptor
一個攔截去用於去除Mybatis內部對RowBounds的處理邏輯:MybatisResultSetHandlerInterceptor
這樣的分頁方案對於寫Mapper XML的人來說是無感的,我們不需要在Mapper XML中處理任何分頁相關代碼。
只需要在Mapper的接口方法參數中添加最后一個RowBounds參數即可實現分頁。