在本系列的前两期中,我讨论了如何使用测试驱动的开发(TDD)帮助您逐步发现设计。 如果您从一个绿色项目开始,那将非常有用。 但是,在更常见的情况下,您有很多不是世界上最好的代码呢? 您如何找到隐藏在老化代码库中的可重用资产和设计?
关于本系列
本系列文章旨在为人们经常讨论但难以捉摸的软件体系结构和设计概念提供新的视角。 通过具体的示例,尼尔·福特为您提供了进化架构和紧急设计的敏捷实践的坚实基础。 通过将重要的架构和设计决策推迟到最后一个负责任的时刻,可以防止不必要的复杂性破坏您的软件项目。
本文讨论了两个具有数十年历史的模式,这些模式可帮助您重构代码以找到可重用的资产: 组合方法和单一抽象 (SLAP)原理。 良好设计的元素已经出现在您的代码中。 您只需要工具来帮助您公开已经创建的隐藏资产。
组成方法
技术变革步伐的不幸副作用之一是,作为开发人员,我们经常忽略软件知识 。 我们倾向于认为,任何超过几年的东西都必须过时。 当然,这是不正确的:许多书籍构成了开发人员的重要知识。 肯特·贝克(Kent Beck)的《 Smalltalk最佳实践模式》是这些经典著作之一(现在几乎被人们忽略了)(请参阅参考资料 )。 作为Java开发人员,您可能会问自己:“一本关于Smalltalk的13岁的书怎么与我有关?” 事实证明,Smalltalkers是最早使用面向对象语言进行编程的开发人员,并且他们开发了许多好主意。 其中之一是组合方法 。
组合方法模式定义了三个关键语句:
将您的程序划分为执行一项可识别任务的方法。 将所有操作保持在同一抽象级别的方法中。 这自然会导致程序具有许多小的方法,每个方法只有几行。
在编写实际代码之前,我在编写单元测试的上下文中讨论了“ 测试驱动设计,第1部分 ”中的组合方法。 严格遵守TDD会自然而然地产生遵循组合方法的方法。 但是现有的代码呢? 现在是时候研究使用组合方法来揭示隐藏的设计了。
惯用模式
您可能对正式的Design Patterns运动很熟悉,这一运动由具有开创性的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被定义为“各种杂物”,并且通俗地用作“剩余物”的另一个词。 (它经常出现在填字游戏中。) Olio方法是一种巨大的方法,其中包含大量杂项,在整个问题领域中都可以跳过。 根据定义,代码库中达到300行标记的方法是olio方法。 如果这么大,这种方法怎么可能具有凝聚力? Olio是重构,测试和紧急设计的主要抑制剂之一。
清单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()方法与部件无关—它是连接数据库的通用功能。 这表明该方法不应该在此类中,因此我将其向上重构为BoundaryBase类,该类充当PartDb类的父类。
清单2中是否有足够的泛型方法可以在父类中泛化? createResultSet()方法听起来很通用,但是它确实有一个指向部件的链接,即SQL_SELECT_PARTS常量。 如果我能找到一种方法来强制子类( PartDb )告诉父类此SQL字符串的值,那么我也可以拉该方法。 这正是抽象方法的目的。 因此,我将createResultSet()与名为getSqlForEntity()方法的配套抽象方法一起拉到BoundaryBase类中,如清单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类与零件有关,而与其他无关。 所有通用的样板连接代码已移至父类。 第三,所有这些方法现在都可以测试:每个方法( populate()除外)仅做一件事。 populate()方法是这些类的真正工作流程方法。 它使用所有其他(私有)方法来执行工作,并且读取的内容类似于执行的步骤的概要。 第四,现在我有了小的构建基块,因为现在我可以混合和匹配它们,所以方法重用变得更加容易。 使用像原来的populate()方法这样的大方法的机会很小:我不太可能需要在后续类中以完全相同的顺序来做这些确切的事情。 拥有原子方法可让您混合和匹配功能。
最好的框架往往是从工作代码中提取的框架,而不是先发制人的设计。 坐下来设计框架的人必须预见开发人员可能希望使用它的所有方式。 该框架最终包括许多功能,很有可能任何给定的用户都不会使用所有这些功能。 但是,您仍然必须考虑所选框架的未使用功能,因为它们会增加应用程序的意外复杂性。 这可能意味着要做一些简单的事情,例如在配置文档中添加额外的条目,或者像更改您想要实现功能的方式那样具有侵入性。 抢占式框架往往是大量的功能部件,而其他(无法预料的)功能部件则被忽略了。 JavaServer Faces(JSF)是抢占式框架的经典示例。 它的很酷的功能之一是能够插入不同的渲染管道,以防您要发出HTML以外的格式。 尽管很少使用此功能,但是所有JSF用户都必须了解其对JSF请求生命周期的影响。
从运行中的应用程序中生长出来的框架往往会提供一组更为实用的功能,因为它们可以解决编写应用程序时某人面临的实际问题。 提取的框架往往具有较少的无关功能。 将抢占式框架(如JSF)与已提取的框架(如Ruby on Rails)进行对比,后者是从实际使用中发展而来的。
此练习真正重要的好处是能够收集可重用的代码。 当您查看清单1中的代码时,您看不到可重用的资产。 您只会看到一堆代码。 通过拆开寡头方法,我发现了可重复使用的资产。 但是优势不仅仅在于重用。 我还为处理应用程序中的持久性的简单框架创建了基础。 当需要创建另一个简单的边界类以从数据库中获取某些实体时,我已经有代码可以帮助我做到这一点。 这是提取框架而不是在象牙塔中构建框架的本质。
可重复使用资产的收集使您的应用程序的整体设计从构成应用程序的一堆代码开始闪耀。 紧急设计的目标之一是在应用程序中找到惯用的使用模式。 BoundaryBase和PartDb的组合形成了一个可用的模式,该模式反复出现在此应用程序中。 当所有活动部件都分成小块时,很容易看到它们是如何装配在一起的。
拍击
组合方法定义的第二部分指出,您应该“将方法中的所有操作保持在相同的抽象级别上”。 应用此原理的示例将帮助您理解其含义以及对设计的影响。
考虑清单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()方法,该方法使用来自“四人帮”中的命令设计模式的内联版本在我的应用程序中实现此通用模式(请参阅参考资料 )。 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原理的概念基础上讨论对设计的重构。 在其中,我将讨论如何发现代码库中潜伏的设计。
翻译自: https://www.ibm.com/developerworks/java/library/j-eaed4/index.html