@
前言
Mybatis是一款半自動的ORM框架,是目前國內Java web開發的主流ORM框架,因此作為一名開發者非常有必要掌握其實現原理,才能更好的解決我們開發中遇到的問題;同時,Mybatis的架構和原始碼也是很優雅的,使用了大量的設計模式實現解耦以及高擴充套件性,所以對其設計思想,我們也非常有必要好好理解掌握。(PS:本系列文章基於3.5.0版本分析)
精良的Mybatis骨架
巨集觀設計
Mybatsi的原始碼相較於Spring原始碼無論是架構還是實現都簡單了很多,它所有的程式碼都在一個工程裡面,在這個工程下分了很多包,每個包分工都很明確:
別看模組有這麼多,實際上只需要分為三層:
這樣分層後,是不是就很清晰了,基礎支撐層是一些通用元件的封裝,如日誌、快取、反射、資料來源等等,這些模組支撐著核心業務邏輯的實現,並且如果需要我們可以將其直接用於到我們專案中,像反射模組就是對JDK的反射進行了封裝,使其更加方便易用;核心處理層就是Mybatis的核心業務的實現了,通過底層支撐模組,實現了配置檔案和SQL解析、引數對映和繫結、SQL執行和返回結果的對映以及擴充套件外掛的執行等等;最後介面層則是對外提供的服務,我們使用Mybatis時只需要通過該介面進行操作,對底層的實現無需關注。這樣分層的好處不用多說,讓我們的程式碼更加簡潔易讀,同時可維護性和可擴充套件性也大大提高,另外從整個架構設計中我們可以看到一個設計模式的體現——門面模式,因為門面模式的設計思想就是對外提供一個統一的的介面,遮蔽掉內部系統實現的複雜性,使得使用者無需關注內部實現就能輕鬆使用所有功能,而這裡的架構設計就是採用的這樣一個思想。舉一反三,再想想看其它的開源框架是不是都是這樣的設計?
基礎支撐
在瞭解了Mybatis的巨集觀架構設計後,下面就是對原始碼的詳細分析,首先先來看幾個重點的基礎支撐模組:
- 日誌
- 資料來源
- 快取
- 反射
日誌
日誌的載入
Mybatis本身是沒有實現日誌功能的,而是引入第三方日誌,但第三方日誌都有自己的log級別,Mybatis需要解決的就是如何相容這些日誌元件。如何相容呢?Mybatis使用了介面卡模式來解決,在logging模組下提供了一個統一的日誌介面Log介面:
public interface Log {
boolean isDebugEnabled();
boolean isTraceEnabled();
void error(String s, Throwable e);
void error(String s);
void debug(String s);
void trace(String s);
void warn(String s);
}
可以看到在這個介面中統一定義了各個日誌級別,引入的第三方日誌元件只需要實現該介面,在各個級別介面中呼叫各元件自身對應的API即可。從下面的類圖我們可以看到Mybatis支援了哪些三方日誌元件:
看到這裡你是否會有疑問,這些第三方日誌元件是怎麼載入的?載入順序又是怎樣的呢?難道是在需要用的地方才例項化麼?當然不是,Mybatis這裡又使用了一個設計模式——工廠模式。在日誌模組下有一個類LogFactory,日誌的載入就是由該類實現的,通過這個類解耦了日誌的例項化和日誌的使用:
public final class LogFactory {
public static final String MARKER = "MYBATIS";
//被選定的第三方日誌元件介面卡的構造方法
private static Constructor<? extends Log> logConstructor;
//自動掃描日誌實現,並且第三方日誌外掛載入優先順序如下:slf4J → commonsLoging → Log4J2 → Log4J → JdkLog
static {
tryImplementation(LogFactory::useSlf4jLogging);
tryImplementation(LogFactory::useCommonsLogging);
tryImplementation(LogFactory::useLog4J2Logging);
tryImplementation(LogFactory::useLog4JLogging);
tryImplementation(LogFactory::useJdkLogging);
tryImplementation(LogFactory::useNoLogging);
}
private LogFactory() {
// disable construction
}
public static Log getLog(Class<?> aClass) {
return getLog(aClass.getName());
}
public static Log getLog(String logger) {
try {
return logConstructor.newInstance(logger);
} catch (Throwable t) {
throw new LogException("Error creating logger for logger " + logger + ". Cause: " + t, t);
}
}
public static synchronized void useCustomLogging(Class<? extends Log> clazz) {
setImplementation(clazz);
}
public static synchronized void useSlf4jLogging() {
setImplementation(org.apache.ibatis.logging.slf4j.Slf4jImpl.class);
}
public static synchronized void useCommonsLogging() {
setImplementation(org.apache.ibatis.logging.commons.JakartaCommonsLoggingImpl.class);
}
public static synchronized void useLog4JLogging() {
setImplementation(org.apache.ibatis.logging.log4j.Log4jImpl.class);
}
public static synchronized void useLog4J2Logging() {
setImplementation(org.apache.ibatis.logging.log4j2.Log4j2Impl.class);
}
public static synchronized void useJdkLogging() {
setImplementation(org.apache.ibatis.logging.jdk14.Jdk14LoggingImpl.class);
}
public static synchronized void useStdOutLogging() {
setImplementation(org.apache.ibatis.logging.stdout.StdOutImpl.class);
}
public static synchronized void useNoLogging() {
setImplementation(org.apache.ibatis.logging.nologging.NoLoggingImpl.class);
}
private static void tryImplementation(Runnable runnable) {
if (logConstructor == null) {//當構造方法不為空才執行方法
try {
runnable.run();
} catch (Throwable t) {
// ignore
}
}
}
//通過指定的log類來初始化構造方法
private static void setImplementation(Class<? extends Log> implClass) {
try {
Constructor<? extends Log> candidate = implClass.getConstructor(String.class);
Log log = candidate.newInstance(LogFactory.class.getName());
if (log.isDebugEnabled()) {
log.debug("Logging initialized using '" + implClass + "' adapter.");
}
logConstructor = candidate;
} catch (Throwable t) {
throw new LogException("Error setting Log implementation. Cause: " + t, t);
}
}
}
通過上面的程式碼我們可以清楚的看到日誌的載入順序是怎樣的,並且只要載入成功了任何一個日誌元件,其它的日誌元件就不會被載入。
日誌的使用
日誌載入完成後,自然而然的我們就該思考的是哪些地方需要列印日誌?Mybatis本身是對JDK原生的JDBC的包裝和增強,所以在以下幾個關鍵地方都應該列印日誌:
- 建立PreparedStatement和Statement時列印SQL語句和引數資訊
- 獲取到查詢結果後列印結果資訊
問題是應該怎麼優雅地增強這些方法呢?Mybatis使用了動態代理來實現。在日誌模組下的JDBC包就是代理類的實現,先來看看類圖:
見名知義,看到這些類名我們應該就能清楚這些類的作用,它們就是對原生的JDBC API的增強,在呼叫相關的方法時,首先會進入到這些代理類的invoke方法裡面,按照執行順序,首先進入呼叫的肯定是ConnectionLogger:
public final class ConnectionLogger extends BaseJdbcLogger implements InvocationHandler {
//真正的連線物件
private final Connection connection;
private ConnectionLogger(Connection conn, Log statementLog, int queryStack) {
super(statementLog, queryStack);
this.connection = conn;
}
@Override
//對連線的增強
public Object invoke(Object proxy, Method method, Object[] params)
throws Throwable {
try {
//如果是從Obeject繼承的方法直接忽略
if (Object.class.equals(method.getDeclaringClass())) {
return method.invoke(this, params);
}
//如果是呼叫prepareStatement、prepareCall、createStatement的方法,列印要執行的sql語句
//並返回prepareStatement的代理物件,讓prepareStatement也具備日誌能力,列印引數
if ("prepareStatement".equals(method.getName())) {
if (isDebugEnabled()) {
debug(" Preparing: " + removeBreakingWhitespace((String) params[0]), true);//列印sql語句
}
PreparedStatement stmt = (PreparedStatement) method.invoke(connection, params);
stmt = PreparedStatementLogger.newInstance(stmt, statementLog, queryStack);//建立代理物件
return stmt;
} else if ("prepareCall".equals(method.getName())) {
if (isDebugEnabled()) {
debug(" Preparing: " + removeBreakingWhitespace((String) params[0]), true);//列印sql語句
}
PreparedStatement stmt = (PreparedStatement) method.invoke(connection, params);//建立代理物件
stmt = PreparedStatementLogger.newInstance(stmt, statementLog, queryStack);
return stmt;
} else if ("createStatement".equals(method.getName())) {
Statement stmt = (Statement) method.invoke(connection, params);
stmt = StatementLogger.newInstance(stmt, statementLog, queryStack);//建立代理物件
return stmt;
} else {
return method.invoke(connection, params);
}
} catch (Throwable t) {
throw ExceptionUtil.unwrapThrowable(t);
}
}
public static Connection newInstance(Connection conn, Log statementLog, int queryStack) {
InvocationHandler handler = new ConnectionLogger(conn, statementLog, queryStack);
ClassLoader cl = Connection.class.getClassLoader();
return (Connection) Proxy.newProxyInstance(cl, new Class[]{Connection.class}, handler);
}
public Connection getConnection() {
return connection;
}
}
從invoke方法裡我們可以看到主要對Connection的prepareStatement、prepareCall、createStatement方法進行了增強,列印日誌並建立了對應的代理類返回。其它幾個類實現原理都是一樣,這裡不再贅述。
但還有個問題,其它幾個類的呼叫都是在建立連線之後,所以對應的代理類是由上一個階段的代理類建立的,那ConnectionLogger是在哪裡建立的呢?自然是在獲取連線時,而獲取連線都是在我們的業務程式碼執行階段的時候,Mybatis對執行階段又封裝了一個個Excutor執行器,詳細程式碼後文分析。
資料來源
資料來源的建立
資料來源都需要實現JDK的DataSource介面,Mybatis自己本身實現了資料來源介面,同時也支援第三方的資料來源。這裡主要看看Mybatis內部的實現,同樣先來看一張類圖:
從圖中我們可以看到DataSource的初始化同樣是通過工廠模式實現的,而其本身提供了三種資料來源:
- PooledDataSource:帶連線池的資料來源
- UnpooledDataSource:不帶連線池的資料來源
- JNDI資料來源
最後一種此處不分析。UnpooledDataSource就是一個普通的資料來源,實現了基本的資料來源介面;而PooledDataSource是基於UnpooledDataSource實現的,只是在此之上提供了連線池功能。另外還需要注意PooledConnection,該類是連線池中存放的連線物件,但其並不是真正的連線物件,只是持有了真實連線的引用,並且是對真實連線進行增強的代理類,下面就主要分析連線池的實現原理。
池化技術原理
資料結構
首先來看下PooledConnection都封裝了些什麼:
class PooledConnection implements InvocationHandler {
private static final String CLOSE = "close";
private static final Class<?>[] IFACES = new Class<?>[] { Connection.class };
private final int hashCode;
//記錄當前連線所在的資料來源物件,本次連線是有這個資料來源建立的,關閉後也是回到這個資料來源;
private final PooledDataSource dataSource;
//真正的連線物件
private final Connection realConnection;
//連線的代理物件
private final Connection proxyConnection;
//從資料來源取出來連線的時間戳
private long checkoutTimestamp;
//連線建立的的時間戳
private long createdTimestamp;
//連線最後一次使用的時間戳
private long lastUsedTimestamp;
//根據資料庫url、使用者名稱、密碼生成一個hash值,唯一標識一個連線池
private int connectionTypeCode;
//連線是否有效
private boolean valid;
/*
* Constructor for SimplePooledConnection that uses the Connection and PooledDataSource passed in
*
* @param connection - the connection that is to be presented as a pooled connection
* @param dataSource - the dataSource that the connection is from
*/
public PooledConnection(Connection connection, PooledDataSource dataSource) {
this.hashCode = connection.hashCode();
this.realConnection = connection;
this.dataSource = dataSource;
this.createdTimestamp = System.currentTimeMillis();
this.lastUsedTimestamp = System.currentTimeMillis();
this.valid = true;
this.proxyConnection = (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), IFACES, this);
}
......省略
/*
* 此方法專門用來增強資料庫connect物件,使用前檢查連線是否有效,關閉時對連線進行回收
*
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
String methodName = method.getName();
if (CLOSE.hashCode() == methodName.hashCode() && CLOSE.equals(methodName)) {//如果是呼叫連線的close方法,不是真正的關閉,而是回收到連線池
dataSource.pushConnection(this);//通過pooled資料來源來進行回收
return null;
} else {
try {
//使用前要檢查當前連線是否有效
if (!Object.class.equals(method.getDeclaringClass())) {
// issue #579 toString() should never fail
// throw an SQLException instead of a Runtime
checkConnection();//
}
return method.invoke(realConnection, args);
} catch (Throwable t) {
throw ExceptionUtil.unwrapThrowable(t);
}
}
}
private void checkConnection() throws SQLException {
if (!valid) {
throw new SQLException("Error accessing PooledConnection. Connection is invalid.");
}
}
}
屬性和方法上都已經有了詳細的註釋,主要關注realConnection真實連線的引用和invoke方法增強。接著再看連線池的實現,這個類包含了很多屬性:
private final PoolState state = new PoolState(this);
//真正用於建立連線的資料來源
private final UnpooledDataSource dataSource;
// OPTIONAL CONFIGURATION FIELDS
//最大活躍連線數
protected int poolMaximumActiveConnections = 10;
//最大閒置連線數
protected int poolMaximumIdleConnections = 5;
//最大checkout時長(最長使用時間)
protected int poolMaximumCheckoutTime = 20000;
//無法取得連線是最大的等待時間
protected int poolTimeToWait = 20000;
//最多允許幾次無效連線
protected int poolMaximumLocalBadConnectionTolerance = 3;
//測試連線是否有效的sql語句
protected String poolPingQuery = "NO PING QUERY SET";
//是否允許測試連線
protected boolean poolPingEnabled;
//配置一段時間,當連線在這段時間內沒有被使用,才允許測試連線是否有效
protected int poolPingConnectionsNotUsedFor;
//根據資料庫url、使用者名稱、密碼生成一個hash值,唯一標識一個連線池,由這個連線池生成的連線都會帶上這個值
private int expectedConnectionTypeCode;
相信上面大部分屬性讀者們都不會陌生,在進行開發時應該都有配置過。其中有一個關鍵的屬性PoolState,這個是物件主要儲存了空閒連線和活躍連線,也就是連線池用來管理資源的,它包含了以下屬性:
protected PooledDataSource dataSource;
//空閒的連線池資源集合
protected final List<PooledConnection> idleConnections = new ArrayList<>();
//活躍的連線池資源集合
protected final List<PooledConnection> activeConnections = new ArrayList<>();
//請求的次數
protected long requestCount = 0;
//累計的獲得連線的時間
protected long accumulatedRequestTime = 0;
//累計的使用連線的時間。從連線取出到歸還,算一次使用的時間;
protected long accumulatedCheckoutTime = 0;
//使用連線超時的次數
protected long claimedOverdueConnectionCount = 0;
//累計超時時間
protected long accumulatedCheckoutTimeOfOverdueConnections = 0;
//累計等待時間
protected long accumulatedWaitTime = 0;
//等待次數
protected long hadToWaitCount = 0;
//無效的連線次數
protected long badConnectionCount = 0;
獲取連線
瞭解了這些關鍵的屬性後,再來看看如何從連線池獲取連線,在PooledDataSource中有一個popConnection用於獲取連線:
private PooledConnection popConnection(String username, String password) throws SQLException {
boolean countedWait = false;
PooledConnection conn = null;
long t = System.currentTimeMillis();//記錄嘗試獲取連線的起始時間戳
int localBadConnectionCount = 0;//初始化獲取到無效連線的次數
while (conn == null) {
synchronized (state) {//獲取連線必須是同步的
if (!state.idleConnections.isEmpty()) {//檢測是否有空閒連線
// Pool has available connection
//有空閒連線直接使用
conn = state.idleConnections.remove(0);
if (log.isDebugEnabled()) {
log.debug("Checked out connection " + conn.getRealHashCode() + " from pool.");
}
} else {// 沒有空閒連線
if (state.activeConnections.size() < poolMaximumActiveConnections) {//判斷活躍連線池中的數量是否大於最大連線數
// 沒有則可建立新的連線
conn = new PooledConnection(dataSource.getConnection(), this);
if (log.isDebugEnabled()) {
log.debug("Created connection " + conn.getRealHashCode() + ".");
}
} else {// 如果已經等於最大連線數,則不能建立新連線
//獲取最早建立的連線
PooledConnection oldestActiveConnection = state.activeConnections.get(0);
long longestCheckoutTime = oldestActiveConnection.getCheckoutTime();
if (longestCheckoutTime > poolMaximumCheckoutTime) {//檢測是否已經以及超過最長使用時間
// 如果超時,對超時連線的資訊進行統計
state.claimedOverdueConnectionCount++;//超時連線次數+1
state.accumulatedCheckoutTimeOfOverdueConnections += longestCheckoutTime;//累計超時時間增加
state.accumulatedCheckoutTime += longestCheckoutTime;//累計的使用連線的時間增加
state.activeConnections.remove(oldestActiveConnection);//從活躍佇列中刪除
if (!oldestActiveConnection.getRealConnection().getAutoCommit()) {//如果超時連線未提交,則手動回滾
try {
oldestActiveConnection.getRealConnection().rollback();
} catch (SQLException e) {//發生異常僅僅記錄日誌
/*
Just log a message for debug and continue to execute the following
statement like nothing happend.
Wrap the bad connection with a new PooledConnection, this will help
to not intterupt current executing thread and give current thread a
chance to join the next competion for another valid/good database
connection. At the end of this loop, bad {@link @conn} will be set as null.
*/
log.debug("Bad connection. Could not roll back");
}
}
//在連線池中建立新的連線,注意對於資料庫來說,並沒有建立新連線;
conn = new PooledConnection(oldestActiveConnection.getRealConnection(), this);
conn.setCreatedTimestamp(oldestActiveConnection.getCreatedTimestamp());
conn.setLastUsedTimestamp(oldestActiveConnection.getLastUsedTimestamp());
//讓老連線失效
oldestActiveConnection.invalidate();
if (log.isDebugEnabled()) {
log.debug("Claimed overdue connection " + conn.getRealHashCode() + ".");
}
} else {
// 無空閒連線,最早建立的連線沒有失效,無法建立新連線,只能阻塞
try {
if (!countedWait) {
state.hadToWaitCount++;//連線池累計等待次數加1
countedWait = true;
}
if (log.isDebugEnabled()) {
log.debug("Waiting as long as " + poolTimeToWait + " milliseconds for connection.");
}
long wt = System.currentTimeMillis();
state.wait(poolTimeToWait);//阻塞等待指定時間
state.accumulatedWaitTime += System.currentTimeMillis() - wt;//累計等待時間增加
} catch (InterruptedException e) {
break;
}
}
}
}
if (conn != null) {//獲取連線成功的,要測試連線是否有效,同時更新統計資料
// ping to server and check the connection is valid or not
if (conn.isValid()) {//檢測連線是否有效
if (!conn.getRealConnection().getAutoCommit()) {
conn.getRealConnection().rollback();//如果遺留歷史的事務,回滾
}
//連線池相關統計資訊更新
conn.setConnectionTypeCode(assembleConnectionTypeCode(dataSource.getUrl(), username, password));
conn.setCheckoutTimestamp(System.currentTimeMillis());
conn.setLastUsedTimestamp(System.currentTimeMillis());
state.activeConnections.add(conn);
state.requestCount++;
state.accumulatedRequestTime += System.currentTimeMillis() - t;
} else {//如果連線無效
if (log.isDebugEnabled()) {
log.debug("A bad connection (" + conn.getRealHashCode() + ") was returned from the pool, getting another connection.");
}
state.badConnectionCount++;//累計的獲取無效連線次數+1
localBadConnectionCount++;//當前獲取無效連線次數+1
conn = null;
//拿到無效連線,但如果沒有超過重試的次數,允許再次嘗試獲取連線,否則丟擲異常
if (localBadConnectionCount > (poolMaximumIdleConnections + poolMaximumLocalBadConnectionTolerance)) {
if (log.isDebugEnabled()) {
log.debug("PooledDataSource: Could not get a good connection to the database.");
}
throw new SQLException("PooledDataSource: Could not get a good connection to the database.");
}
}
}
}
}
if (conn == null) {
if (log.isDebugEnabled()) {
log.debug("PooledDataSource: Unknown severe error condition. The connection pool returned a null connection.");
}
throw new SQLException("PooledDataSource: Unknown severe error condition. The connection pool returned a null connection.");
}
return conn;
}
這裡的邏輯相對比較複雜,我總結了整個步驟並畫了一張圖幫助理解:迴圈獲取連線,首先判斷是否還存在空閒連線,如果存在,則直接使用,並刪除一個空閒連線;如果不存在,優先判斷是否已經達到最大活躍連線數量。如果沒有則直接建立一個新的連線;如果已經達到最大活躍連線數,則從活躍連線池中取出最早的連線,判斷是否超時。如果沒有超時,則呼叫wait方法阻塞;如果超時,則統計超時連線資訊,並根據超時連線的真實連線建立新的連線,同時讓舊連線失效。經過以上步驟後,如果獲取到一個連線,則還需要判斷連線是否有效,有效連線需要回滾之前未提交的事務並新增到活躍連線池,無效連線則統計資訊並判斷是否已經超過重試次數,若沒有則繼續迴圈下一次獲取連線,否則丟擲異常。迴圈完成後返回獲取到的連線。
回收連線
普通的連線是直接關閉,需要用的時候重新建立,而連線池則需要將連線回收到池中複用,避免重複建立連線提高效率,在PooledDataSource中的pushConnection就是用於回收連線的:
protected void pushConnection(PooledConnection conn) throws SQLException {
synchronized (state) {//回收連線必須是同步的
state.activeConnections.remove(conn);//從活躍連線池中刪除此連線
if (conn.isValid()) {
//判斷閒置連線池資源是否已經達到上限
if (state.idleConnections.size() < poolMaximumIdleConnections && conn.getConnectionTypeCode() == expectedConnectionTypeCode) {
//沒有達到上限,進行回收
state.accumulatedCheckoutTime += conn.getCheckoutTime();
if (!conn.getRealConnection().getAutoCommit()) {
conn.getRealConnection().rollback();//如果還有事務沒有提交,進行回滾操作
}
//基於該連線,建立一個新的連線資源,並重新整理連線狀態
PooledConnection newConn = new PooledConnection(conn.getRealConnection(), this);
state.idleConnections.add(newConn);
newConn.setCreatedTimestamp(conn.getCreatedTimestamp());
newConn.setLastUsedTimestamp(conn.getLastUsedTimestamp());
//老連線失效
conn.invalidate();
if (log.isDebugEnabled()) {
log.debug("Returned connection " + newConn.getRealHashCode() + " to pool.");
}
//喚醒其他被阻塞的執行緒
state.notifyAll();
} else {//如果閒置連線池已經達到上限了,將連線真實關閉
state.accumulatedCheckoutTime += conn.getCheckoutTime();
if (!conn.getRealConnection().getAutoCommit()) {
conn.getRealConnection().rollback();
}
//關閉真的資料庫連線
conn.getRealConnection().close();
if (log.isDebugEnabled()) {
log.debug("Closed connection " + conn.getRealHashCode() + ".");
}
//將連線物件設定為無效
conn.invalidate();
}
} else {
if (log.isDebugEnabled()) {
log.debug("A bad connection (" + conn.getRealHashCode() + ") attempted to return to the pool, discarding connection.");
}
state.badConnectionCount++;
}
}
}
回收連線的邏輯就比較簡單了,不過還是有幾個地方需要注意:首先從活躍連線池移除掉該連線,然後判斷是否是有效連線以及空閒連線池是否還有位置,如果是有效連線且空閒連線池還有位置的話,則需要基於當前回收連線的真實連線並建立新的連線放入到空閒連線中,然後喚醒等待的執行緒;如果沒有則直接關閉真實連線。這兩個分支都需要將回收的連線中未提交的事務回滾並將連線置為無效。如果本來就是無效連線則只需要記錄獲取無效連線的次數。
以上就是Mybatis資料來源以及連線池的實現原理,其中池化技術是非常重要的。
快取
快取的實現
Mybatis有一級快取和二級快取,一級快取是SqlSession級別的,只能存在於同一個SqlSession生命週期中;二級快取則是跨SqlSession,以namespace為單位的。但實際上Mybatis的二級快取非常雞肋,有可能出現髒讀的情況,一般不會使用。
但Mybatis對快取做了大量的擴充套件,提供了防止快取擊穿、快取清空策略、序列化、定時清空、日誌等功能,設計非常優雅,所以此處主要領略這一模組的設計思想。先來看看包的結構:
從上圖中我們可以看到,Mybatis提供了統一的快取介面,impl和decorators包中都是它的實現類,從包的名字我們可以想到快取這裡又是使用了一個設計模式——裝飾者模式,利用該模式動態得為快取新增功能。真正的實現就是impl包 下的PerpetualCache,通過HashMap來快取資料的(會不會出現併發安全問題?),key是CacheKey物件,value是快取的資料,為什麼key是CacheKey物件,而不是一個字串呢?讀者可以想想,怎樣才能確定不會讀取到錯誤的快取,這個類最後來分析。而decorators包下的都是進行功能增強的裝飾者類,這裡主要來看看BlockingCache是如何防止快取擊穿的。
public class BlockingCache implements Cache {
//阻塞的超時時長
private long timeout;
//被裝飾的底層物件,一般是PerpetualCache
private final Cache delegate;
//鎖物件集,粒度到key值
private final ConcurrentHashMap<Object, ReentrantLock> locks;
public BlockingCache(Cache delegate) {
this.delegate = delegate;
this.locks = new ConcurrentHashMap<>();
}
@Override
public void putObject(Object key, Object value) {
try {
delegate.putObject(key, value);
} finally {
releaseLock(key);
}
}
@Override
public Object getObject(Object key) {
acquireLock(key);//根據key獲得鎖物件,獲取鎖成功加鎖,獲取鎖失敗阻塞一段時間重試
Object value = delegate.getObject(key);
if (value != null) {//獲取資料成功的,要釋放鎖
releaseLock(key);
}
return value;
}
@Override
public Object removeObject(Object key) {
// despite of its name, this method is called only to release locks
releaseLock(key);
return null;
}
private ReentrantLock getLockForKey(Object key) {
ReentrantLock lock = new ReentrantLock();//建立鎖
ReentrantLock previous = locks.putIfAbsent(key, lock);//把新鎖新增到locks集合中,如果新增成功使用新鎖,如果新增失敗則使用locks集合中的鎖
return previous == null ? lock : previous;
}
//根據key獲得鎖物件,獲取鎖成功加鎖,獲取鎖失敗阻塞一段時間重試
private void acquireLock(Object key) {
//獲得鎖物件
Lock lock = getLockForKey(key);
if (timeout > 0) {//使用帶超時時間的鎖
try {
boolean acquired = lock.tryLock(timeout, TimeUnit.MILLISECONDS);
if (!acquired) {//如果超時丟擲異常
throw new CacheException("Couldn't get a lock in " + timeout + " for the key " + key + " at the cache " + delegate.getId());
}
} catch (InterruptedException e) {
throw new CacheException("Got interrupted while trying to acquire lock for key " + key, e);
}
} else {//使用不帶超時時間的鎖
lock.lock();
}
}
private void releaseLock(Object key) {
ReentrantLock lock = locks.get(key);
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
在呼叫getObject獲取資料時,首先呼叫acquireLock根據key獲取鎖,如果獲取到鎖,則從PerpetualCache快取中獲取資料,如果沒有則去資料庫查詢資料,返回結果後新增到快取中並釋放鎖,注意去資料庫查詢資料時是根據key加了鎖的,因此相同key只會有一個執行緒到達資料庫查詢,也就不會出現快取擊穿的問題,這個思路也可以用到我們的專案中去。
以上就是Mybatis解決快取擊穿的思路,另外再來看一個裝飾者SynchronizedCache,提供同步的功能,該裝飾器就是在對快取的增刪API上加上了synchronized關鍵字,這個裝飾器就是用來防止二級快取出現併發安全問題的,而一級快取根本不存在併發安全問題。其餘的裝飾者這裡就不贅述了,感興趣的讀者可自行分析。
CacheKey
因為Mybatis中存在動態SQL,所以快取的key沒法僅用一個字串來表示,所以通過CacheKey來封裝所有可能影響快取的因素,那麼哪些因素會影響到快取呢?
- namespace + id
- 查詢的sql
- 查詢的引數
- 分頁資訊
而在CacheKey中有以下屬性:
private static final int DEFAULT_MULTIPLYER = 37;
private static final int DEFAULT_HASHCODE = 17;
private final int multiplier; //參與hash計算的乘數
private int hashcode; //CacheKey的hash值,在update函式中實時運算出來的
private long checksum; //校驗和,hash值的和
private int count; //updateList的中元素個數
private List<Object> updateList; //該集合中的元素決定兩個CacheKey是否相等
其中updateList就是用來儲存所有可能影響快取的因素,其它幾個則是根據該屬性中的物件計算出來的值,每次構造CacheKey物件時都會呼叫update方法:
public void update(Object object) {
//獲取object的hash值
int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object);
//更新count、checksum以及hashcode的值
count++;
checksum += baseHashCode;
baseHashCode *= count;
hashcode = multiplier * hashcode + baseHashCode;
//將物件新增到updateList中
updateList.add(object);
}
而判斷兩個CacheKey物件是否相同則是通過equals方法:
public boolean equals(Object object) {
if (this == object) {//比較是不是同一個物件
return true;
}
if (!(object instanceof CacheKey)) {//是否型別相同
return false;
}
final CacheKey cacheKey = (CacheKey) object;
if (hashcode != cacheKey.hashcode) {//hashcode是否相同
return false;
}
if (checksum != cacheKey.checksum) {//checksum是否相同
return false;
}
if (count != cacheKey.count) {//count是否相同
return false;
}
//以上都不相同,才按順序比較updateList中元素的hash值是否一致
for (int i = 0; i < updateList.size(); i++) {
Object thisObject = updateList.get(i);
Object thatObject = cacheKey.updateList.get(i);
if (!ArrayUtil.equals(thisObject, thatObject)) {
return false;
}
}
return true;
}
可以看到這裡比較相等的方法是非常嚴格的,並且效率極高,我們在專案中重寫equals方法時也可以參照該方法的實現。
反射
反射是Mybatis的重中之重,通過反射Mybatis才能實現物件的例項化和屬性的賦值,並且Mybatis的反射是對JDK的封裝和增強,使其更易於使用,效能更高。其中關鍵的幾個類如下:
- ObjectFactory:通過該物件建立POJO類的例項。
- ReflectorFactory:建立Reflector的工廠類。
- Reflector:MyBatis反射模組的基礎,每個Reflector物件都對應一個類,在其中快取了反射操作所需要的類元資訊。
- ObjectWrapper:物件的包裝,抽象了物件的屬性資訊,他定義了一系列查詢物件屬性資訊的方法,以及更新屬性的方法。
- ObjectWrapperFactory:建立ObjectWrapper的工廠類。
- MetaObject:包含了原始物件、ObjectWrapper、ObjectFactory、ObjectWrapperFactory、ReflectorFactory的引用,通過該類可以進行核心反射類的所有操作,也是門面模式的實現。
由於該模組只是對JDK的封裝,雖然程式碼和類非常多,但並不是很複雜,這裡就不詳細闡述了。
總結
本篇講解了Mybatis最核心的四大模組,可以看到使用了大量的設計模式使得程式碼優雅簡潔,可讀性高,同時便於擴充套件,這也是我們在做專案時首先需要考慮的,程式碼都是給人讀的,如何降低閱讀程式碼的成本,提高程式碼的質量,減少BUG的數量,只有多學習優秀程式碼的設計思想才能提高我們自身的水平。