Mybatis原始碼分析(七)自定義快取、分頁的實現
上一章節通過原始碼已經深入瞭解到外掛的載入機制和時機,本章節就實戰一下。拿兩個功能點來展示外掛的使用。
一、快取
我們知道,在Mybatis中是有快取實現的。分一級快取和二級快取,不過一級快取其實沒啥用。因為我們知道它是基於sqlSession的,而sqlSession在每一次的方法執行時都會被新建立。二級快取是基於namespace,離開了它也是不行。有沒有一種方式來提供自定義的快取機制呢?
1、Executor
Executor是Mybatis中的執行器。所有的查詢就是呼叫它的<E> List<E> query()
方法。我們就可以在這裡進行攔截,不讓它執行後面的查詢動作, 直接從快取返回。
在這個類裡面,我們先獲取引數中的快取標記和快取的Key,去查詢Redis。如果命中,則返回;未命中,接著執行它本身的方法。
@Intercepts({@Signature(method = "query", type = Executor.class,args = { MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class})}) //BeanFactoryAware是Spring中的介面。目的是獲取jedisService的Bean public class ExecutorInterceptor implements Interceptor,BeanFactoryAware{ private JedisServiceImpl jedisService; @SuppressWarnings("unchecked") public Object intercept(Invocation invocation) throws Throwable { if (invocation.getTarget() instanceof CachingExecutor) { //獲取CachingExecutor所有的引數 Object[] params = invocation.getArgs(); //第二個引數就是業務方法的引數 Map<String,Object> paramMap = (Map<String, Object>) params[1]; String isCache = paramMap.get("isCache").toString(); //判斷是否需要快取,並取到快取的Key去查詢Redis if (isCache!=null && "true".equals(isCache)) { String cacheKey = paramMap.get("cacheKey").toString(); String cacheResult = jedisService.getString(cacheKey); if (cacheResult!=null) { System.out.println("已命中Redis快取,直接返回."); return JSON.parseObject(cacheResult, new TypeReference<List<Object>>(){}); }else { return invocation.proceed(); } } } return invocation.proceed(); } //返回代理物件 public Object plugin(Object target) { if (target instanceof Executor) { return Plugin.wrap(target, this); } return target; } public void setProperties(Properties properties) {} public void setBeanFactory(BeanFactory beanFactory) throws BeansException { jedisService = (JedisServiceImpl) beanFactory.getBean("jedisServiceImpl"); } } 複製程式碼
以上方法只是從快取中獲取資料,但什麼時候往快取中新增資料呢?總不能在每個業務方法裡面呼叫Redis的方法,以後如果把Redis換成了別的資料庫,豈不是很尷尬。
回憶一下Mybatis執行方法的整個流程。在提交執行完SQL之後,它是怎麼獲取返回值的呢?
2、ResultSetHandler
沒有印象嗎?就是這句return resultSetHandler.<E> handleResultSets(ps);
其中的resultSetHandler就是DefaultResultSetHandler例項的物件。它負責解析並返回從資料庫查詢到的資料,那麼我們就可以在返回之後把它放到Redis。
@Intercepts({@Signature(method = "handleResultSets", type = ResultSetHandler.class,args = {Statement.class})}) public class ResultSetHandlerInterceptor implements Interceptor,BeanFactoryAware{ private JedisServiceImpl jedisService; @SuppressWarnings("unchecked") public Object intercept(Invocation invocation) throws Throwable { Object result = null; if (invocation.getTarget() instanceof DefaultResultSetHandler) { //先執行方法,以獲得結果集 result = invocation.proceed(); DefaultResultSetHandler handler = (DefaultResultSetHandler) invocation.getTarget(); //通過反射拿到裡面的成員屬性,是為了最終拿到業務方法的引數 Field boundsql_field = getField(handler, "boundSql"); BoundSql boundSql = (BoundSql)boundsql_field.get(handler); Field param_field = getField(boundSql, "parameterObject"); Map<String,Object> paramMap = (Map<String, Object>) param_field.get(boundSql); String isCache = paramMap.get("isCache").toString(); if (isCache!=null && "true".equals(isCache)) { String cacheKey = paramMap.get("cacheKey").toString(); String cacheResult = jedisService.getString(cacheKey); //如果快取中沒有資料,就新增進去 if (cacheResult==null) { jedisService.setString(cacheKey, JSONObject.toJSONString(result)); } } } return result; } public Object plugin(Object target) { if (target instanceof ResultSetHandler) { return Plugin.wrap(target, this); } return target; } private Field getField(Object obj, String name) { Field field = ReflectionUtils.findField(obj.getClass(), name); field.setAccessible(true); return field; } public void setProperties(Properties properties) {} public void setBeanFactory(BeanFactory beanFactory) throws BeansException { jedisService = (JedisServiceImpl) beanFactory.getBean("jedisServiceImpl"); } } 複製程式碼
通過這兩個攔截器,就可以實現自定義快取。當然了,處理邏輯還是看自己的業務來定,但大體流程就是這樣的。這裡面最重要的其實是cacheKey的設計,怎麼做到通用性以及唯一性。為什麼這樣說呢?想象一下,如果執行了UPDATE操作,我們需要清除快取,那麼以什麼規則來清除呢?還有,如果cacheKey的粒度太粗,相同查詢方法的不同引數值怎麼來辨別呢?這都需要深思熟慮來設計這個欄位才行。
public @ResponseBody List<User> queryAll(){ Map<String,Object> paramMap = new HashMap<>(); paramMap.put("isCache", "true"); paramMap.put("cacheKey", "userServiceImpl.getUserList"); List<User> userList = userServiceImpl.getUserList(paramMap); return userList; } 複製程式碼
二、分頁
基本每個應用程式都有分頁的功能。從資料庫的角度來看,分頁就是確定從第幾條開始,一共取多少條的問題。比如在MySQL中,我們可以這樣select * from user limit 0,10
。
在程式中,我們不能每個SQL語句都加上limit,萬一換了不支援Limit的資料庫也是麻煩事。同時,limit後的0和10也並非一成不變的,這個取決於我們的頁面邏輯。
在解析完BoundSql之後,Mybatis開始呼叫StatementHandler.prepare()方法來構建預編譯物件,並設定引數值和提交SQL語句。我們的目的就是在此之前修改BoundSql中的SQL語句。先來看下攔截器的定義。
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class,Integer.class})}) public class PageInterceptor implements Interceptor { public Object intercept(Invocation invocation) throws Throwable { return invocation.proceed(); } public Object plugin(Object target) { if (target instanceof RoutingStatementHandler) { return Plugin.wrap(target, this); } return target; } } 複製程式碼
1、Page物件
那麼,第一步,我們先建立一個Page物件。它負責記錄和計算資料的起始位置和總條數,以便在頁面通過計算來友好的展示分頁。
public class Page { public Integer start;//當前頁第一條資料在List中的位置,從0開始 public static final Integer pageSize = 10;//每頁的條數 public Integer totals;//總記錄條數 public boolean needPage;//是否需要分頁 public Page(int pages) { setNeedPage(true); start = (pages-1)*Page.pageSize; } public boolean isNeedPage() { return needPage; } public void setNeedPage(boolean needPage) { this.needPage = needPage; } } 複製程式碼
2、獲取引數
從目標物件中,拿到各種引數,先要判斷是否需要分頁
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class,Integer.class})}) public class PageInterceptor implements Interceptor { public Object intercept(Invocation invocation) throws Throwable { if (invocation.getTarget() instanceof StatementHandler) { StatementHandler statementHandler = (StatementHandler)invocation.getTarget(); Field delegate_field = getField(statementHandler, "delegate"); StatementHandler preparedHandler = (StatementHandler)delegate_field.get(statementHandler); Field mappedStatement_field = getField(preparedHandler, "mappedStatement"); MappedStatement mappedStatement = (MappedStatement) mappedStatement_field.get(preparedHandler); Field boundsql_field = getField(preparedHandler, "boundSql"); BoundSql boundSql = (BoundSql)boundsql_field.get(preparedHandler); String sql = boundSql.getSql(); Object param = boundSql.getParameterObject(); if (param instanceof Map) { Map paramObject = (Map)param; if (paramObject.containsKey("page")) { //判斷是否需要分頁 Page page = (Page)paramObject.get("page"); if (!page.isNeedPage()) { return invocation.proceed(); } Connection connection = (Connection) invocation.getArgs()[0]; setTotals(mappedStatement,preparedHandler,page,connection,boundSql); sql = pageSql(sql, page); Field sql_field = getField(boundSql, "sql"); sql_field.setAccessible(true); sql_field.set(boundSql, sql); } } } return invocation.proceed(); } } 複製程式碼
3、設定總條數
實際上,一次分頁功能要設計到兩次查詢。一次是本身的SQL加上Limit標籤,一次是不加Limit的標籤並且應該是Count語句,來獲取總條數。所以,就是涉及到setTotals
這個方法。
這個方法的目的是獲取資料的總條數,它涉及幾個關鍵點。
- 修改原來的SQL,改成Count語句。
- 修改原來方法的返回值型別。
- 執行SQL。
- 把修改後的SQL和返回值型別,再改回去。
private void setTotals(MappedStatement mappedStatement,StatementHandler preparedHandler, Page page,Connection connection,BoundSql boundSql){ //原來的返回值型別 Class<?> old_type = Object.class; ResultMap resultMap = null; List<ResultMap> resultMaps = mappedStatement.getResultMaps(); if (resultMaps!=null && resultMaps.size()>0) { resultMap = resultMaps.get(0); old_type = resultMap.getType(); //修改返回值型別為Integer,因為我們獲取的是總條數 Field type_field = getField(resultMap, "type"); type_field.setAccessible(true); type_field.set(resultMap, Integer.class); } //修改SQL為count語句 String old_sql = boundSql.getSql(); String count_sql = getCountSql(old_sql); Field sql_field = getField(boundSql, "sql"); sql_field.setAccessible(true); sql_field.set(boundSql, count_sql); //執行SQL 並設定總條數到Page物件 Statement statement =prepareStatement(preparedHandler, connection); List<Object> resObjects = preparedHandler.query(statement, null); int result_count = (int) resObjects.get(0); page.setTotals(result_count); /** * 還要把sql和返回型別修改回去,這點很重要 */ Field sql_field_t = getField(boundSql, "sql"); sql_field_t.setAccessible(true); sql_field_t.set(boundSql, old_sql); Field type_field = getField(resultMap, "type"); type_field.setAccessible(true); type_field.set(resultMap, old_type); } private String getCountSql(String sql) { int index = sql.indexOf("from"); return "select count(1) " + sql.substring(index); } 複製程式碼
4、Limit
還獲取到總條數之後,還要修改一次SQL,是加上Limit。最後執行,並返回結果。
String sql = boundSql.getSql(); //加上Limit,從start開始 sql = pageSql(sql, page); Field sql_field = getField(boundSql, "sql"); sql_field.setAccessible(true); sql_field.set(boundSql, sql); private String pageSql(String sql, Page page) { StringBuffer sb = new StringBuffer(); sb.append(sql); sb.append(" limit "); sb.append(page.getStart()); sb.append("," + Page.pageSize); return sb.toString(); } 複製程式碼
最後,在業務方法裡面直接呼叫即可。當然了,記住要把Page引數傳過去。
public @ResponseBody List<User> queryAll(HttpServletResponse response) throws IOException { Page page = new Page(1); Map<String,Object> paramMap = new HashMap<>(); paramMap.put("isCache", "true"); paramMap.put("cacheKey", "userServiceImpl.getUserList"); paramMap.put("page", page); List<User> userList = userServiceImpl.getUserList(paramMap); for (User user : userList) { System.out.println(user.getUsername()); } System.out.println("資料總條數:"+page.getTotals()); return userList; } -------------------------------- 關小羽 小露娜 亞麻瑟 小魯班 資料總條數:4 複製程式碼
三、總結
本章節重點闡述了Mybatis中外掛的實際使用過程。在日常開發中,快取和分頁基本上都是可以常見的功能點。你完全可以高度自定義自己的快取機制,快取的時機、快取Key的設計、過期鍵的設定等....對於分頁你也應該更加清楚它們的實現邏輯,以便未來在選型的時候,你會多一份選擇。