UML软件工程组织

模式与XP
Joshua Kerievsky著,Gigix 译

概述

模式和极端编程(XP)都为软件设计、开发者提供了无法用金钱衡量的帮助。但是迄今为止XP大量关注于重构(refactoring),而对模式只字不提。在这篇文章中,我问“为什么”,并且最终描述出模式怎样以XP的方式更好地实现、以及XP怎样因为包含对模式的使用而变得更好。

致谢

非常感谢Kent Beck、Martin Fowler和Ward Cunningham,他们为这篇文章提出了友善的评论。

仍在所知不多的时候我们就开始了自己的程序设计生涯,生产出的软件也反映出了我们的缺乏经验:我们创建的代码臃肿、错误百出、脆弱、难以维护、难以扩展。随着时间的流逝,我们成为了更好的软件设计者:我们从技术作家、专家那里学习,我们从自己的错误中学习。现在我们编写具有高度灵活性的软件,它适应广泛而且坚固。当被请求编写一个新的系统时,我们知道查明当前和将来的需求,这样我们可以设计软件来处理当前和将来的需要。

在软件开发生涯的这个阶段,极端编程告诉我们,我们经常对软件过分设计(over-engineer)了。我们从自己的错误中学到了太多,我们不希望重复这些错误,所以我们在系统生命周期的早期做了大量的努力来创造灵活而坚固的设计。不幸的是,我们没有认识到:如果这个系统永远不需要这个程度的灵活性和坚固性,那么我们所有的工作就都没有意义了。我们过分设计了。

我也曾经过分设计过。说实话,与其他设计者坐在一间房间里考虑如何设计软件来适应许多当前和将来的需求,这的确是一种乐趣。我们把自己学到的所有东西――尤其是那些最好的经验――应用在设计中。我们常常知道需求的列表会改变,但用户或客户总是改变需求。不过,我们认为我们可以足够聪明地设计软件,使软件足够灵活,使它能应付所有的需求变化。

今天,极端编程将告诉你这是多么愚蠢的做法。XP说,我们必须让设计自己显现出来,而不是去预测设计将是什么样子。XP说,“做可能起作用的最简单的事”,因为“你将不再需要它”。另外,Kent Beck说:

你需要在一个强调沟通、简单、反馈和勇气的价值系统中选择最好的工作方法,这样你才能勇敢的脱离过分设计。[Beck1 00]

同意。但是,现在我必须提到我的朋友Norm Kerth。Norm在软件开发领域有丰富的经验和见识。一年以前我问他“对XP有什么想法”。他说:

我喜欢XP里的每样东西。我关心的是:还有什么不在XP中。[Kerth 99]

当时,我只认为Norm是一个保守派。但现在我不能确定了。XP明显缺少的就是使用模式的经验。尽管一些XP的创始人帮助建设了模式社团,但没有哪一个坚定清楚的说明模式如何适应XP。

一开始,这还没有让我感到迷惑。但现在,我的确感到迷惑。

我感到迷惑,因为我在XP和模式上的经验让我相信:在XP的场景中模式会工作得更好;并且当XP包含模式时,XP也会工作得更好。

这需要一些解释。我将从描述我自己使用模式和XP的一些经验开始。

从1995年开始,我开始沉浸入模式之中。我学习模式文献、主办了一个每周一次的模式学习组、使用模式设计和开发软件、并进行UP(一个关于使用模式的国际学术会议)的组织和运转工作。说我“热衷于模式”实在是一种保守的说法。

当时,就象很多第一次学习模式的人一样,我有一点过分渴望使用它们。这不是一件好事,因为它会让你的设计比需要的更复杂。但我没有意识到这一点,直到我开始学习重构。

大概在1996年,我第一次接触到了重构。我开始实证它并很快观察到重构带我离开了我在模式学习中学到的某些原则。

举个例子,那本里程碑式的书――《设计模式:可复用面向对象软件的基础》――中的一个原则是:

针对接口编程,而不是针对实现编程。[GHJV1 95]

《设计模式》的作者们做了相当精彩的工作来解释为什么我们需要遵循这条建议。几乎在所有的模式中,都讨论了当你针对某个特定实现编程时你的软件如何变得缺少灵活性和可修改性。几乎每一次都是接口过来帮忙。

但如果我们不需要灵活性和可修改性,情况又是怎样?为什么我们要在开始设计时预料一些可能永远不会出现的需要?这是我的一次觉悟。所以随后我记录下了下面这个JAVA技巧:

