敏捷软件开发(上篇)
NetReptile推荐 [2005-7-17]
出处:ZDNet
作者:Brian Swan
作为三篇系列文章的第一篇,我们将带你了解敏捷软件开发的重要做法——如何使用它们、你可能会碰到什么样的问题,以及你将从它们那里获得什么。
敏捷软件开发不是一个具体的过程,而是一个涵盖性术语(umbrella term),用于概括具有类似基础的方式和方法。这些方法,其中包括极限编程(Extreme Programming)、动态系统开发方法(Dynamic System Development Method)、SCRUM、Crystal和Lean等,都着眼于快速交付高质量的工作软件,并做到客户满意。
尽管构成这个敏捷开发过程的每种方法都具有类似的目标,但是它们实现这个目标的做法(practice)却不尽相同。我们把在自己完成所有过程中经历过的最佳做法集中到了本系列的文章里。
下面的图表基本勾画出了我们提炼出来的这些敏捷开发最佳做法。最中间的圆环代表一对程序员日常工作的做法。紧接着的中间一个圆环表示开发人员小组使用的做法。最外面的一个圆环是项目所涉及的所有人的做法——客户、开发人员、测试人员、业务分析师等等。
这些圆环里的所有做法都直接与四个角上显示的敏捷开发的核心价值相关:沟通(Communication)、反馈(Feedback)、勇气(Courage)和简单(Simplicity)。也就是说,每个做法都给予我们一条实现敏捷开发价值并让它们成为该过程一部分的具体方法。
在理想状况下,如果决定采用敏捷软件开发的方法,你就应该在一个经过管理层许可的敏捷开发实验项目里尝试所有的作法。这是掌握敏捷开发的最好方法之一,因为这样能保证得到支持,为你的努力提供更多的回报,帮助捕捉学习到的东西,这样你才能让敏捷开发过程来适应你独特的环境。
然而,这并不总是可行的,所以有的时候最好采用步步为营的方法。在这种情况下,我们建议从最里面的圆环向外面的圆环推进。也就是从开发人员实践开始,然后是小组这一层次的做法,最后再融入“统一小组(one team)”的概念。
为技术优势设个限——开发人员做法
技术优势是敏捷开发过程的核心。为了让其他的做法真正生效,我们必须在开发人员中进行技术优势的培训。从表面上看,技术优势可能看起来并不是核心优先对象,但是如果把我们注意力都放在上面,它将确保我们编写出不同寻常的优秀代码。这反过来同样会给予公司、客户,以及用户对软件和对我们交付能力的信心。
开发人员做法(developer practice)是我们推动技术优势的切实可行的方法。即使是独立完成,而没有其他敏捷开发做法的介入,开发人员做法也能够给你的软件带来巨大的收益。
开发人员做法可以被分解为四个做法(如果你把实际的编写代码的过程加上去就是五个做法)。它们分别是测试-编码-重整循环(Test-Code-Refactor cycle)、配对编程(Pair Programming)和简单设计(Simple Design)等。
测试-编码-重整(TCR)循环——第一步
由测试驱动的开发和重整常常被当作是各自独立做法,但是它们事实上是TCR循环的一部分。要建立我们正在寻求的紧密反馈循环,我们就需要把它们放在一起。
我们在这里的目标有两层:测试让我们对代码质量的充满信心,并能表明我们加入新代码的时候没有破坏任何东西;重整和测试有助于让代码变成我们就代码实际在做什么而进行沟通的最真实形式——任何人都应该可以看到它,并知道什么是什么。
由测试驱动的开发(TDD)是一个循环,它从测试失败开始,然后是编写足够的代码通过测试,再是重整代码,使得代码在实现系统当前功能的条件下尽可能地简单。
测试-编码-重整循环非常短暂——也就几分钟。如果超出这个时间范围那就意味着测试的级别过高,有可能加入了未经测试的实现代码。
在本文的开始部分,我们不会举出TDD的例子,有关的内容会在后面2, 3, 4详细讨论。在这里,从整体上把握并把重点放在TCR循环更有趣的方面上会更加有用。
就同任何极限编程/敏捷开发项目一样,要做的第一个素材(story)是一个经过简化的应用程序,用来完整地说明程序的功能。在本文里,这样的应用程序是一个二十一点纸牌游戏。在经过简化的第一个素材里,只有一个玩家外加一个发牌人,每个玩家只会得到两张牌,获胜者是两张牌发完后点数最大的人。
素材/要求
一个简单的二十一点纸牌游戏
- 玩家下注
- 给玩家和发牌人每人两张牌
- 给获胜者支付奖金(玩家获胜的机会为2:1)
验收测试
要知道我们的素材什么时候完成就需要经过一系列验收测试。我们这个简单游戏的验收测试如下:
玩家获胜 | 发牌人获胜 | 平局 |
玩家赌注总额=100 | 玩家赌注总额=100 | 玩家赌注总额=100 |
发牌人赌注总额=1000 | 发牌人赌注总额=1000 | 发牌人赌注总额=1000 |
玩家下注10 | 玩家下注10 | 玩家下注10 |
玩家发到10 & 9 | 玩家发到8 & 9 | 玩家发到8 & 9 |
发牌人发到8 & 9 | 发牌人发到10 & 9 | 发牌人发到8 & 9 |
玩家赌注总额=110 | 玩家赌注总额=90 | 玩家赌注总额=100 |
发牌人赌注总额=990 | 发牌人赌注总额=1010 | 发牌人赌注总额=1000 |
任务
素材往往单独解决起来往往非常困难,所以在一般情况下我们都把它分解为一系列任务来完成。在本文的二十一点纸牌游戏里,需要进行下列任务:
- 创建一副牌
- 创建一个投注台面
- 创建一手牌
- 创建游戏
- 创建一副牌
在把素材分解成为任务的时候,我们可以把各个任务再分解成一系列待办事项,从而指导我们进行测试。这让我们可以保证在通过所有测试之后完成这个任务。对于这一副牌,我们有下列事项需要完成。
- 向牌桌上放一张纸牌
- 在发牌的同时将其从牌桌上移走
- 检查牌桌是否为空
- 检查牌桌上纸牌的张数
- 将牌桌上的一副牌的张数限制为52张(如果超过,就要显示异常)
- 不断发牌,直到发完
- 洗牌
- 检查牌桌上纸牌的张数是否正确
在进行过第一轮的几个简单测试之后,我们的待办事项列表就像下面这样了:
向牌桌上放一张纸牌- 在发牌的同时将其从牌桌上移走
检查牌桌是否为空检查牌桌上纸牌的张数将牌桌上一副牌的张数限制为52张(如果超过,就要显示异常)- 不断发牌,直到发完
- 洗牌
- 检查牌桌上纸牌的张数是否正确
下一个要进行的测试是从牌桌上发牌。当我们在为测试方法编写代码的时候,我们所扮演的角色就是将要编写的应用程序的用户。这就是为什么我们给自己的类创建的接口要与给用户的接口像类似的原因。在本文的这个例子里,我们将按照命令/查询分离原则(Command/Query Separation Principle5)编写出下面这样的代码。
Deck类。如列表A所示。
列表A
import <a href="http://www.chinaitpower.com/Dev/Programme/Java/index.html" target="_blank">java</a>.util.List; import <a href="http://www.chinaitpower.com/Dev/Programme/Java/index.html" target="_blank">java</a>.util.ArrayList; public class Deck { private static final int CARDS_IN_DECK = 52; private List cards = new ArrayList(); public boolean isEmpty() { return size() == 0; } public int size() { return cards.size(); } public void add(int card) throws IllegalStateException { if(CARDS_IN_DECK == size()) throw new IllegalStateException("Cannot add more than 52 cards"); cards.add(new Integer(card)); } public int top() { return ((Integer) cards.get(0)).intValue(); } public void remove() { cards.remove(0); } }
我们所有的测试都通过了,而且我们没有看到任何重复或者其他必要的重整,所以应该是时候进行下面的测试了。然而事实却不是这样的。我们top和remove方法的实现里有一个潜在的问题。如果对一个空的Deck调用它们,会发生什么?这两个方法都会从纸牌的内部列表里跳出一个IndexOutOfBoundsException异常,但是目前我们还没有就这个问题进行沟通。回头看看简单性的原则,我们知道自己需要沟通。我们的类的用户应该知道这个潜在的问题。幸运的是,我们将这种测试当作是一种沟通的方式,因此我们增加了下面的测试。
public void testTopOnEmptyDeck() { Deck deck = new Deck(); try { deck.top(); fail("IllegalStateException not thrown"); } catch(IllegalStateException e) { assertEquals("Cannot call top on an empty deck", e.getMessage()); } } public void testRemoveOnEmptyDeck() { Deck deck = new Deck(); try { deck.remove(); fail("IllegalStateException not thrown"); } catch(IllegalStateException e) { assertEquals("Cannot call remove on an empty deck", e.getMessage()); } }
上面都是异常测试(Exception Test2)的例子。我们再一次运行这些测试看它们失败,然后加入实现让它们通过。
public int top() { if(isEmpty()) throw new IllegalStateException("Cannot call top on an empty deck"); return ((Integer) cards.get(0)).intValue(); } public void remove() { if(isEmpty()) throw new IllegalStateException("Cannot call remove on an empty deck"); cards.remove(0); }
尽管guard语句有重复,但是我们决定不去管它,没有将它们简化成一个共同的方法。这是因为沟通的价值超过了重复的代价,当然这只是一个个人的选择。
一手牌
我们已经完成了对牌桌和投注台面的测试和实现,现在就到了创建一手牌的时候了。待办事项列表再一次发挥其作用,我们得到了下面这样一个列表:
- 创建一个一开始没有纸牌的空手
- 向手上加入纸牌
- 检查一只手是否击败了另一手
- 检查一只手是否爆了
为空手增加一个测试很简单,我们继续到给手上加入纸牌。
public void testAddACard()
{
Hand hand = new Hand();
hand.add(10);
assertEquals(1, hand.size());
hand.add(5);
assertEquals(2, hand.size());
}
我们运行测试,然后加入实现。
public void add( int card )
{
cards.add(new Integer(card));
}
测试通过了,我们没有看到Hand类里有任何重复。但是我们刚刚给Hand加上的实现和给Deck加上的方法极其相似。回头看看牌桌的待办事项列表,我们记得必须检查牌桌(上纸牌的张数)是否正确,我们最后也对手做同样的事情。
public void testAddInvalidCard() { Hand hand = new Hand(); try { hand.add(1); fail("IllegalArgumentException not thrown"); } catch(IllegalArgumentException e) { assertEquals("Not a valid card value 1", e.getMessage()); } try { hand.add(12); fail("IllegalArgumentException not thrown"); } catch(IllegalArgumentException e) { assertEquals("Not a valid card value 12", e.getMessage()); } }
我们加入了下面的实现来通过测试。
public void add( int card )
{
if(card < 2 || card > 11)
throw new IllegalArgumentException("Not a valid card value " + card);
cards.add(new Integer(card));
}
但是现在我们在Deck和Hand里有相同的guard语句,用来检查该自变量是否代表着正确的纸牌值。简单性的原则要求我们删除重复,但是在这里情况并不像Extract Method重整6这么简单。如果我们看到多个类之间存在重复,这意味着我们缺失了某种概念。在这里我们很容易就看到Card类担负起了判断什么值是有效的责任,而Deck和Hand作为Card的容器变得更具沟通性。
我们引入了Card类以及相应的Deck和Hand重整,如列表B:
public class Card { private final int value; public Card( int value ) { if( value < 2 || value > 11 ) throw new IllegalArgumentException( "Not a valid card value " + value ); this.value = value; } public int getValue() { return value; } } public class Deck { private static final int[] NUMBER_IN_DECK = new int[] {0, 0, 4, 4, 4, 4, 4, 4, 4, 4, 16, 4}; … public void add( Card card ) throws IllegalStateException { if(NUMBER_IN_DECK[card.getValue()] == countOf(card)) throw new IllegalStateException("Cannot add more cards of value " + card.getValue()); cards.add(card); } public Card top() { if(isEmpty()) throw new IllegalStateException("Cannot call top on an empty deck"); return (Card) cards.get(0); } … private int countOf(Card card) { int result = 0; for(Iterator i = cards.iterator(); i.hasNext(); ) { Card each = (Card) i.next(); if(each.getValue() == card.getValue()) result++; } return result; } } public class Hand { … public void add( Card card ) { cards.add(card); } … }
测试-编码-重整循环的每一阶段都涉及不同类型的思想。在测试阶段,重点放在了被实现的类的接口上。编写代码是为了让测试尽可能快地通过测试。而重整阶段可以被当作是使用简单性原则进行指导的微型代码审查。有没有重复的或者看起来类似的代码,不仅仅是在当前的类里,而且是在系统的其他类里?现在的实现可能会出现什么问题,类的用户能够与之顺利沟通吗?
重要的成功因素
- 小步前进——TCR对于开发人员来说不是一个很容易的转换。一次只进行一个步骤,同时还要明白它学习起来有一定难度。
- 严格遵守原则——只进行TDD或者只进行重整并不能让整个TCR循环一蹴而就。给自己足够的时间来尝试,并取得效果。压力和最终期限会迫使小组回到原来的习惯上——一定要小心!
- 重整过程——与小组的所有成员交换意见,了解一下他们的反馈
- 理解——确保整个小组都完全理解TCR循环是什么,如何实现它。考虑一下就此主题进行员工培训和讲座。
配对编程——第二步
TCR循环可以由某个开发人员独自完成,但是敏捷开发和TCR循环的真正威力来自于配对编程(pair programming)。在敏捷开发里,开发人员每两人一组编写所有的生产代码,其中一人担当“驱动者(driver)”(负责操作鼠标和键盘),而另一个人同驱动者一道解决问题和规划更大的图景。编程配对里的这个驱动者可以按需要进行轮换。配对让你能够实现眼前的目标,同时确保不会忽略项目的整体目标。它会保证有人在考虑下一步的走向和下一个要解决的问题。
虽然配对编程引起了很多争议,但是大多数优秀的开发人员还是在按照这一方法进行开发,至少有的时候是这样的。管理人员们可能会相信配对编程降低了生产效率,然而尽管开发小组的生产效率在一开始会有所降低,但是研究已经表明从质量和增加的生产效率的角度来看,配对编程远远超过了开发人员单独工作的质量和效率7。而另一方面,开发人员可能会觉得配对编程非常困难,因为它需要与人们更多的交互过程,并与另一个开发人员一起编写代码。但是这也是建立一种相互学习的环境的最好方法。
实施配对编程
1. 不要独断专行——要讨论。与你的小组成员讨论配对编程的思想及其优劣,而不是独断专行地给他们定规则。配对编程是开发人员相互学习的绝好机会。
2. 确定你的小组需要多少配对。配对编程是一项工作强度很大但是令人满意的工作方式。
3. 不要让配对编程人员每天连续工作八个小时——否则你的小组会吃不消的。从较短的时间开始——每天一到两个小时,看看它是如何进展的,然后随着小组信心的增强而延长时间。
4. 定期检查。如果你已经决定尝试再次进行敏捷开发,你就需要确保为小组营造了正式的环境,以便(定期)就项目进度进行反馈。
重要的成功因素
- 尝试它——如果你不去尝试,你就永远不了解它。
- 时间——给你小组(足够的)时间来尝试,并一步一步来完成。
- 沟通——配对编程会暴露一些有争议的问题——要保证沟通的渠道畅通。
- 配对恐惧症——你可能会碰到拒绝或者不希望与别人搭配工作的人。通常情况都是有别的原因驱使他们这样做,所以你需要找出并解决这些原因。
- 花时间思考——开发人员需要时间来思考并想出主意——确信给他们留出了时间做别的事情。
从整体上讲,我们在这里说的是要尝试这种方法——首先尝试测试-编码-重整循环,一旦你让它运转起来,就尝试一下配对编程。你应该马上就可以看到质量的提升,以及团队里沟通层次的提高。
我们在本文没有谈及的内容很简单——增量设计。敏捷编程喜欢简单的增量、改进的设计,而不是在编写代码之前的大型设计。很多人都认为敏捷编程不喜欢设计——事实并不是如此,而应该是只要满足最低需要就行了。
在本系列的第二部分里,我们将更加仔细地探讨简单设计以及一套的开发团队做法。
参考资料
1. Beck K和Andres C,《极限编程详解:变化,第二版(Extreme Programming explained: embrace change 2nd ed.)》,Pearson Education出版社,2005年。
2. Beck K,《测试驱动的开发:举例(Test-driven development: by example)》,Pearson Education出版社,2003年。
3. Jeffries R、Anderson A、Hendrickson C,《实现极限编程(Extreme Programming installed)》,Addison-Wesley出版社,2001年。
4. Wake W,《极限编程探讨(Extreme programming explored)》,Addison-Wesley出版社,2002年。
5. Meyer B,《构建面向对象的软件,第二版(Object-oriented software construction 2nd ed.)》,Prentice Hall出版社,1997年。
6. Fowler M,《重整:改进原有代码的设计(Refactoring: improving the design of existing code)》,Addison Wesley Longman出版社,1999年。
7. Williams L等,《强化配对编程案例(Strengthening the Case for Pair-Programming),美国犹他大学计算机系,1999年。
Brian Swan是Exoftware公司教授敏捷开发的指导老师。他在敏捷开发的技术和管理方面具有相当丰富的经验,曾经带领很多小组成功地转换到了敏捷开发,并以敏捷开发的思想和做法来培训开发人员和管理人员。他在Exoftware公司和在敏捷开发方面的工作使他到过很多公司,并对其开发小组产生了持续的、积极的影响。Brian先前的经验还包括担任Napier大学的讲师,讲授软件开发和人机互动。Brian可以通过电子邮件联系上。
本文引自:http://www.chinaitpower.com/A200508/2005-08-07/183958.html