Mybatis原始碼分析(七)自定義快取、分頁的實現

清幽之地發表於2019-03-10

上一章節通過原始碼已經深入瞭解到外掛的載入機制和時機,本章節就實戰一下。拿兩個功能點來展示外掛的使用。

一、快取

我們知道,在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的設計、過期鍵的設定等....對於分頁你也應該更加清楚它們的實現邏輯,以便未來在選型的時候,你會多一份選擇。

相關文章