不要分离类和接口

我曾经习惯于在我的接口名字后面加上一个“I”。但当我继续学习更多的重构技术时,我开始看到一种明智的做法:把类名和接口名设计成一样。下面是原因:在开发过程中,你知道你可以使用一个接口来让某些东西变得灵活(使实现多样化),但可能你现在根本不需要让实现多样化。所以,放下预测太多的“过分设计”吧,你仍然保持简单,仍然把东西放在一个类中。在某个地方你会写一个方法语句来使用这个类的对象。然后,几天、几星期、几个月之后,你明确“需要”一个接口。因此你就将原来的类转换成一个接口,再创建一个实现类(实现新的接口),并且让你原来的语句保持不变。[Kerievsky 96]

我继续学习类似于重构的课程,逐渐的,我使用模式的方式开始改变了。我不再预先考虑使用模式。现在,我更加明智了:如果某个模式可以解决某个设计问题,如果它提供一种方法来实现一个需求,我就会使用它,但我将从可以编码出的模式的最简单实现开始。晚些时候,当我需要增加或修改时,我将让这个实现更加灵活、稳固。

这种使用模式的新方法是一种更好的方法。它节约了我的时间,并让我的设计更简单。

由于我继续学到更多关于XP的知识,我很快开始考虑这样一个事实:那些清楚介绍“XP是什么”和“XP如何工作”的人毫不提及模式。看起来,焦点已经全部从开发转向了重构。构造一点,测试一点,重构一点,然后再重复。

那么,模式怎么了?

我收到的一般的答案是:模式鼓励过分设计,而重构保持事情简单、轻量级。

现在,我和其他任何人一样喜欢重构――我回顾了Martin Fowler的书的关于这个主题的两份手稿,然后知道重构将成为一个标准。但我仍然喜欢模式,我发现模式在“帮助人们学会如何设计更好的软件”方面是无价之宝。所以,XP怎么能不包括模式呢?!

我小心的在Portland Pattern Repository上写下了我的不安。我问:是否完美的XP模式应该由完全不知道模式的程序员和指导者组成,是否他们应该完全依赖重构来“让代码去它该去的地方”。Ron Jeffries,世界上最有经验的XP实践者,与我争论了这个主题,并且这样写:

一个初学者不能倾听代码所说的话。他需要学习代码质量的模式(在一般意义上)。他需要看好的代码(以及,我猜,差的代码),这样他才能学会写出好的代码。

一个问题――我的意思是一个可能的问题――是,现在的模式是否被用于帮助提高代码的质量。我想Beck的Smalltalk Best Practice Patterns会有帮助,因为那些都是非常小型的模式。我想设计模式都更值得怀疑,因为模式和讨论有时变得相当大,而且它们可能造成看起来合理的庞大解决方案。Martin Fowler的精彩的分析模式也有同样的危险:在可以选择一个小规模解决方案的时候选择了大规模的解决方案。[Jeffries 99]

一个非常有趣的关于模式的观点。尽管我已经看到模式可以被明智的实现、使用,但Ron看起来却认为它们是危险的,因为它们“让庞大的解决方案看起来合理”。在其他地方,Ron观察了一件经常发生的事情:第一次学习模式的人们如何过分渴望使用它们。

我无法不同意后面这个观察结果。就象任何新事物――甚至是XP――一样,人们可能会过分渴望使用它们。但模式真的鼓励在可以使用小规模解决方案时使用大规模解决方案吗?

我想这主要取决于你如何定义、使用模式。举个例子,我观察了许多模式的初级使用者,他们认为一个模式与它的结构图(或类图)是完全相同的。只有在我向他们指出“模式可以根据需要以不同的方式实现”之后,他们才开始发现这些图只是表示实现模式的一种方式。

模式的实现有简单的也有复杂的。诀窍是:发现模式针对的问题,将这个问题与你当前的问题进行比较,然后将这个模式最简单的实现(解决方案)与你的问题进行比较。当你这样做时,你就不会在可以使用小规模解决方案的时候使用大规模解决方案。你获得了解决问题最好的平衡。

当人们没有受过模式的良好训练时,困难就可能出现。Ron提到人们使用模式的方式是“现在构成的”――这就是说,他们如何与现在的作者沟通。我同意模式文献有一些缺点。关于模式的书很多,你可以花一些时间来理解模式解决的问题,这样你就可以聪明的根据自己的特定需要选择模式。

