簡介:如何在陳舊的代碼庫中找出隱藏的設計?本文討論兩種對於代碼結構很重要的模式:組合方法 和單一抽象層。對代碼應用這些原則有助於找到以前隱藏的可重用資產,有助於把現有的代碼抽象為成熟的框架。
在這個 系列 的前兩期中,我討論了如何使用測試驅動開發 (TDD) 幫助您逐步發現設計。如果從頭開始一個新項目,這種方法的效果非常 好。但是,更常見的情況是您手中已經有許多並不完善的代碼,在這種情況下應該怎麼辦呢?如何在陳舊的代碼庫中找出可重用的資產和隱藏 的設計?
本文討論兩個很成熟的模式,它們可以幫助您重構代碼,尋找可重用的資產:組合方法 和單一抽象層 (SLAP) 原則。良好設計的元素已經 在您的代碼中存在了;您只需通過工具找出已經創建的隱藏的資產。
組合方法
科技的變化速度非常快,這有一種糟糕的副作用:開發人員常常會忽視軟件知識。我們往往會認為幾年前的東西一定已經過時了。這當然是 不對的:許多老書仍然能夠提供對於開發人員很重要的知識。這樣的經典著作之一是 Kent Beck 所著的 Smalltalk Best Practice Patterns 。作為 Java 開發人員,您可能會問,“13 年前的 Smalltalk 書對我有什麼用呢?” Smalltalk 開發人員是第一批用面向對象語言編寫程序 的開發人員,他們首創了許多出色的思想。其中之一就是組合方法。
組合方法模式有三條關鍵規則:
把程序劃分為方法,每個方法執行一個可識別的任務。
讓一個方法中的所有操作處於相同的抽象層。
這會自然地產生包含許多小方法的程序,每個方法只包含少量代碼。
在 “測試驅動設計,第 1 部分” 中,我在討論在編寫實際代碼之前編寫單元測試時討論過組合方法。嚴格遵守 TDD 會自然地產生符合組 合方法模式的方法。但是,對於現有的代碼,應該怎麼辦呢?現在,我們來研究如何使用組合方法發現隱藏的設計。
慣用模式
您可能很熟悉正式的設計模式運動,這一運動起源於 Gang of Four 所著的 Design Patterns。它描述了應用於所有項目的通用模式。但是 ,每個解決方案還包含慣用模式,這些模式不夠正式,但是得到了普遍應用。慣用模式代表代碼中常用的設計習慣。緊急設計的真正訣竅就是 發現這些模式。它們包括從純技術模式(例如這個項目中處理事務的方式)到問題領域模式(比如 “在發貨之前總是要檢查客戶的信用”)的 各種模式。
重構成組合方法
請考慮清單 1 中的簡單方法。它使用低層 JDBC 連接一個數據庫,收集 Part 對象,把它們放在一個 List 中:
清單 1. 用於收集 Part 的簡單方法
public void populate() throws Exception {
Connection c = null;
try {
Class.forName(DRIVER_CLASS);
c = DriverManager.getConnection(DB_URL, USER, PASSWORD);
Statement stmt = c.createStatement();
ResultSet rs = stmt.executeQuery(SQL_SELECT_PARTS);
while (rs.next()) {
Part p = new Part();
p.setName(rs.getString("name"));
p.setBrand(rs.getString("brand"));
p.setRetailPrice(rs.getDouble("retail_price"));
partList.add(p);
}
} finally {
c.close();
}
}
混雜的方法
混雜(Olio) 是指 “不同類型的東西的集合”,也就是俗話所說的 “大雜燴”。(這個詞經常出現在填字游戲中)。混雜的方法 是包含 各種功能的大型方法,涉及問題領域的各個方面。根據定義,代碼庫中達到 300 行的方法就是混雜的方法。這麼大的方法怎麼可能是內聚的呢 ?混雜的方法是阻礙重構、測試和緊急設計的主要因素之一。
清單 1 不包含任何特別復雜的東西。但是,它也不包含明顯的可重用代碼。盡管它相當短,但是仍然應該重構。組合方法模式指出,每個 方法應該只做一件事,這個方法違反了此規則。我認為,在 Java 項目中任何超過 10 行代碼的方法都應該考慮重構,因為它很可能做多件事 。因此,我將根據組合方法模式重構這個方法,看看是否可以分離出原子性部分。重構的版本見清單 2:
清單 2. 重構的 populate() 方法
public void populate() throws Exception {
Connection c = null;
try {
c = getDatabaseConnection();
ResultSet rs = createResultSet(c);
while (rs.next())
addPartToListFromResultSet(rs);
} finally {
c.close();
}
}
private ResultSet createResultSet(Connection c)
throws SQLException {
return c.createStatement().
executeQuery(SQL_SELECT_PARTS);
}
private Connection getDatabaseConnection()
throws ClassNotFoundException, SQLException {
Connection c;
Class.forName(DRIVER_CLASS);
c = DriverManager.getConnection(DB_URL,
"webuser", "webpass");
return c;
}
private void addPartToListFromResultSet(ResultSet rs)
throws SQLException {
Part p = new Part();
p.setName(rs.getString("name"));
p.setBrand(rs.getString("brand"));
p.setRetailPrice(rs.getDouble("retail_price"));
partList.add(p);
}
populate() 方法現在短多了,看起來像是它需要執行的任務的大綱,任務的實現都放在私有方法中。把所有原子性部分分離出來之後,就 可以看出我實際上擁有哪些資產了。注意,getDatabaseConnection() 方法沒有對 parts 做任何操作 — 它只提供通用的數據庫連接功能。這 說明這個方法不應該放在這個類中,所以我要把它重構到 PartDb 類的父類 BoundaryBase 中。
清單 2 中是否還有其他方法是通用的,能夠放到父類中?createResultSet() 方法看起來相當通用,但是它包含 parts 的鏈接,即 SQL_SELECT_PARTS 常量。如果能夠迫使子類 (PartDb) 把這個 SQL 字符串的值告訴父類,就可以把這個方法提升到父類中。這正是抽象方法 的作用。因此,我把 createResultSet() 提升到 BoundaryBase 類中,並聲明一個名為 getSqlForEntity() 的抽象方法,見清單 3:
清單 3. 目前的 BoundaryBase 類
abstract public class BoundaryBase {
private static final String DRIVER_CLASS =
"com.mysql.jdbc.Driver";
private static final String DB_URL =
"jdbc:mysql://localhost/orderentry";
protected Connection getDatabaseConnection() throws ClassNotFoundException,
SQLException {
Connection c;
Class.forName(DRIVER_CLASS);
c = DriverManager.getConnection(DB_URL, "webuser", "webpass");
return c;
}
abstract protected String getSqlForEntity();
protected ResultSet createResultSet(Connection c) throws SQLException {
Statement stmt = c.createStatement();
return stmt.executeQuery(getSqlForEntity());
}
這很有意思。是否還能把更多的方法從子類提升到通用的父類中?如果看一下 清單 2 中的 populate() 方法本身,可以看出它與 PartDb 類的連接點是 getDatabaseConnection()、createResultSet() 和 addPartToListFromResultSet() 方法。前兩個方法已經轉移到父類中了。 如果對 addPartToListFromResultSet() 方法進行抽象(並使用適當的更通用的名稱),就可以把整個 populate() 方法放到父類中,見清單 4:
清單 4. BoundaryBase 類
abstract public class BoundaryBase {
private static final String DRIVER_CLASS =
"com.mysql.jdbc.Driver";
private static final String DB_URL =
"jdbc:mysql://localhost/orderentry";
protected Connection getDatabaseConnection() throws ClassNotFoundException,
SQLException {
Connection c;
Class.forName(DRIVER_CLASS);
c = DriverManager.getConnection(DB_URL, "webuser", "webpass");
return c;
}
abstract protected String getSqlForEntity();
protected ResultSet createResultSet(Connection c) throws SQLException {
Statement stmt = c.createStatement();
return stmt.executeQuery(getSqlForEntity());
}
abstract protected void addEntityToListFromResultSet(ResultSet rs)
throws SQLException;
public void populate() throws Exception {
Connection c = null;
try {
c = getDatabaseConnection();
ResultSet rs = createResultSet(c);
while (rs.next())
addEntityToListFromResultSet(rs);
} finally {
c.close();
}
}
}
把這些方法提升到父類中之後,PartDb 類已經大大簡化了,見清單 5:
清單 5. 簡化和重構後的 PartDb 類
public class PartDb extends BoundaryBase {
private static final int DEFAULT_INITIAL_LIST_SIZE = 40;
private static final String SQL_SELECT_PARTS =
"select name, brand, retail_price from parts";
private static final Part[] TEMPLATE = new Part[0];
private ArrayList partList;
public PartDb() {
partList = new ArrayList(DEFAULT_INITIAL_LIST_SIZE);
}
public Part[] getParts() {
return (Part[]) partList.toArray(TEMPLATE);
}
protected String getSqlForEntity() {
return SQL_SELECT_PARTS;
}
protected void addEntityToListFromResultSet(ResultSet rs)
throws SQLException {
Part p = new Part();
p.setName(rs.getString("name"));
p.setBrand(rs.getString("brand"));
p.setRetailPrice(rs.getDouble("retail_price"));
partList.add(p);
}
}
我通過前面的重構得到了什麼?首先,與以前相比,現在的這兩個類更集中於自己的任務。這兩個類中的所有方法都很簡潔,很容易理解。 第二,PartDb 類專門處理 parts,不涉及其他東西。所有通用的連接代碼都已經轉移到父類中了。第三,所有方法現在都是可測試的:每個方 法(除了 populate())都只做一件事。populate() 方法是這些類的實際工作流方法。它使用所有其他(私有)方法執行工作,看起來像是執 行的步驟的大綱。第四,現在有了小的構建塊,可以組合使用它們,所以方法重用變得更容易了。對於原來的 populate() 方法那樣的大型方 法,重用的機會很少:在其他類中,幾乎不可能需要以完全相同的次序做相同的事情。實現原子性方法使我們能夠組合使用功能。
提煉的框架與提前設計的框架
最好的框架往往是從現有的代碼中提煉出來的,而不是預先設計的。關起門來設計框架的人必須預測到開發人員希望使用框架的所有方式。 框架最終會包含大量特性,而用戶很可能並不使用所有特性。但是,您仍然必須考慮到所選框架中不使用的特性,因為它們會增加應用程序的 復雜性;這可能影響不大,比如只需在配置文件中添加額外的條目,也可能影響很大,比如改變實現某一特性的方式。提前設計的框架往往包 含大量特性,同時忽略了沒有預測到的其他特性。JavaServer Faces (JSF) 就是提前設計的框架的典型例子。它最酷的特性之一是能夠插入不 同的顯示管道,從而輸出 HTML 之外的其他格式。盡管很少使用這個特性,但是所有 JSF 用戶必須了解它對 JSF 請求的生命周期的影響。
從現有應用程序中發展出來的框架往往提供更實用的特性集,因為它們解決開發人員在編寫應用程序時面對的實際問題。在提煉的框架中, 多余的特性往往更少。提煉的框架的例子是 Ruby on Rails,它是從實踐中發展出來的。
這些重構工作真正重要的益處是得到了可重用的代碼。在 清單 1 中,看不到可重用的資產;只看到一大堆代碼。通過把混雜的方法分隔開 ,我發現了可重用的資產。但是,除了重用,還有其他好處。我還為在應用程序中處理持久性的簡單框架建立了基礎。在需要通過創建另一個 邊界類從數據庫中收集其他實體時,我已經有了基本框架。這就是 提煉 框架的過程,而不是關起門來設計框架。
獲得可重用的資產之後,應用程序的總體設計就會透過組成它的代碼逐漸顯現出來了。緊急設計的目標之一是找到應用程序中使用的慣用模 式。BoundaryBase 和 PartDb 的組合構成一個在此應用程序中反復出現的模式。把代碼分隔為小塊之後,就很容易看出各個部分是如何協作的 。
SLAP
組合方法的定義的第二部分指出,您應該 “讓一個方法中的所有操作處於相同的抽象層”。下面給出一個應用此原則的示例,幫助您理解 它的意義以及它對設計的影響。
請考慮清單 6 中的代碼,這些代碼取自一個小型電子商務應用程序。addOrder() 方法接受幾個參數並把訂單信息存儲進數據庫中。
清單 6. 取自某電子商務站點的 addOrder() 方法
public void addOrder(ShoppingCart cart, String userName,
Order order) throws SQLException {
Connection c = null;
PreparedStatement ps = null;
Statement s = null;
ResultSet rs = null;
boolean transactionState = false;
try {
s = c.createStatement();
transactionState = c.getAutoCommit();
int userKey = getUserKey(userName, c, ps, rs);
c.setAutoCommit(false);
addSingleOrder(order, c, ps, userKey);
int orderKey = getOrderKey(s, rs);
addLineItems(cart, c, orderKey);
c.commit();
order.setOrderKeyFrom(orderKey);
} catch (SQLException sqlx) {
s = c.createStatement();
c.rollback();
throw sqlx;
} finally {
try {
c.setAutoCommit(transactionState);
dbPool.release(c);
if (s != null)
s.close();
if (ps != null)
ps.close();
if (rs != null)
rs.close();
} catch (SQLException ignored) {
}
}
}
addOrder() 方法中有許多雜亂的東西。但是,我感興趣的是接近 try 塊開頭的工作流。請注意下面這兩行:
c.setAutoCommit(false);
addSingleOrder(order, c, ps, userKey);
這兩行代碼違反了 SLAP 原則。第一行(和它上面的方法)處理設置數據庫基礎結構的低層細節。第二行是高層的訂單方法,業務分析師會 理解它。這兩行屬於兩個不同的領域。如果必須在抽象層之間轉移,閱讀代碼會很困難,這正是 SLAP 原則試圖避免的情況。可讀性問題導致 難以理解代碼的底層設計,因此難以分離這個應用程序中的慣用模式。
為了改進 清單 6 中的代碼,我要根據 SLAP 原則重構它。在經過兩輪提取方法 重構之後,得到了清單 7 中的代碼:
清單 7. 改進抽象後的 addOrder() 方法
public void addOrderFrom(ShoppingCart cart, String userName,
Order order) throws SQLException {
setupDataInfrastructure();
try {
add(order, userKeyBasedOn(userName));
addLineItemsFrom(cart, order.getOrderKey());
completeTransaction();
} catch (SQLException sqlx) {
rollbackTransaction();
throw sqlx;
} finally {
cleanUp();
}
}
private void setupDataInfrastructure() throws SQLException {
_db = new HashMap();
Connection c = dbPool.getConnection();
_db.put("connection", c);
_db.put("transaction state",
Boolean.valueOf(setupTransactionStateFor(c)));
}
private void cleanUp() throws SQLException {
Connection connection = (Connection) _db.get("connection");
boolean transactionState = ((Boolean)
_db.get("transation state")).booleanValue();
Statement s = (Statement) _db.get("statement");
PreparedStatement ps = (PreparedStatement)
_db.get("prepared statement");
ResultSet rs = (ResultSet) _db.get("result set");
connection.setAutoCommit(transactionState);
dbPool.release(connection);
if (s != null)
s.close();
if (ps != null)
ps.close();
if (rs != null)
rs.close();
}
private void rollbackTransaction()
throws SQLException {
((Connection) _db.get("connection")).rollback();
}
private void completeTransaction()
throws SQLException {
((Connection) _db.get("connection")).commit();
}
private boolean setupTransactionStateFor(Connection c)
throws SQLException {
boolean transactionState = c.getAutoCommit();
c.setAutoCommit(false);
return transactionState;
}
現在,方法的可讀性好多了。它的主體符合組合方法的目標:看起來像是執行的步驟的大綱。方法現在處於高層,甚至非技術用戶差不多也 能夠理解方法的作用。如果仔細看看 completeTransaction() 方法,會發現它只有一行代碼。難道不能把這一行代碼放回 addOrder() 方法中 嗎?不行,這會損害代碼的可讀性和抽象層。從高層的訂單業務工作流跳到事務的細節是違反 SLAP 原則的。建立 completeTransaction() 方 法能夠使代碼更概念化,避免具體的細節。如果以後要改變訪問數據庫的方式,只需修改 completeTransaction() 方法的內容,而不必修改調 用代碼。
SLAP 原則的目標是使代碼更容易閱讀和理解。但是,它也有助於發現代碼中存在的慣用模式。注意,在用事務塊保護更新方面有一個慣用 模式。可以進一步重構 addOrder() 方法,見清單 8:
清單 8. 事務性訪問模式
public void wrapInTransaction(Command c) throws SQLException {
setupDataInfrastructure();
try {
c.execute();
completeTransaction();
} catch (RuntimeException ex) {
rollbackTransaction();
throw ex;
} finally {
cleanUp();
}
}
public void addOrderFrom(final ShoppingCart cart, final String userName,
final Order order) throws SQLException {
wrapInTransaction(new Command() {
public void execute() throws SQLException{
add(order, userKeyBasedOn(userName));
addLineItemsFrom(cart, order.getOrderKey());
}
});
}
我添加了一個 wrapInTransaction() 方法,它使用 Gang of Four 提出的 Command Design 模式的內聯版本實現這個常用模式。 wrapInTransaction() 方法執行讓代碼正常工作所需的所有具體工作。考慮到包裝這個方法的匿名內部類的真正用途,我留下了幾行難看的樣 板代碼 — addOrderFrom() 方法體中的兩行代碼。這個資源保護塊會在代碼中重復出現,所以應該考慮把它提升到層次結構中的更高位置。
我使用匿名內部類實現 wrapInTransaction() 方法是為了說明語言語法的表達能力的重要性。如果用 Groovy 編寫這段代碼,那麼可以使 用閉包塊創建更漂亮的版本,見清單 9:
清單 9. 使用 Groovy 閉包包裝事務性訪問
public class OrderDbClosure {
def wrapInTransaction(command) {
setupDataInfrastructure()
try {
command()
completeTransaction()
} catch (RuntimeException ex) {
rollbackTransaction()
throw ex
} finally {
cleanUp()
}
}
def addOrderFrom(cart, userName, order) {
wrapInTransaction {
add order, userKeyBasedOn(userName)
addLineItemsFrom cart, order.getOrderKey()
}
}
}
Groovy 的高級語言語法和特性讓我們能夠編寫出可讀性更好的代碼,在與組合方法和 SLAP 等補充技術相結合時尤其如此。
結束語
在本期文章中,我討論了對於代碼設計和可讀性很重要的兩個模式。在改進現有代碼的糟糕設計時,第一步是把它改造成您能應付的東西。 從設計或重用的角度來看,長達 300 行的方法是沒用的,因為無法把注意力集中在重要的部分上。通過把它重構為原子性的小塊,就能夠看出 您擁有哪些資產。清楚地了解這些資產之後,就可以找到可重用的部分並應用慣用設計原則。
在下期文章中,我將在組合方法和 SLAP 原則的基礎上討論重構如何推進設計。我將討論如何發現代碼庫中隱藏的設計。