级别: 中级 Neal Ford, 软件架构师/Meme Wrangler, ThoughtWorks Inc.
2009 年 6 月 12 日 如何在陈旧的代码库中找出隐藏的设计?本文讨论两种对于代码结构很重要的模式:组合方法 和单一抽象层。对代码应用这些原则有助于找到以前隐藏的可重用资产,有助于把现有的代码抽象为成熟的框架。
在这个 系列 的前两期中,我讨论了如何使用测试驱动开发 (TDD) 帮助您逐步发现设计。如果从头开始一个新项目,这种方法的效果非常好。但是,更常见的情况是您手中已经有许多并不完善的代码,在这种情况下应该怎么办呢?如何在陈旧的代码库中找出可重用的资产和隐藏的设计?
 |
关于本系列
本 系列 旨在从全新的视角来介绍经常讨论但是又难以理解的软件架构和设计概念。通过具体示例,Neal Ford 将帮助您在演化架构 和紧急设计 的敏捷实践方面打下坚实的基础。通过将重要的架构和设计决定推迟到最后责任时刻,可以防止不必要的复杂度降低软件项目的质量。
|
|
本文讨论两个很成熟的模式,它们可以帮助您重构代码,寻找可重用的资产:组合方法 和单一抽象层 (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 原则的基础上讨论重构如何推进设计。我将讨论如何发现代码库中隐藏的设计。
参考资料 学习
讨论
关于作者  | 
|  | Neal Ford 是一家全球性 IT 咨询公司 ThoughtWorks 的软件架构师和 Meme Wrangler。他的工作还包括设计和开发应用程序、教材、杂志文章、课件和视频/DVD 演示,而且他是各种技术书籍的作者或编辑,包括最近的新书
The Productive Programmer
。他主要的工作重心是设计和构建大型企业应用程序。他还是全球开发人员会议上的国际知名演说家。请访问他的 Web 站点。 |
对本文的评价
|