半年前入職新公司以來,一直參與一個維護型的大型JAVA項目。項目是一個交易系統,基於一個非常老的框架搭建的(大概98年的一個框架),框架本身的設計思想不錯,沒有應用任何開源框架,簡單明瞭。但是公司平時在設計和代碼質量把控方面做得不夠到位,導致現在代碼的質量可以說是高偶合、低類聚的典型,隨便改點什麼都無法簡單評估影響範圍,大部分需求變更都不得不把業務和開發人員集中起來,開個大半天的會議討論。經過無數次的吐槽與溝通,終於說服了領導,來次翻天覆地的重構。
寫這篇博客的目的並不是關於重構的,而是在重構完成後遇到的技術問題,在此記錄下來與大家分享。
首先,不得不先簡單介紹下項目的情況,項目是一個交易系統,技術上很簡單,就是一個servlet,對外提供接口。外部調用方封裝報文參數以http方式請求,系統內部解析報文,然後交由一個個的step依次處理,最後返回結果。就這麼簡單,大致流程如下圖所示:
調用方的一次請求我們稱之爲一次交易,每個交易根據報文參數來決定使用哪些Step來完成不同的業務。Parser負責解析外部調用方傳入的參數;每個Step相當於一個獨立的、可複用的業務模塊。比如創建一個用戶,Step1負責創建用戶的基本信息,Step2負責創建用戶的安全信息,其它Step可以負責創建會員的資產信息等或者做一些權限驗證、日誌記錄等,這樣的設計初衷是爲了複用Step;Wrapper把內部處理封裝成結果返回給調用方。整個過程很容易用一段簡單代碼來表示:
public void process() {
long start = System.currentTimeMillis();
Connection conn = null;
try {
conn = ds.getConnection();
processStep1(conn);
processStep2(conn);
...
processStepN(conn);
long end = System.currentTimeMillis();
if (end - start > limit) {
conn.rollback();
return;
}
conn.commit();
} catch (SQLException e) {
if (null != conn)
conn.rollback();
} finally {
if (null != conn) conn.close();
}
}
從代碼上看,框架確實很簡單,一開始從數據源拿到一個DB Connection,然後把Connection傳入一個個的Step(Step內部直接使用這個Connection對象操作數據庫)順序處理,最後判斷是否超時來決定是否提交事務。既然這麼簡單了,爲什麼還要重構呢?那是因爲業務都在這些Step裏面,有大量的Step,而且裏面的代碼...這裏省略1000字。代碼質量是重構一原因之一,最主要的是目前項目的代碼風格是完全面向過程式的,沒有一丁點的面向對象的感覺,維護的成本實在太高,所以重構是採用的是Domain-Driven Design這種純面向對象的設計方式進行的,關於DDD大家可以自行搜索一下相關的文章,網上不少。
因爲是一個大型項目,所以不可能一下子完全重構,而是一個模塊一個模塊、循序漸進地重構,還需要考慮新舊代碼的兼容性,所以第一輪重構後的代碼大致如下:
try {
conn = ds.getConnection();
processStep1(conn);
processStep2(conn);
...
//重構後某個Step變成了
Factory factory = ContextLoader
.getCurrentWebApplicationContext().getBean("factory", Factory.class);
DomainObject doObj = factory.create..();
doObj.doSomething();
processStepN(conn);
long end = System.currentTimeMillis();
if (end - start > limit) {
conn.rollback();
return;
}
conn.commit();
}
上面只是重構了某個Step,但完成整個交易的功能,必須兼容原來的代碼。從代碼可以看出,這裏我們使用了spring(持久層使用了spring jdbc),從Context中取得工廠,通過工廠創建一個領域對象doObj,之後就可以使用領域對象去實現具體的業務了。等全部的Step都重構完成後,就可以完全丟掉現有的框架設計了。
不知道大家有沒有看出問題,我們有個超時的判斷,超過響應時間是要回滾的。但是使用spring jdbc我們一般只是聲明一個DataSource,它自己通過data source去獲取connection,這樣spring jdbc使用的connection與我們代碼中使用的connection肯定不是同一個對象,這樣代碼中的connection就無法控制doObj對數據庫的提交與回滾了,起碼超時回滾這個業務就肯定會出問題了。
擺在面前的只有兩條路:
- 把某個交易中所涉及的全部Step都重構了,這個交易中完全去掉step,不再代碼中維護connection,而是把事務的控制交給spring。這個做法可行,而且也是重構的最終目標,但是時間有限,在短時間內重構完一個交易涉及的全部Step,工作量實在不小,而且測試風險也很大。
- 想辦法解決
各方面的壓力,使得我沒有選擇,只能想辦法解決。代碼中的connection相當於new出來的一個對象,而spring中的DataSource又是一個singleton對象,這還不是一個Ioc的問題,因爲spring jdbc需要的是一個DataSource,而我們只有Connection,所以我們必須實現一個新的DataSource,並且覆蓋它的getConnection方法使它返回我們的代碼中new出來的Connection對象。DataSource與我們的框架代碼之間好像沒有任何關係,怎麼能才讓它返回我們new出來的Connection呢?感謝我的同事老朱,討論中他提到了ThreadLocal類,是啊,一次交易過程就是一個線程的一次執行過程,我們可以把Connection保存在ThreadLocal對象中去。
關於ThreadLocal類,簡單說就是通過ThreadLocal,可以在同一程線內(不同的方法、代碼段等任何地方)共享信息。
首先,創建一個類,通過ThreadLocal用來存取Connection對象。
public class ConnThreadLocal {
private static final ThreadLocal<Connection> local
= new ThreadLocal<Connection>();
public static void addConn(Connection conn) {
local.set(conn);
}
public static Connection getConn() {
return local.get();
}
}
接着,修改框架代碼,在獲得Connection對象後,保存到ThreadLocal中去。
try {
conn = ds.getConnection();
ConnThreadLocal.addConn(conn); //把Connection對象保存到ThreadLocal中
processStep1(conn);
processStep2(conn);
Factory factory = ContextLoader
.getCurrentWebApplicationContext().getBean("factory", Factory.class);
DomainObject doObj = factory.create..();
doObj.doSomething();
processStepN(conn);
long end = System.currentTimeMillis();
if (end - start > limit) {
conn.rollback();
return;
}
conn.commit();
}
最後,再實現一個新的DataSource,並且讓spring jdbc使用這個新的DataSource我們的問題就解決啦!!!
public class CustomDataSource extends AbstractDataSource {
@Override
public Connection getConnection() throws SQLException {
return ConnThreadLocal.getConn();
}
@Override
public Connection getConnection(String username, String password)
throws SQLException {
return this.getConnection();
}
}
至此,一切就緒,上環境,測試。Duang.....出錯啦,Connection is closed....,這個錯誤出現在doObj.doSomething方法中第二次數據庫訪問,怎麼會這樣呢?網上查閱了一翻,原來spring jdbc在每次數據庫訪問之後,都會調用Connection的close方法把Connection還給連接池,之後可以再通過連接池獲取可用的連接。設計上非常合理,但是苦了我哎,只能想辦法讓CustomDataSource返回的鏈接不能被spring jdbc關閉,爲此,需要一個新的Connection類:
public class CustomConnection implements java.sql.Connection {
private java.sql.Connection conn;
public CustomConnection(java.sql.Connection conn) {
this.conn = conn;
}
@Override
public Statement createStatement() throws SQLException {
return conn.createStatement();
}
@Override
public void close() throws SQLException {
//覆蓋close方法,不讓spring關閉
}
}
CustomConnection類內部維護一個真正可用的Connection對象,除了close方法,所有其它方法都委託這個對象去做。再修改CustomDataSource,使之返回CustomConnection對象。
public class CustomDataSource extends AbstractDataSource {
@Override
public Connection getConnection() throws SQLException {
return new CustomConnection(ConnThreadLocal.getConn());
}
@Override
public Connection getConnection(String username, String password)
throws SQLException {
return this.getConnection();
}
}
這樣一來,spring jdbc獲取的Connection對象就是我們框架代碼裏面的那個Connection對象了,並且spring jdbc關閉不了,至此,一切問題解決。