MyBatis執行初探
在閱讀一個專案的原始碼前,使用具有斷點除錯功能的開發軟體對專案程式碼進行一次追蹤是非常有必要的。這項工作將讓我們對整個專案的骨架脈絡有一個總體的認識,為之後的原始碼閱讀提供指引。
在追蹤的過程中要抓大放小,重點關注與專案核心功能相關的部分,忽略一些細枝末節的程式碼。在追蹤過程中也可以對程式碼的邏輯進行一些猜測,但不要對看不懂的程式碼過分糾結。
進行程式碼追蹤時,可以將整個過程大體記錄下來,作為原始碼閱讀的框架;也可以將遇到的問題記錄下來,等待原始碼閱讀時分析解答。
現在就開始對 MyBatis的原始碼進行一次除錯追蹤,從而瞭解 MyBatis原始碼的骨架脈絡。在這一次除錯追蹤中,我們不會使用第2 章中建立的專案。因為上述專案透過mybatis-spring-boot-starter包引入了 mybatis-spring、mybatis-spring-boot-autoconfigure等包,這會給除錯工作帶來干擾。因此,我們會盡量不依賴其他外部專案,而搭建一個純粹的MyBatis專案。
最終,搭建的專案除依賴 Spring Boot必需的 spring-boot-starter外,還依賴 mybatis和mysql-connector-java。該專案的依賴如程式碼3-1所示。
【程式碼3-1】
該專案需要手動建立 MyBatis配置檔案,如程式碼3-2所示。
【程式碼3-2】
此外,User類、UserMapper介面、對映檔案 UserMapper.xml均和第2章中建立的專案一致,並且 application.properties檔案中不需要任何配置,只留下一個空檔案即可。最終的專案檔案結構如圖3-1所示。
圖3-1 專案檔案結構
專案搭建完成後,在 Spring Boot的主函式中寫入操作邏輯,如程式碼3-3所示。執行後便可以透過資料庫查詢出圖3-2所示的程式執行結果。
【程式碼3-3】
圖3-2 程式執行結果
透過程式碼可以清晰地看出,MyBatis的操作主要分為兩大階段:
· 第一階段:MyBatis初始化階段。該階段用來完成 MyBatis執行環境的準備工作,只在 MyBatis啟動時執行一次。
· 第二階段:資料讀寫階段。該階段由資料讀寫操作觸發,將根據要求完成具體的增、刪、改、查等資料庫操作。
在進行 MyBatis的執行追蹤時,也按照上述兩個階段分別展開。
初始化階段追蹤MyBatis 的初始化會在整個專案啟動時開始執行,主要用來完成配置檔案的解析、資料庫的連線等工作。
靜態程式碼塊的執行
每個 Java類在被“首次主動使用”時都需要先進行類的載入。所謂的“首次主動使用”包括建立類的例項、訪問類或介面的靜態變數、被反射呼叫、初始化類的子類等。
類的載入就是 Java虛擬機器將描述類的資料從 Class檔案載入到 JVM的過程,在這一過程中會對 Class檔案進行資料載入、連線和初始化,最終形成可以被虛擬機器直接使用的 Java類。類的載入過程如圖3-3所示。
圖3-3 類的載入過程
而在圖3-3所示的初始化階段,會執行類的靜態程式碼塊。
靜態程式碼塊是類中一段由 static關鍵字標識的程式碼,它通常用來對類靜態變數進行初始化。例如,程式碼3-4所示的靜態程式碼塊位於 MyBatis的 Jdk類中,用來判斷當前環境中是否存在 java.lang.reflect.Parameter類,並根據結果初始化 parameterExists變數的值。
【程式碼3-4】
靜態程式碼塊會在類載入過程的初始化階段執行,並且只會執行一次。一個類中可以有多個靜態程式碼塊,它們會按照順序依次執行。MyBatis 中存在眾多的類,而這些類被“首次主動使用”的時間各不相同,因此不同類中的靜態程式碼塊的執行時機各不相同。但是,對於每一個類而言,類中的靜態程式碼塊都是這個類中首先被執行的程式碼。
因此,接下來系統呼叫任何一個類時,這個類的靜態程式碼塊必定已經執行完成。對於這一點,在後面的分析中不再單獨提及。
獲取InputStream
下面從主方法入手,來追蹤 MyBatis 的初始化過程。主方法中首先進行的是InputStream物件的獲取,如程式碼3-5所示。
【程式碼3-5】
在程式碼3-5中將配置檔案的路徑傳遞給了 Resource中的 getResourceAsStream方法。我們以此為入口透過單步執行的方式不斷追蹤程式碼。
最終,我們發現 ClassLoaderWrapper 中的 getResourceAsStream(String,ClassLoader[])方法根據配置檔案的路徑獲取到配置檔案的輸入流。程式碼3-6給出了該方法的原始碼。
【程式碼3-6】
程式碼3-6 所示方法的輸入引數除配置檔案的路徑外,還包括一組 ClassLoader。ClassLoader叫作類載入器,是負責載入類的物件。給定類的二進位制名稱,類載入器會嘗試定位或生成構成該類定義的資料。一般情況下,類載入器會將名稱轉換為檔名,然後從檔案系統中讀取該名稱的類檔案。因此,類載入器具有讀取外部資源的能力,這裡要藉助的正是類載入器的這種能力。
程式碼3-6 所示的 getResourceAsStream 方法會依次呼叫傳入的每一個類載入器的getResourceAsStream方法來嘗試獲取配置檔案的輸入流。在嘗試過程中如果失敗的話,會在傳入的地址前加上“/”再試一次。只要嘗試成功,即表明成功載入了指定的資源,會將所獲得的輸入流返回。
整個過程中涉及的 Resource類和 ClassLoaderWrapper類均在 MyBatis的 io包中,這也印證了 Resource類和 ClassLoaderWrapper類是負責讀寫外部檔案的。
配置資訊讀取
獲取 InputStream後,進行的是程式碼3-7所示的操作。
【程式碼3-7】
這一步首先建立了一個SqlSessionFactoryBuilder類的例項,然後呼叫了其build方法。build方法有多個,其中的核心方法如程式碼3-8所示。
【程式碼3-8】
整個方法中最核心的部分如程式碼3-9所示。
【程式碼3-9】
這兩句程式碼完成了兩步操作:
(1)生成了一個 XMLConfigBuilder 物件,並呼叫了其 parse 方法,得到一個Configuration物件(因為 parse方法的輸出結果為 Configuration物件)。
(2)呼叫了 SqlSessionFactoryBuilder 自身的 build 方法,傳入引數為上一步得到的Configuration物件。
我們對上述兩步操作分別進行追蹤。
首先找到 XMLConfigBuilder類的 parse方法,如程式碼3-10所示。
【程式碼3-10】
在程式碼3-10 中出現了“/configuration”字元。“/configuration”是整個配置檔案的根節點,因此這裡是解析整個配置檔案的入口。
而 parseConfiguration方法是解析配置檔案的起始方法,如程式碼3-11所示。
【程式碼3-11】
在程式碼3-11中,parseConfiguration方法依次解析了配置檔案 configuration節點下的各個子節點,包括關聯了所有的對映檔案的 mappers子節點。
進入每個子方法可以看出,解析出的相關資訊都放到了 Configuration類的例項中。因此 Configuration 類中儲存了配置檔案的所有設定資訊,也儲存了對映檔案的資訊。可見Configuration類是一個非常重要的類。
最終,XMLConfigBuilder物件的 parse方法返回了一個 Configuration物件。
透過 XMLConfigBuilder 物件的 parse 方法獲得了 Configuration 物件後,SqlSessionFactoryBuilder 自身的 build 方法接受 Configuration 物件為引數,返回了SqlSessionFactory物件。這樣主函式中“SqlSessionFactory sqlSessionFactory=new SqlSessionFactoryBuilder().build(inputStream)”這一句的解析就結束了。
總結
透過上面的追蹤,MyBatis 的初始化階段已經分析完畢。在初始化階段,MyBatis 主要進行了以下幾項工作。
· 根據配置檔案的位置,獲取它的輸入流 InputStream。
· 從配置檔案的根節點開始,逐層解析配置檔案,也包括相關的對映檔案。解析過程中不斷將解析結果放入 Configuration物件。
· 以配置好的 Configuration物件為引數,獲取一個 SqlSessionFactory物件。
資料讀寫階段追蹤在初始化階段結束之後,我們來對讀寫階段進行追蹤,初步探究當進行一次資料庫的讀或寫操作時,MyBatis內部都要經過哪些步驟。
獲得SqlSession
在初始化階段,我們已經獲得了 SqlSessionFactory,而資料庫操作過程中需要一個SqlSession物件。從類的名稱就可以看出,SqlSession是由 SqlSessionFactory生成的。在主方法中,由 SqlSessionFactory生成 SqlSession的過程如程式碼3-12所示。
【程式碼3-12】
我們追蹤 openSession 方法瞭解實現細節,在 DefaultSqlSessionFactory 中找到了openSessionFromDataSource方法,這是生成 SqlSession的核心原始碼,如程式碼3-13所示。
【程式碼3-13】
在程式碼3-13中,我們看到 Configuration物件中儲存的設定資訊被用來建立各種物件,包括事務工廠 TransactionFactory、執行器 Executor及預設的 DefaultSqlSession。
有一點需要注意,資料讀寫階段是在進行資料讀寫時觸發的,但並不是每次讀寫都會觸發“SqlSession session=sqlSessionFactory.openSession()”操作,因為該操作得到的SqlSession物件可以供多次資料庫讀寫操作複用。
對映介面檔案與對映檔案的繫結
在 1.5.2節我們已經介紹,對映介面檔案是指 UserMapper.class等存有介面的檔案,而對映檔案是指 UserMapper.xml等存有 SQL操作語句的檔案。最終,MyBatis將這兩類檔案一一對應了起來。
在進行資料查詢之前,主方法先透過程式碼3-14所示的語句找到 UserMapper介面對應的實現。
【程式碼3-14】
該操作透過 Configuration類的 getMapper方法轉接,最終進入 MapperRegistry類中的getMapper方法。MapperRegistry類中的 getMapper方法如程式碼3-15所示。
【程式碼3-15】
在程式碼3-15所示的原始碼中,getMapper方法透過對映介面資訊從所有已經解析的對映檔案中找到對應的對映檔案,然後根據該對映檔案組建並返回介面的一個實現物件。
對映介面的代理
我們已經知道“session.getMapper(UserMapper.class)”方法最終得到的是“mapperProxy Factory.newInstance(sqlSession)”返回的物件。那該物件到底是什麼呢?
我們追蹤“mapperProxyFactory.newInstance(sqlSession)”方法,可以在 MapperProxyFactory類中找到程式碼3-16所示的方法。
【程式碼3-16】
可見這裡返回的是一個基於反射的動態代理物件,因此我們直接找到 MapperProxy類的 invoke方法並在其中打上斷點。invoke方法的原始碼如程式碼3-17所示。
【程式碼3-17】
接下來主方法中程式碼3-18所示的操作會進入程式碼3-17所示的方法。
【程式碼3-18】
然後,會觸發 MapperMethod物件的 execute方法,該方法如程式碼3-19所示。
【程式碼3-19】
在程式碼3-19中,MyBatis根據不同資料庫操作型別呼叫了不同的處理方法。當前專案進行的是資料庫查詢操作,因此會觸發程式碼3-19中的“result=execute ForMany(sqlSession,args)”語句。executeForMany方法的原始碼如程式碼3-20所示。
【程式碼3-20】
在程式碼3-20 所示的 executeForMany 方法中,MyBatis 開始透過SqlSession 物件的selectList方法開展後續的查詢工作。
追蹤到這裡,MyBatis 已經完成了為對映介面注入實現的過程。於是,對對映介面中抽象方法的呼叫轉變為了資料查詢操作。
SQL語句的查詢
程式碼3-20所示的操作呼叫到了 DefaultSqlSession類中的 selectList方法,該方法的原始碼如程式碼3-21所示。
【程式碼3-21】
每個 MappedStatement 物件對應了我們設定的一個數據庫操作節點,它主要定義了資料庫操作語句、輸入/輸出引數等資訊。
程式碼3-21 中的“configuration.getMappedStatement(statement)”語句將要執行的MappedStatement物件從 Configuration物件儲存的對映檔案資訊中找了出來。
查詢結果快取
對應的資料庫操作節點被查詢到後,MyBatis 使用執行器開始執行語句。在程式碼3-21中可以看到程式碼3-22所示的觸發操作。
【程式碼3-22】
上述 query方法實際是一個 Executor介面中的抽象方法,如程式碼3-23所示。
【程式碼3-23】
該抽象方法有兩種實現,分別在BaseExecutor類和CachingExecutor類中。這時可以直接在抽象方法上打斷點,如圖3-4所示,檢視程式會跳轉到哪個實現方法上。
圖3-4 抽象方法的斷點
執行到斷點後可以發現,實際執行的是 CachingExecutor類中的程式碼3-24所示的方法。
【程式碼3-24】
BoundSql是經過層層轉化後去除掉 if、where等標籤的 SQL語句,而 CacheKey是為該次查詢操作計算出來的快取鍵。接下來流程會走到程式碼3-25所示的函式。
【程式碼3-25】
在程式碼3-25中,MyBatis檢視當前的查詢操作是否命中快取。如果是,則從快取中獲取資料結果;否則,便透過 delegate呼叫 query方法。
資料庫查詢
3.2.5節中 delegate呼叫的 query方法再次呼叫了一個 Executor介面中的抽象方法,如程式碼3-26所示。我們同樣在該抽象方法上打斷點以追蹤程式的實際流向。
【程式碼3-26】
我們發現,程式停留在了 BaseExecutor類中的 query方法上。該方法如程式碼3-27所示。
【程式碼3-27】
上述方法邏輯判斷較多,相對複雜,我們不去深究。其中的關鍵操作如程式碼3-28所示。這表明 MyBatis開始呼叫資料庫展開查詢操作。
【程式碼3-28】
queryFromDatabase方法的原始碼如程式碼3-29所示。
【程式碼3-29】
透過程式碼3-29可以看出,MyBatis先在快取中放置一個佔位符,然後呼叫 doQuery方法實際執行查詢操作。最後,又把快取中的佔位符替換成真正的查詢結果。
doQuery方法是 BaseExecutor類中的抽象方法,實際執行的最終實現如程式碼3-30所示。
【程式碼3-30】
上述方法生成了Statement物件stmt。Statement類並不是MyBatis中的類,而是java.sql包中的類。Statement類能夠執行靜態 SQL語句並返回結果。
程式還透過 Configuration的 newStatementHandler方法獲得了一個 StatementHandler物件 handler,然後將查詢操作交給 StatementHandler物件進行。StatementHandler是一個語句處理器類,其中封裝了很多語句操作方法,這裡先不細究。繼續追蹤“handler.<E>query(stmt,resultHandler)”語句。
“handler.<E>query(stmt,resultHandler)”呼叫的是 StatementHandler介面中如程式碼3-31所示的抽象方法。
【程式碼3-31】
我們在程式碼3-31 所示的抽象方法上打斷點,經過多次跳轉後,程式執行到了PreparedStatementHandler類中的程式碼3-32所示的方法中。
【程式碼3-32】
這裡 ps.execute()真正執行了 SQL 語句,然後把執行結果交給ResultHandler 物件處理。而PreparedStatement類並不是MyBatis中的類,因而ps.execute()的執行不再由MyBatis負責,而是由 com.mysql.cj.jdbc包中的類負責,這裡不再繼續追蹤。
查詢完成之後的結果放在 PreparedStatement物件中,透過除錯工具可以看到其中包含了這次查詢得到的資料庫欄位資訊、資料記錄資訊等,如圖3-5所示。
圖3-5 PreparedStatement物件中的資訊
這一步資料庫查詢操作涉及的方法較多。整個流程的關鍵步驟如下。
· 在進行資料庫查詢前,先查詢快取;如果確實需要查詢資料庫,則資料庫查詢之後的結果也放入快取中。
· SQL 語句的執行經過了層層轉化,依次經過了 MappedStatement 物件、Statement物件和 PreparedStatement物件,最後才得以執行。
· 最終資料庫查詢得到的結果交給 ResultHandler物件處理。
處理結果集
查詢得到的結果並沒有直接返回,而是交給 ResultHandler物件處理。ResultHandler是結果處理器,用來接收此次查詢結果的方法是該介面中的抽象方法 handleResultSets,如程式碼3-33所示。
【程式碼3-33】
最終實際執行的方法是 DefaultResultSetHandler中程式碼3-34所示的方法。
在上述方法中,查詢出來的結果被遍歷後放入了列表multipleResults 中並返回。multipleResults中儲存的就是這次查詢期望的結果 List<User>。
在結果處理中,我們最關心的是 MyBatis如何將資料庫輸出的記錄轉化為物件列表,因此詳細追蹤這個過程。然而整個過程非常長,在 DefaultResultSetHandler 的方法中進行了多次跳轉,這裡直接給出整個方法的呼叫鏈路,如圖3-6所示。
圖3-6 方法的呼叫鏈路
其中重點關注的是圖3-6中粗線邊框標註的三個方法。
· createResultObject(ResultSetWrapper,ResultMap,List<Class<?>>,List<Object>,String)方法:該方法建立了輸出結果物件。在示例中,為 User物件。
· applyAutomaticMappings 方法:在自動屬性對映功能開啟的情況下,該方法將資料記錄的值賦給輸出結果物件。
· applyPropertyMappings方法:該方法按照使用者的對映設定,給輸出結果物件的屬性賦值。
其中,createResultObject(ResultSetWrapper,ResultMap,List<Class<?>>,List<Object>,String)方法的原始碼如程式碼3-35所示,該方法根據輸出物件的不同,使用型別處理器或透過呼叫構造方法等方式建立輸出結果物件。
【程式碼3-35】
applyAutomaticMappings方法和 applyPropertyMappings方法的實現邏輯類似,以程式碼3-36所示的 applyAutomaticMappings方法為例進行介紹。
【程式碼3-36】
其基本思路就是迴圈遍歷每個屬性,然後呼叫“metaObject.setValue(mapping.property,value)”語句為屬性賦值。
經過以上過程,MyBatis將資料庫輸出的記錄轉化為了物件列表。
之後,以上方法逐級返回。最後,裝載著物件列表的 multipleResults 被返回給“List<User> userList”變數,我們便拿到了查詢結果。追蹤到這裡,主方法中程式碼3-37所示的語句終於執行完成了。【程式碼3-37】
總結
在整個資料庫操作階段,MyBatis完成的工作可以概述為以下幾條。
· 建立連線資料庫的 SqlSession。
· 查詢當前對映介面中抽象方法對應的資料庫操作節點,根據該節點生成介面的實現。
· 介面的實現攔截對對映介面中抽象方法的呼叫,並將其轉化為資料查詢操作。
· 對資料庫操作節點中的資料庫操作語句進行多次處理,最終得到標準的 SQL語句。
· 嘗試從快取中查詢操作結果,如果找到則返回;如果找不到則繼續從資料庫中查詢。
· 從資料庫中查詢結果。
· 處理結果集。
-建立輸出物件;
-根據輸出結果對輸出物件的屬性賦值。
· 在快取中記錄查詢結果。· 返回查詢結果。
透過以上步驟可以看出,MyBatis完成一次資料庫操作的過程還是十分複雜的。因此,平時的軟體開發過程中要儘量減少資料庫操作,這樣能極大地提高軟體執行的效率。
終於,我們完成了 MyBatis原始碼的一次執行追蹤。整個追蹤過程中可能會有一些點讓我們恍然大悟,但有更多的點讓我們感到迷茫。這種情況是正常的,因為這只是一次構建整個專案框架脈絡的初步探索。接下來,將以包為單位詳細閱讀 MyBatis的原始碼,在此過程中,這些迷茫會漸漸消失。