这种选择是极其重要的。如果你选择了错误的模式,你可能过分设计或仅仅把你的设计揉在一起。有经验的模式使用者也会犯错误,并且经常看到这样的结果。但这些专家有其他的模式作为装备,这些模式可以帮助他们面对自己的错误。所以他们最终经常把自己真正需要的模式换成了不那么理想的模式。

那么,你将怎样成为一个有经验的模式使用者呢?我发现除非人们投身于大量模式的学习中,否则他们就有可能陷入误解它们、过分使用它们以及用它们过分设计的危险之中。

但这是避免使用模式的一个原因吗?

我想,不。我发现模式在如此多的项目中如此有用,以至于我无法想象不使用它们来进行软件设计和开发。我相信对模式的彻底的学习是非常值得的。

那么,XP对模式保持沉默是因为感觉到它们将被误用吗?

如果情况是这样,也许问题已经变成:我们怎样使用模式中的智慧,而避免模式在XP开发场景中的误用呢?

在这里,我想我必须回到《设计模式》。在“结论”一章、“设计模式将带来什么”一节、“重构的目标”小节中,作者写道:

我们的设计模式记录了许多重构产生的设计结构。在设计初期使用这些模式可以防止以后的重构。不过即使是在系统建成之后才了解如何使用这些模式,它们仍可以教你如何修改你的系统。设计模式为你的重构提供了目标。[GHJV2 95]

这就是我们需要的观点:重构的目标。这就是重构和模式之间的桥梁。它完美的描述了我自己在如何使用模式方面的进步:从简单开始,考虑模式但将它们保持在次要地位,小规模重构,只有在真正需要模式的时候才把重构转移为模式。

这个需要训练和仔细判断的过程将很好的适应XP所包含的最好的习惯。

而且这个途径很明显与“故意不知道或不使用模式而只依赖重构来改善设计”的方法非常不同。

只依赖重构的危险是:没有目标,人们可能使设计小小进步,但他们的全面设计将最终受损害,因为这种方法缺乏顺序、简单性和效力,而聪明的使用模式则可以让开发者拥有这些。

引用Kent Beck自己的话:模式生成体系结构。[Beck2 94]

但模式不保证有纪律的使用。如果我们在设计中过多、过早的使用它们,我们就又回到了过分设计的问题。因此,我们必须回答这个问题:“在设计的生命周期中,何时引入模式是安全的?”请回忆上面对《设计模式》的引用:

在设计初期使用这些模式可以防止以后的重构。

这是一个聪明的主张。如果我们不知道“何时配置一个模式”的基本规则,那么我们就很容易在设计周期的早期就陷入过分设计。

再一次,问题又全部集中在一起:如何将项目中的问题与一个合适的模式相匹配。

在这里,我必须讲述我为不同行业开发软件得到的经验。

有一家客户要求我和我的团队用JAVA为他们的网站构造软件,这将是一个很酷的交互式版本。这个客户没有任何JAVA程序员,但仍然要求能在他们需要的任何时候、任何地方修改软件的行为,而不必做程序的修改。多么高的要求!

在对他们的需要做了一些分析之后,我们发现Command模式将在这个设计中扮演一个非常重要的角色。我们将编写命令对象,并让这些命令对象控制软件的整个行为。用户将可以参数化这些命令、将它们排序、并选择命令运行的时间和地点。

这个解决方案工作得很完美,Command模式正是成功的关键。所以在这里,我们不会等到重构的时候才使用Command模式。相反,我们预先看到了使用它的需要,并从一开始就用Command模式来设计软件。

在另一个项目中,系统需要作为独立应用程序和WEB应用程序运行。Builder模式在这个系统中发挥了巨大的作用。如果没有它,我不敢想象我们会拼凑出一个多么臃肿的设计。Builder模式的作用就是解决“多平台、多环境运行”这样的问题。所以在设计早期就选择它是正确的。

现在,我必须声明:即使在设计的早期引入了模式,但一开始仍然应该按照它们最原始的样子来实现它们。只有在晚些时候,当需要附加的功能时,模式的实现才能被替换或升级。

一个例子会让你更清楚这一点。

上面提到的由命令对象控制的软件是用多线程的代码实现的。有时候两个线程会使用同一个宏命令来运行一系列命令。但一开始我们并没有被宏命令的线程安全问题困扰。所以,当我们开始遇到线程安全造成的莫名其妙的问题时,我们必须重新考虑我们的实现。问题是,我们应该花时间构造宏命令的线程安全吗?或者有没有更简单的方法来解决这个问题?

