上一章節通過原始碼已經深入瞭解到外掛的載入機制和時機,本章節就實戰一下。拿兩個功能點來展示外掛的使用。
一、快取
我們知道,在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的設計、過期鍵的設定等....對於分頁你也應該更加清楚它們的實現邏輯,以便未來在選型的時候,你會多一份選擇。