高质量软件开发人员的五大习惯
作者: Robert J. Miller
08/24/2006
那些在团队环境下有能力开发和维护高质量软件的开发人员在今天的技术驱动的经济里有着巨大的需求。工作在团队环境下的开发人员所面临的排在第一位的挑战是阅读和理解其他开发人员的软件。本文试图帮助软件开发团队来克服这个挑战。
本文举出了使得软件开发团队更加有效并且更加高质量的五大习惯。首先,本文描述了这样一些商业团队给于软件开发团队以及他们所开发的软件的要求。接着,本文解释了状态更换逻辑和行为逻辑之间的重要的区别。最后,本文将要示例五个使用客户帐户情形作为案例研究的习惯。
业务对开发人员提出的要求
业务团队的工作是要决定往软件里加入什么新的功能,同时确保新的功能对业务来说是最有利的。在这里,“新的功能”是指一个新的产品或者对一个已存在产品的额外的功能提升。换句话说,业务团队决定了哪些新的特性会帮助软件产品赚更多的钱。决定上那些新的功能的关键因素是实现这些新的功能所需要的成本。如果实现的成本超过了潜在的价值,那么新的功能将不会实现在软件产品里。
业务团队要求软件开发团队以最低的成本开发新的功能。它同时也要求软件开发团队在给产品增加新的功能的时候,不会随着时间的过去而增加新的成本。而且,每一次业务团队请求开发新的功能,他们要求增加新的功能的同时不会丢失旧的功能。随着时间的过去,软件将产生足够多的功能,业务团队将要求使用文档来描述软件提供的当前功能。然后,业务团队将使用这些文档来决定下一个新的功能。
软件开发团队通过开发“易于理解”的软件来最佳的满足这些要求。“难于理解”的软件将导致整个开发过程的无效。这些无效增加了软件开发的成本,并且将导致意想不到的已经实现的功能的丢失。增加了开发人员的开发时间,和错误的软件文档的交付。通过改变业务团队的需求,这些无效能够被减少,将复杂的转化为简单的、“易于理解”的软件。
引入关键概念:状态和行为
开发易于理解的软件开始于创建一个拥有状态和行为的对象。“状态”是指在方法调用之间保持的对象的数据。一个Java对象能够在它的实例变量中临时的保持它的状态,或者通过保存到一个永久的数据源的方式保持。一个永久的数据源可以是数据库或者Web服务。“状态改变的方法”典型的是通过获取数据或从远程数据源取得和放置数据的方式管理一个对象的数据。“行为”是一个对象在“状态”的基础上回答问题的能力。“行为方法”回答问题不用改变状态,并且在应用中通常指的是业务逻辑。
案例研究:CustomerAccount
对象
下面的
ICustomerAccount
接口定义了一个对象必须实现的用来管理用户帐户的方法。它定义了产生一个活跃账户的能力,载入一个已经存在客户账户状态、校验潜在用户的用户名和密码、校验一个账户是否为活跃账户能够购买产品等功能。
public interface ICustomerAccount {
//State-changing methods
public void createNewActiveAccount()
throws CustomerAccountsSystemOutageException;
public void loadAccountStatus()
throws CustomerAccountsSystemOutageException;
//Behavior methods
public boolean isRequestedUsernameValid();
public boolean isRequestedPasswordValid();
public boolean isActiveForPurchasing();
public String getPostLogonMessage();
}
习惯1
:构造器实现最少的工作
第一个习惯是一个对象的构造器只能实现尽量少的工作。理想的,构造器仅仅是通过它的参数载入数据到它的实例变量。如下所是,作为一个例子,开发一个只实现最少功能的构造器使得这个对象易于被使用和理解。因为构造器只用来实现简单任务,将数据载入到对象的实例变量。
public class CustomerAccount implements ICustomerAccount{
//Instance variables.
private String username;
private String password;
protected String accountStatus;
//Constructor that performs minimal work.
public CustomerAccount(String username, String password) {
this.password = password;
this.username = username;
}
}
构造器被用来产生对象的实例。构造器的名字永远和对象的名字相同(译者:这里的“对象”应该是“类”的意思,以下相同)。既然构造器的名字是无法改变的,它的名字不能表达出它要行使的其他功能。因而,它最好履行尽量少的工作。另一方面,状态的改变和行为方法的名称使用描述性的名称来表达它们更加复杂的意图,就像在“习惯2:方法名要清晰地表达方法的意图”描述的那样。下面的例子将要表明:软件的可读性很高,因为构造器仅仅产生对象的实例,而让行为和状态改变的方法去剩下的事情。
注:在例子中使用“…
”表示它们在现实案例中是必须的,但是它们与例子的意图无关
String username = "robertmiller";
String password = "java.net";
ICustomerAccount ca = new CustomerAccount(username, password);
if(ca.isRequestedUsernameValid() && ca.isRequestedPasswordValid()) {
...
ca.createNewActiveAccount();
...
}
另一方面,那些构造器的功能超过载入实例变量的对象是难于理解的,并且容易被误用,因为它们的名称没有传递它们的意图。例如,下面的这个构造器额外的调用一个方法用来远程调用数据库或者Web服务,目的是为了事先载入一个账户的状态。
//Constructor that performs too much work!
public CustomerAccount(String username, String password)
throws CustomerAccountsSystemOutageException {
this.password = password;
this.username = username;
this.loadAccountStatus();//unnecessary work.
}
//Remote call to the database or web service.
public void loadAccountStatus()
throws CustomerAccountsSystemOutageException {
...
}
一个开发人员使用了这个构造器,但他没有意识到构造器已经做了一个远程调用,因而做了两个远程调用。
String username = "robertmiller";
String password = "java.net";
try {
//makes a remote call
ICustomerAccount ca = new CustomerAccount(username, password);
//makes a second remote call
ca.loadAccountStatus();
} catch (CustomerAccountsSystemOutageException e) {
...
}
或者一个开发人员重用了这个构造器来校验一个潜在的用户所希望的用户名和密码,那么他就被迫作出了一个不需要的远程调用,因为这些方法(
isRequestedUsernameValid()
,
isRequestedPasswordValid()
)不需要账户状态。
String username = "robertmiller";
String password = "java.net";
try {
//makes unnecessary remote call
ICustomerAccount ca = new CustomerAccount(username, password);
if(ca.isRequestedUsernameValid() && ca.isRequestedPasswordValid()) {
...
ca.createNewActiveAccount();
...
}
} catch (CustomerAccountsSystemOutageException e){
...
}
习惯2
:方法名清晰的表达方法的意图
第二个习惯是,通过它们的方法名,所有的方法必须清晰的传递它们的意图。例如,
isRequestedUsernameValid()
让开发人员知道这个方法确定请求的用户名是否是合法的。与之相对照的是,
isGoodUser()
可能有好几种用途:它能决定一个用户的账户是否是活跃的,决定是否请求的用户名或密码是正确的,或者决定用户是否是一个好人。既然这个方法名的描述性不强,那么它对于其他开发人员来说很难确定它的意图是什么。简短地说,一个方法名使用长的和描述性的比使用短的和毫无意义的好。
长的、描述性的方法名帮助开发团队迅速的理解他们的软件的意图和功能。此外,应用这种技术到测试方法的名称,使得测试表达了软件现有的需求。例如,软件要求检验请求的用户名和密码是不同的。使用方法名称:
testRequestedPasswordIsNotValidBecauseItMustBeDifferentThanTheUsername()
就能传递测试的这个意图,因此为表达了软件需求的意图。
import junit.framework.TestCase;
public class CustomerAccountTest extends TestCase{
public void testRequestedPasswordIsNotValid
BecauseItMustBeDifferentThanTheUsername(){
String username = "robertmiller";
String password = "robertmiller";
ICustomerAccount ca = new CustomerAccount(username, password);
assertFalse(ca.isRequestedPasswordValid());
}
}
这个测试方法可以很简单的被命名为
testRequestedPasswordIsNotValid()
,或者更差的
testBadPassword()
,这两个名称都使得它很难确定测试的意图。不清楚地或者说含糊不清的名称将导致效率的损失。效率的损失导致需要增加额外的时间来理解测试、创建不需要的方法或属性、重复的或者冲突的测试、或者销毁了对象已经测试过的已经存在的功能。
最后,描述性的方法名减少了对于常规文档或者 Javadoc注释的需要。
习惯3
:一个对象执行功能集中的服务集
第三个习惯是,软件的每一个对象都集中的执行一个小的、独一无二的服务集。执行小基数工作的对象容易阅读,容易正确使用,因为只有少量的代码需要理解。此外,软件的每一个对象都必须执行独一无二的服务集,因为重复的逻辑浪费开发人员的时间,增加维护的成本。设想,未来,业务团队要求升级
isRequestedPasswordValid()
的逻辑,如果两个不同的对象都有执行相同动作的类似方法,在这种情况下,软件开发人员将要比升级仅仅一个对象花费更多的时间来升级两个对象。
作为案例学习的示例,
CustomerAccount
对象的目的是管理一个独立的客户的账户。它首先是创建一个账户,然后是验证账户对于购买商品来说仍然是活跃的。假设在未来,软件需要给那些购买了十件以上商品的客户折扣。创建一个新的接口,
ICustomerTransactions
,而且对象,
CustomerTransactions
,来实现这些新的特性。这些都是开发“易于理解”软件需要有目的进行的工作。
public interface ICustomerTransactions {
//State-changing methods
public void createPurchaseRecordForProduct(Long productId)
throws CustomerTransactionsSystemException;
public void loadAllPurchaseRecords()
throws CustomerTransactionsSystemException;
//Behavior method
public void isCustomerEligibleForDiscount();
}
这个新的对象维护存储客户交易和决定什么时候客户获得他的十件商品者扣的状态变化和行为方法。它应该是易于创建、测试和维护,因为它只有一个简单的、集中的目的。而一个效率低下的方法是将这些新方法加入到已经存在的
ICustomerAccount
接口和
CustomerAccount
对象。如下所示:
public interface ICustomerAccount {
//State-changing methods
public void createNewActiveAccount()
throws CustomerAccountsSystemOutageException;
public void loadAccountStatus()
throws CustomerAccountsSystemOutageException;
public void createPurchaseRecordForProduct(Long productId)
throws CustomerAccountsSystemOutageException;
public void loadAllPurchaseRecords()
throws CustomerAccountsSystemOutageException;
//Behavior methods
public boolean isRequestedUsernameValid();
public boolean isRequestedPasswordValid();
public boolean isActiveForPurchasing();
public String getPostLogonMessage();
public void isCustomerEligibleForDiscount();
}
就像上面看到的那样,允许对象变成大的责任和目标的仓库将使得它们更加难以阅读,更加容易误解。误解将导致效率的损失,增加业务团队的时间和金钱。简而言之,让对象和它的方法集中的执行小单元的工作更好一些。
习惯4
:状态改变方法包含最小限度的行为逻辑
第四个习惯是状态改变方法必须包含最小数量的行为逻辑。混合状态改变逻辑和行为逻辑使得软件理解起来更加的困难,因为它增加了在一个地方发生的工作的数量。状态改变方法通常是用来获取或发送数据到一个远程的数据存储设备,因而容易在产品系统中出现问题。诊断一个状态改变方法的系统问题在远程调用被独立的时候更容易一些,这时候它完全不含有行为逻辑。此外,两者的混合还制约了开发过程。例如,
getPostLogonMessage()
是一个基于
accountStatus
的值的行为方法:
public String getPostLogonMessage() {
if("A".equals(this.accountStatus)){
return "Your purchasing account is active.";
} else if("E".equals(this.accountStatus)) {
return "Your purchasing account has " +
"expired due to a lack of activity.";
} else {
return "Your purchasing account cannot be " +
"found, please call customer service "+
"for assistance.";
}
}
loadAccountStatus()
是从远程数据存储设备载入
accountStatus
的值的状态改变方法:
public void loadAccountStatus()
throws CustomerAccountsSystemOutageException {
Connection c = null;
try {
c = DriverManager.getConnection("databaseUrl", "databaseUser",
"databasePassword");
PreparedStatement ps = c.prepareStatement(
"SELECT status FROM customer_account "
+ "WHERE username = ? AND password = ? ");
ps.setString(1, this.username);
ps.setString(2, this.password);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
this.accountStatus=rs.getString("status");
}
rs.close();
ps.close();
c.close();
} catch (SQLException e) {
throw new CustomerAccountsSystemOutageException(e);
} finally {
if (c != null) {
try {
c.close();
} catch (SQLException e) {}
}
}
}
单元测试方法
getPostLogonMessage()
能够通过模仿
loadAccountStatus()
方法很容易地进行测试,不需要那种通过数据库的远程调用,每一个假设条件就能够被测试到。例如,
accountStatus
是“E”用来中止,那么
getPostLogonMessage()
将返回“Your purchasing account has expired due to a lack of activity,”,如下所示:
public void testPostLogonMessageWhenStatusIsExpired(){
String username = "robertmiller";
String password = "java.net";
class CustomerAccountMock extends CustomerAccount{
...
public void loadAccountStatus() {
this.accountStatus = "E";
}
}
ICustomerAccount ca = new CustomerAccountMock(username, password);
try {
ca.loadAccountStatus();
}
catch (CustomerAccountsSystemOutageException e){
fail(""+e);
}
assertEquals("Your purchasing account has " +
"expired due to a lack of activity.",
ca.getPostLogonMessage());
}
与之相反的方法是将
getPostLogonMessage()
的行为逻辑和
loadAccountStatus()
的状态改变工作放到一个方法里。下面的示例展示了这个错误的做法:
public String getPostLogonMessage() {
return this.postLogonMessage;
}
public void loadAccountStatus()
throws CustomerAccountsSystemOutageException {
Connection c = null;
try {
c = DriverManager.getConnection("databaseUrl", "databaseUser",
"databasePassword");
PreparedStatement ps = c.prepareStatement(
"SELECT status FROM customer_account "
+ "WHERE username = ? AND password = ? ");
ps.setString(1, this.username);
ps.setString(2, this.password);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
this.accountStatus=rs.getString("status");
}
rs.close();
ps.close();
c.close();
} catch (SQLException e) {
throw new CustomerAccountsSystemOutageException(e);
} finally {
if (c != null) {
try {
c.close();
} catch (SQLException e) {}
}
}
if("A".equals(this.accountStatus)){
this.postLogonMessage = "Your purchasing account is active.";
} else if("E".equals(this.accountStatus)) {
this.postLogonMessage = "Your purchasing account has " +
"expired due to a lack of activity.";
} else {
this.postLogonMessage = "Your purchasing account cannot be " +
"found, please call customer service "+
"for assistance.";
}
}
在这个实现里,行为方法
getPostLogonMessage()
没有包含任何的行为逻辑,而是简单的返回实例变量
this.postLogonMessage
。这个实现存在着三个问题。首先,这个实现使得我们很难理解“post logon message”的逻辑是怎么工作的,因为它被包含在一个执行两个任务的方法里。第二,
getPostLogonMessage()
的重用是受限制的,因为它永远和
loadAccountStatus()
相关联。最后,在出现系统问题的情况下,
CustomerAccountsSystemOutageException
将会被抛出,使得方法在设置
this.postLogonMessage
的值之前就停止了。这个实现也对测试产生了一个负面的影响,因为测试
getPostLogonMessage()
逻辑的唯一方法是创建一个
CustomerAccount
对象,这个对象有一个在数据库里有用户名和密码的用户,而且这个用户的
accountStatus
被设置为“E”,被用来停止。这将导致为了这个测试必须给数据库做一个远程调用。这使得这个测试运行起来速度慢,而且由于数据库发生的改变将导致测试意想不到的失败。这个测试需要对数据库做一个远程调用,因为
loadAccountStatus()
方法也包含了行为逻辑,如果行为逻辑被模仿,那么测试测试的是模拟对象的行为,而不是实际对象的行为。
习惯5
:行为方法能够在任何条件下被调用
第五个习惯是确保每一个行为方法提供的功能相对于其他的行为方法来说是独立的。换句话说,一个对象的行为方法能够被重复和以任何顺利调用。这个习惯使得对象传递固定的行为。例如,
CustomerAccount
对象的
isActiveForPurchasing()
和
getPostLogonMessage()
行为方法在它们的逻辑里都使用
accountStatus
的值。每一个方法对于其他的方法来说是功能独立的。例如,一个场景要求
isActiveForPurchasing()
被调用,接着调用
getPostLogonMessage()
:
ICustomerAccount ca = new CustomerAccount(username, password);
ca.loadAccountStatus();
if(ca.isActiveForPurchasing()){
//go to "begin purchasing" display
...
//show post logon message.
ca.getPostLogonMessage();
} else {
//go to "activate account" display
...
//show post logon message.
ca.getPostLogonMessage();
}
另一个场景要求调用
getPostLogonMessage()
,而不要求调用
isActiveForPurchasing()
:
ICustomerAccount ca = new CustomerAccount(username, password);
ca.loadAccountStatus();
//go to "welcome back" display
...
//show post logon message.
ca.getPostLogonMessage();
如果
getPostLogonMessage()
要求
isActiveForPurchasing()
首先被调用的话,
CustomerAccount
对象将不支持第二个场景。例如,创建两个方法来使用一个
postLogonMessage
实例变量,这样,它的值能够在支持场景一的方法中间得到维护,但是在支持场景二的方法中却不能:
public boolean isActiveForPurchasing() {
boolean returnValue = false;
if("A".equals(this.accountStatus)){
this.postLogonMessage = "Your purchasing account is active.";
returnValue = true;
} else if("E".equals(this.accountStatus)) {
this.postLogonMessage = "Your purchasing account has " +
"expired due to a lack of activity.";
returnValue = false;
} else {
this.postLogonMessage = "Your purchasing account cannot be " +
"found, please call customer service "+
"for assistance.";
returnValue = false;
}
return returnValue;
}
public String getPostLogonMessage() {
return this.postLogonMessage;
}
另一方面,如果两个方法设计为逻辑彼此相互独立,那么它们将支持两个场景。在这个优胜的例子中,
postLogonMessage
是一个局域变量,由
getPostLogonMessage()
方法自己创建:
public boolean isActiveForPurchasing() {
return this.accountStatus != null && this.accountStatus.equals("A");
}
public String getPostLogonMessage() {
if("A".equals(this.accountStatus)){
return "Your purchasing account is active.";
} else if("E".equals(this.accountStatus)) {
return "Your purchasing account has " +
"expired due to a lack of activity.";
} else {
return "Your purchasing account cannot be " +
"found, please call customer service "+
"for assistance.";
}
}
使得这两个方法彼此独立的一个额外的好处是这些方法容易被理解。例如,
isActiveForPurchasing()
更加具有可读性,因为它仅仅只回答“is active for purchasing”的问题,而不是相反的它还要设置“post logon message”。另外一个额外的好处是每一个方法都能被独立的测试,这也使得测试容易被理解:
public class CustomerAccountTest extends TestCase{
public void testAccountIsActiveForPurchasing(){
String username = "robertmiller";
String password = "java.net";
class CustomerAccountMock extends CustomerAccount{
...
public void loadAccountStatus() {
this.accountStatus = "A";
}
}
ICustomerAccount ca = new CustomerAccountMock(username, password);
try {
ca.loadAccountStatus();
} catch (CustomerAccountsSystemOutageException e) {
fail(""+e);
}
assertTrue(ca.isActiveForPurchasing());
}
public void testGetPostLogonMessageWhenAccountIsActiveForPurchasing(){
String username = "robertmiller";
String password = "java.net";
class CustomerAccountMock extends CustomerAccount{
...
public void loadAccountStatus() {
this.accountStatus = "A";
}
}
ICustomerAccount ca = new CustomerAccountMock(username, password);
try {
ca.loadAccountStatus();
} catch (CustomerAccountsSystemOutageException e) {
fail(""+e);
}
assertEquals("Your purchasing account is active.",
ca.getPostLogonMessage());
}
}
结论
遵从上面的五个习惯将有助于开发团队开发的软件容易被团队的每一个成员阅读、理解和修改。当软件开发团队开发新的功能太快,而不考虑将来,那么他们的软件将增加高额的使用成本。不可避免的,当他们为了在一次的理解和修改软件的时候,他们将得到的是可怕的经历。如果软件很难被理解的话,增加一个新的功能就会变得非常昂贵。然而,当开发团队应用这些好的经验,他们将以最低的成本给业务团队提供一个新的功能。
参考资料
- JDBC Basics
- JUnit
- XP/Agile methodologies as defined by Ron Jeffries
致谢
作者要感谢
Gary Brown,是他教会作者使用敏捷的开发方法。还要感谢
Kelli Moran-Miller和
Ethan Vizitei评论这篇文章,并且给与宝贵的反馈。
关于作者
Robert J. Miller 是
CARFAX, Inc.的一个敏捷的web开发人员。是
University of Missouri-Columbia的一位MBA学生。
Missouri Innovation Center的一个企业家型的学者。