我们用更简单的方法解决了这个问题,并且避免了过分设计:为每个线程提供一个独立的宏命令实例。我们可以在30秒内实现这个解决方案。请把这个时间与设计一个线程安全的宏命令所需的时间做一下比较。

这个例子描述了XP的哲学怎样在使用模式的情况下保持事情简单。没有这种简单化的驱动,过分设计的解决方案――就象线程安全的宏命令――很容易出现。

因此,简单化和模式之间的关联是很重要的。

当程序员需要做出设计决策时,很重要的一件事就是:他们应该试图保持设计简单,因为简单的设计通常比庞大而复杂的设计更容易维护和扩展。我们已经知道,重构意味着将我们保持在简单的路上:它鼓励我们以小而简单步骤逐渐改进我们的设计,并避免过分设计。

但是模式呢?难道它们不是帮助我们保持简单吗?

有些人会说“不”。他们认为模式尽管有用,但容易造成复杂的设计。他们认为模式会造成对象快速增加,并导致过分依赖对象组合。

这种观点是由于对使用模式的方法的错误理解。有经验的模式使用者会避免复杂的设计、对象的快速增长和过多的对象组合。

实际上,在使用模式的时候,有经验的模式使用者会让他们的设计更简单。我将再用一个例子来说明我的观点。

JUnit是一个简单而有用的JAVA测试框架,它的作者是Kent Beck和Erich Gamma。这是一个精彩的软件,其中满是精心选择的简单的模式。

最近一些人要求我对JUnit进行DeGoF,也就是说,将JUnit中的设计模式移除掉,以观察没有模式的JUnit是什么样子。这是一次非常有趣的练习,因为它让参与者认真考虑应该在什么时候在系统中引入模式。

为了描述他们学到的东西,我们将对JUnit 2.1版中的一些扩展进行DeGoF。

JUnit中有一个叫做TestCase的抽象类,所有的具体测试类都派生自它。TestCase类没有提供任何多次运行的方法,也没有提供在自己的线程中运行测试的方法。Erich和Kent用Decorator模式很优雅的实现了可重复测试和基于线程的测试。但是如果设计团队不知道Decorator模式呢?让我们看看他们会开发出什么,并评估一下他们的设计有多简单。

这是Test Case在JUnit框架1.0版本中的样子(为了简化,我们忽略了注释和很多方法):

public abstract class TestCase implements Test {
private String fName;
public TestCase(String name) {

fName= name;

}

public void run(TestResult result) {

result.startTest(this);

setUp();

try {

runTest();

}

catch (AssertionFailedError e) {

result.addFailure(this, e);

}

catch (Throwable e) {

result.addError(this, e);

}

tearDown();

result.endTest(this);

}

public TestResult run() {

TestResult result= defaultResult();

run(result);

return result;

}

protected void runTest() throws Throwable {

Method runMethod= null;

try {

runMethod= getClass().getMethod(fName, new Class[0]);

} catch (NoSuchMethodException e) {

e.fillInStackTrace();

throw e;

}

try {

runMethod.invoke(this, new Class[0]);

}

catch (InvocationTargetException e) {

e.fillInStackTrace();

throw e.getTargetException();

}

catch (IllegalAccessException e) {

e.fillInStackTrace();

throw e;

}

}

public int countTestCases() {

return 1;

}

}

新的需求要求允许测试重复进行、或在它们各自的线程中进行、或以上两者。

没有经验的程序员通常在遇到这样的新需求时进行子类型化。但是在这里,因为知道TestCase对象将需要能够在同一个线程中重复运行、或在各自独立的线程中重复运行,所以程序员知道:他们需要考虑得更多。

一种实现方法是:将所有的功能都添加给TestCase本身。许多开发者――尤其是那些不了解设计模式的开发者――将会这样做,而不考虑这会使他们的类变得臃肿。他们必须添加功能,所以他们将功能添加到任何可以添加的地方。下面的代码可能就是他们的实现:

public abstract class TestCase implements Test {
private String fName;
private int fRepeatTimes;
public TestCase(String name) {
this(name, 0);
}
public TestCase(String name, int repeatTimes) {
fName = name;
fRepeatTimes = repeatTimes;
}
public void run(TestResult result) {
for (int i=0; i < fRepeatTimes; i++) {
result.startTest(this);
setUp();
try {
runTest();
}
catch (AssertionFailedError e) {
result.addFailure(this, e);
}
catch (Throwable e) {
result.addError(this, e);
}
tearDown();
result.endTest(this);
}
}
public int countTestCases() {
return fRepeatTimes;
}
}

