作為三篇系列文章的第一篇,我們將帶你了解敏捷軟件開發的重要做法——如何使用它們、你可能會碰到什么樣的問題,以及你將從它們那里獲得什么。
敏捷軟件開發不是一個具體的過程,而是一個涵蓋性術語(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 java.util.List;
import java.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可以通過電子郵件聯系上。
?