请注意run(TestResult result)方法变大了一些。他们还为TestCase类添加了另外的构造子。到目前为止,这还不算什么大事。并且,我们可以说:如果这就是所有必须做的事情,那么使用Decorator模式就是多余的。

现在,如果要让每个TestCase对象在其自己的线程中运行又怎样呢?这里也有一个可能的实现:

public abstract class TestCase implements Test {
private String fName;
private int fRepeatTimes;
private boolean fThreaded;
public TestCase(String name) {
this(name, 0, false);
}
public TestCase(String name, int repeatTimes) {
this(name, repeatTimes, false);
}
public TestCase(String name, int repeatTimes, boolean threaded) {
fName = name;
fRepeatTimes = repeatTimes;
fThreaded = threaded;
}
public void run(TestResult result) {
if (fThreaded) {
final TestResult finalResult= result;
final Test thisTest = this;
Thread t= new Thread() {
public void run() {
for (int i=0; i < fRepeatTimes; i++) {
finalResult.startTest(thisTest);
setUp();
try {
runTest();
}
catch (AssertionFailedError e) {
finalResult.addFailure(thisTest, e);
}
catch (Throwable e) {
finalResult.addError(thisTest, e);
}
tearDown();
finalResult.endTest(thisTest);
}
}
};
t.start();
result = finalResult;
} else {
for (int i=0; i < fRepeatTimes; i++) {
result.startTest(this);
setUp();
try {
runTest();
}
catch (AssertionFailedError e) {
result.addFailure(this, e);
}
catch (Throwable e) {
result.addError(this, e);
}
tearDown();
result.endTest(this);
}
}
}
public int countTestCases() {
return fRepeatTimes;
}
}

唔,这看起来开始变得更坏了。为了支持两个新的特征,我们现在拥有了三个构造子,而且run(TestResult result)方法的大小迅速的膨胀起来。

即使不管所有这些新代码,我们这些程序员还没有满足这些需求:我们仍然不能在各自的线程中重复运行测试。为了这个目的,我们必须添加更多的代码。算了,我就放过你吧。

重构可以帮助这些代码减小尺寸。但是只需要稍做思考:如果再接到一个新的需求,我们要怎么办?现在JUnit 3.1支持四种不同的TestCase修饰器,它们可以轻松的随意组合以获取所需的功能。同时,JUnit的实现仍然简单――没有混乱的代码。这种设计保持TestCase类的简单、轻量级,用户只需要在需要的时候对TestCase对象进行装饰即可,而且可以选择任何组合顺序。

很清楚,这是一个模式帮助简化设计的例子。这个例子也说明了缺乏经验的开发者怎样改善他们的设计――如果他们知道模式指出的重构目标。

使用模式来开发软件是聪明之举,但如果你缺乏使用模式的经验,它也可能是危险的。出于这个原因,我极力提倡模式学习组。这些学习组让人们在同伴的帮助下稳步前进而精通模式。

当人们了解模式并以受过训练的方式使用它们时,模式是最有用的――这种受过训练的方式就是XP的方式。以XP的方式使用模式鼓励开发者保持设计的简单、并完全根据需要对模式进行重构。它鼓励在设计早期使用关键的模式。它鼓励将问题与能帮助解决问题的模式相匹配。最后,它鼓励开发者编写模式的简单实现,然后根据需要发展它们。

在XP的场景中,模式的确更有用;而在包含对模式的使用时,XP开发则更有可能成功。

参考书目

[Beck1 00] Beck, Kent. Email on extremeprogramming@egroups.com, January 2000.

[Beck2 94] Patterns Generate Architectures, Kent Beck and Ralph Johnson, ECOOP 94

[GHJV1 95] Design Patterns: Elements of Reusable Object-Oriented Software, by Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides. 中译本:《设计模式:可复用面向对象软件的基础》,李英军等译。

[GHJV2 95] Design Patterns: Elements of Reusable Object-Oriented Software, by Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides. Pages 353-354 中译本:《设计模式:可复用面向对象软件的基础》,李英军等译,第6章。

[Jeffries 99] Jeffries, Ron. Patterns And Extreme Programming. Portland Pattern Repository. December, 1999

[Kerth 99] Kerth, Norm. Conversation, circa March, 1999.

[Kerievsky 96] Kerievsky, Joshua. Don’t Distinguish Between Classes And Interfaces. Portland Pattern Repository. Circa 1996

 

版权所有:UML软件工程组织