TDD 實踐-FizzFuzzWhizz(三)

lyning發表於2019-03-17

標籤 | TDD Java

字數 | 4742 字

說明:該 TDD 系列案例主要是為了鞏固和記錄自己 TDD 實踐過程中的思考與總結。個人認為 TDD 本身並不難,難的大部分是程式設計之外的技能,比如分析能力、設計能力、表達能力和溝通能力,它可以鍛鍊一個人事先思考、化繁為簡、制定計劃、精益求精的習慣和品質。本文的原始碼放在個人的 Github 上,案例需求來自於網上。

在之前的實踐文章中著重掌握 TDD 的口號和整體流程,用 9 個 UT 驅動出核心任務的實現程式碼,即完成了核心任務,也得到了將近 100% 的測試覆蓋率,並且在測試的支撐下對程式進行小範圍重構,從目前看來採用 TDD 的效果還是不錯的。不過上一篇文章留下了一個反思一直困擾著我,並不是因為這個問題有多難解決,而是以後再面對這種型別的問題時,我可以運用何種思路去簡化並解決這種型別的問題,這是寫這篇文章最主要的動機,而 TDD 可以幫助到我。

問題回顧

到目前為止,程式是否存在更加優秀的設計? 這是上一篇文章結尾留下的一個反思,提這個問題的同時也引發了自己對程式設計的反思,到底應該通過什麼方法來降低錯誤設計和程式設計浪費(重寫和太多的重構)呢?

解題思路

目標: 得到簡潔可用的程式碼和合理的程式設計。

準備: 簡化案例為接下來的活動做好準備。

活動:

  1. 通過物件導向分析提煉領域模型或分析模型。
  2. 在領域模型的指導下實施物件導向設計得到設計模型。
  3. 視覺化領域模型和設計模型,以便於理解和分析。
  4. 任務分解。
  5. TDD。

物件導向分析

OOA 強調的是在問題領域內發現和描述物件(或概念), 關注重要概念類、屬性和關鍵關係,強調調查研究,而非解決方案, 最終提煉出領域模型。得到領域模型是我第一階段的目標。這裡還要強調的是,建模的目的是為了理解和溝通,以便於確認模型的合理性,所以建模是一項重要但不應該花太多時間的工作,既可以在白板上草圖繪製,也可以使用 UML 元素。

建立領域模型的步驟可以分為四步:

  1. 確認需求範圍。
  2. 尋找重要概念類。
  3. 視覺化(草圖或 UML)。
  4. 新增關聯關係和屬性。

經分析,由於案例難度一般,所以當前需求範圍涉及整個案例(或者當前迭代所涉及的需求),其中第三點需要學習相關的元素或者畫草圖即可,難度相對簡單,而第二點和第四點是整個建模過程中的關鍵步驟,直接影響到整個領域模型,所以把主要精力放在這兩個地方。

如何尋找概念類:

  1. 在已有的模型上調整和修改(效率較高,推薦)。
  2. 使用分類列表。
  3. 確定名詞短語(較為簡單,需要注意自然語言的二義性)。

這裡我使用"分類列表"策略來尋找重要概念類,在分析案例後我得到以下分類列表:

概念類類別 重要等級 示例
遊戲 重要 Game
遊戲規則 關鍵 Rule
參與者 重要 Teacher、Student
地點 無關緊要 Classroom

經分析,該案例中的核心在於遊戲規則,也是整個案例中的實現難點,Classroom 在當前需求中缺乏業務含義,可以剔除,然後我使用 UML 工具快速繪製初步的領域模型:

圖1

然後給模型加上關聯關係和屬性:

圖2

這個模型主要是站在參與者 Student 的角度進行分析和建模,也是我一開始的理解,但是經過分析,我發現這個模型有點混亂,Student 可能同時跟 TeacherGame 存在耦合關係, startplay也存在歧義,所以我再次站在參與者的角度對模型進行調整:

圖3

我把 Teacher 從領域模型中去掉,並且將 Student 概念抽象為 Player,此時整個領域模型變得簡潔多了,不過在進一步分析領域模型發現模型中的 PlayerGame 之間的關係非常奇怪,由 100 個玩家去玩一個遊戲,導致遊戲裡面包含了 100 個玩家物件,而且每個玩家的唯一標識僅僅是通過序號來區分,那麼 Player 到底是概念類還是屬性呢?這兩者要如何區分呢?

如何分辨概念類和屬性?

如果某個概念類不是現實世界中的數字和文字,那麼該概念類很可能是概念類而不是屬性,反之同理。

這是在《UML與模式應用》這本書的第九章中提到的準則,也正是這句話給了我靈感,通過分析,在這個案例中並沒有明確區分玩家是張三還是李四,因此“100個玩家”應該作為遊戲的一個屬性更加合適不過,所以我重新調整了模型:

圖4

此時的領域模型得到了簡化,領域聚焦也變得更加合理。那 Rule 的設計合理嗎?通過分析案例發現 GameRule 的關係存在問題,GameRule 並不是 1 : 3 的關係,而是 1 : 1 的關係,因為特殊數字只是遊戲規則的一部分,這個問題在程式設計階段可以非常明顯的反映出來,此時模型調整為:

圖5

我把一些需要重點關注的資訊標上了紅色,以便於聚焦自己的關注點。 雖然在識別概念類和建模的過程中遇到了一些小問題,但是最終還是得到一個合理的領域模型。領域模型是對概念內的概念類或現實世界中物件的視覺化表達,它去掉了問題域中的大部分細枝末節,只保留重要的領域概念,這對於分析問題和指導程式設計提供了非常大的幫助,不過有時候領域模型看不出來的問題在程式設計階段可能會反映出來,所以可以先採用領域模型指導程式設計,然後反過來通過程式設計的反饋來分析領域模型的合理性

思考:如何判斷領域模型是否正確?

不同的分析角度得到的領域模型可能存在異同,所以並沒有所謂正確的領域模型,模型只是近似地在嘗試描述一個實際領域,它可以有效地捕獲理解當前問題域所需要的重要資訊,幫助人們理解當前問題域中的概念、術語和關係,以便於提高開發人員和業務人員之間的理解和溝通效率。

思考:應該花多少時間去建立領域模型?

假設在為期 3 周的迭代中,建模時間最好不要超過一天,因為有很多因素阻礙我們建立一個"完美"的模型,例如業務需求變更、部分重要概念未被髮掘等等情況,所以應該把主要精力花在核心問題域的分析和建模,然後通過程式設計階段反過來去驗證模型的合理性,在 TDD 的過程中通過重構來發現隱式概念並提煉程式設計,因此在實踐過程中運用好 OOA 、 OOD 和 TDD 可以達到相輔相成的作用。

物件導向設計

OOD 關注軟體物件的職責和協作以實現軟體需求,強調得到滿足當前需求的概念上的解決方案(可以被實現,但不是具體實現,設計思想不關注具體實現),而實現則表達了真實且完整的設計,通常會使用 UML 互動圖和類圖進行視覺化

動態建模和靜態建模的區別

動態模型有助於設計邏輯和設計程式碼行為,通常會使用如下工具進行動態建模:

  • UML 互動圖
  • UML 順序圖
  • UML 活動圖
  • UML 狀態機圖
  • ...

靜態建模有助於設計包、類名、屬性和方法,通常會使用如下工具進行靜態建模:

  • UML 類圖
  • ...

其中最有價值、最具挑戰性、最有益和有效的設計工作基本上都發生在動態建模的過程中,在動態建模的過程中可以明確知道有哪些物件,物件之間如何進行互動,所以應該在這方面花費更多的精力

思考:領域模型和設計模型(UML互動圖、類圖)之間是什麼關係?

設計模型並不是完完全全臨摹領域模型,因為這兩個階段的目標完全不同, 領域模型描述的是真實世界領域內的概念類,設計模型描述的是軟體類(可以被某種程式語言實現),因此設計階段需要結合程式語言、程式設計思想和設計原則,而領域模型在這裡可以給予設計師靈感並指導程式設計。

在明確設計建模的輸入(領域模型)和輸出(設計模型)之後,現在需要明確輸入到輸出的中間過程。在進行動態建模的過程中,我主要採用了職責驅動設計和 GRASP來幫助我實施這一過程(通常可能還會有更多的活動,比如開討論會等)。

知識:職責驅動設計

職責驅動設計把軟體物件想象成具有某種職責的人,這個人要與其他人協作以完成工作,這個過程中會考慮職責、角色和協作三大要素。

知識:GRASP

GRASP 是 GRAS Pattern 的縮寫,是一系列模式的統稱,它可以以一種系統的、合理的、可以被解釋的方式來運用職責驅動設計,並進行設計推理,詳細描述請閱讀《UML與模式應用》。

構建 UML 互動圖

UML 互動圖用於動態物件建模,它可以幫助描述物件之間的互動。

在經過上面一番對 OOD 的理解之後,這裡我使用 UML 互動圖中的順序圖(UML互動圖還包括通訊圖等)來描述物件之間的互動,在領域模型的指導下我得到一個初步的 UML 互動圖:

圖6

這個圖體現了 GameRule 的職責和協作,Game 承擔玩遊戲 play(specialNumbers) 職責,Rule 承擔核心的規則匹配 match(number) 職責,GameRule 的協作體現在物件的建立和方法的呼叫。

到這裡 UML 互動圖總算大功告成,接下來進入設計類圖階段。

構建 UML 類圖

類圖用於靜態物件建模,可以用來描述類、介面、屬性及其關聯關係。

在領域模型和 UML 互動圖的指導下,類圖已經變得非常簡單,因為大部分資訊都已經在 UML 互動圖體現出來,所以我根據 UML 互動圖快速設計了以下類圖:

圖7

到這裡 UML 類圖構建完畢,在進行任務分解後就可以開始 TDD。

任務分解

OOD 已經讓我得到了實現需求的解決方案,有趣的是設計模型很大程度上已經幫我完成了任務分解的過程,所以我根據設計模型修改了一開始的任務清單。

舊的任務清單:

  1. 發起遊戲。
  2. 定義遊戲規則。
  3. 說出 3 個不重複的個位數數字。
  4. !!! 學生報數。
    1. 如果是第一個特殊數字的倍數,就報 Fizz。
    2. 如果是第二個特殊數字的倍數,就報 Buzz。
    3. 如果是第三個特殊數字的倍數,就報 Whizz。
    4. 如果同時是多個特殊數字的倍數,需要按特殊數字的順序把對應的單詞拼接起來再報出,比如 FizzBuzz、BuzzWhizz、FizzBuzzWhizz。
    5. 如果包含第一個特殊數字,只報 Fizz (忽略規則 1、2、3、4)
    6. 如果不是特殊數字的倍數,並且不包含第一個特殊數字,就報對應的序號。
  5. 驗證入參。

修改後的任務清單:

  1. 生成 3 個不重複的個位數數字。
  2. !!! 定義遊戲規則。
    1. 如果是第一個特殊數字的倍數,就報 Fizz。
    2. 如果是第二個特殊數字的倍數,就報 Buzz。
    3. 如果是第三個特殊數字的倍數,就報 Whizz。
    4. 如果同時是多個特殊數字的倍數,需要按特殊數字的順序把對應的單詞拼接起來再報出,比如 FizzBuzz、BuzzWhizz、FizzBuzzWhizz。
    5. 如果包含第一個特殊數字,只報 Fizz (忽略規則 1、2、3、4)
    6. 如果不是特殊數字的倍數,並且不包含第一個特殊數字,就報對應的序號。
    7. 重寫 Student 類,使用 Rule 替換 Student
  3. 驗證入參。

測試驅動開發

Kent Beck:“測試驅動開發不是一種測試技術。它是一種分析技術、設計技術,更是一種組織所有開發活動的技術”。

有效的分析和設計可以概括為:做正確的事(分析)和正確地做事(設計),這應該就是 TDD 中提到的分析技術和設計技術吧。

在之前的 TDD 實踐文章中我驅動出了 Student 併為其分配了報數 countOff() 職責,但是這跟目前的設計模型完全不一樣,好在 TDD 讓程式留下了單元測試,可以在自動化測試的支撐下進行安全的重寫,所以我運用了以下策略幫助我完成重寫任務:

  1. 在不修改原來的程式碼的前提下逐漸進行小範圍重寫和替換
    1. Student 替換成 Rule,並使自動化測試通過。
    2. 引入並初始化 List<Rule.Item> items 成員變數,用以儲存特殊數字和單詞的對映關係。
    3. 建立 match(number) 方法。
    4. 逐漸使所有測試通過的方式驅動 match(number) 方法的實現。
    5. 在保證新引入的程式碼全部通過測試之後刪除舊程式碼。
  2. 執行自動化測試以保證沒有引入新的錯誤。
  3. 如果引入錯誤則馬上修改使測試通過。
  4. 回到第一步,直到完成重寫任務。
  5. 保證重構任務完成和所有測試通過的情況下,刪除多餘的程式碼。

遵循上面的重寫策略最終我得到了以下程式碼:

public class RuleTest {

    final Rule rule = new Rule(Arrays.asList(3, 5, 7));

    @Test
    public void should_return_1_when_mismatch_any_number() {
        assertThat(rule.match(1)).isEqualTo("1");
    }

    @Test
    public void should_return_fizz_when_just_a_multiple_of_the_first_number() {
        assertThat(rule.match(3)).isEqualTo("Fizz");
        assertThat(rule.match(6)).isEqualTo("Fizz");
    }

    @Test
    public void should_return_buzz_when_just_a_multiple_of_the_second_number() {
        assertThat(rule.match(5)).isEqualTo("Buzz");
        assertThat(rule.match(10)).isEqualTo("Buzz");
    }

    @Test
    public void should_return_whizz_when_just_a_multiple_of_the_third_number() {
        assertThat(rule.match(7)).isEqualTo("Whizz");
        assertThat(rule.match(14)).isEqualTo("Whizz");
    }

    @Test
    public void should_return_fizzbuzz_when_just_a_multiple_of_the_first_number_and_second_number() {
        assertThat(rule.match(15)).isEqualTo("FizzBuzz");
        assertThat(rule.match(45)).isEqualTo("FizzBuzz");
    }

    @Test
    public void should_return_fizzwhizz_when_just_a_multiple_of_the_first_number_and_third_number() {
        assertThat(rule.match(21)).isEqualTo("FizzWhizz");
        assertThat(rule.match(42)).isEqualTo("FizzWhizz");
    }

    @Test
    public void should_return_buzzwhizz_when_just_a_multiple_of_the_second_number_and_third_number() {
        assertThat(rule.match(70)).isEqualTo("BuzzWhizz");
    }

    @Test
    public void should_return_fizzbuzzwhizz_when_at_the_same_time_is_a_multiple_of_the_three_number() {
        Rule rule = new Rule(Arrays.asList(2, 3, 4));
        assertThat(rule.match(48)).isEqualTo("FizzBuzzWhizz");
        assertThat(rule.match(96)).isEqualTo("FizzBuzzWhizz");
    }

    @Test
    public void should_return_fizz_when_included_the_first_number() {
        assertThat(rule.match(3)).isEqualTo("Fizz");
        assertThat(rule.match(13)).isEqualTo("Fizz");
        assertThat(rule.match(30)).isEqualTo("Fizz");
        assertThat(rule.match(31)).isEqualTo("Fizz");
    }
}

public class Rule {

    private List<Item> items;

    public Rule(final List<Integer> specialNumbers) {
        this.items = new ArrayList<>(3);
        this.items.add(new Item(specialNumbers.get(0), "Fizz"));
        this.items.add(new Item(specialNumbers.get(1), "Buzz"));
        this.items.add(new Item(specialNumbers.get(2), "Whizz"));
    }

    public String match(Integer number) {
        if (number.toString().contains(items.get(0).getNumber().toString())) {
            return items.get(0).getWord();
        }
        return items
                .stream()
                .filter(item -> isMultiple(number, item.getNumber()))
                .map(item -> item.getWord())
                .reduce((w1, w2) -> w1 + w2)
                .orElse(number.toString());
    }

    private boolean isMultiple(Integer divisor, Integer dividend) {
        return divisor % dividend == 0;
    }


    private class Item {
        private Integer number;
        private String word;

        public Item(Integer number, String word) {
            this.number = number;
            this.word = word;
        }

        public Integer getNumber() {
            return number;
        }

        public String getWord() {
            return word;
        }
    }
}
複製程式碼

整個過程對原始的程式碼改動稍微有點大,不過在自動化測試的支撐下還是非常順利地完成了重寫任務,接下來可以進入重構環節識別程式碼中的"壞味道",

if (number.toString().contains(items.get(0).getNumber().toString())) {
    return items.get(0).getWord();
}
複製程式碼

通過分析發現上面的判斷條件表達的意圖不太清晰,因此我通過重構手法 Extract Method 來提高程式碼的表達能力:

public class Rule {
    ...
    public String match(Integer number) {
        if (isContainFirstSpecialNumber(number)) {
            return items.get(0).getWord();
        }
        return items
                .stream()
                .filter(item -> isMultiple(number, item.getNumber()))
                .map(item -> item.getWord())
                .reduce((w1, w2) -> w1 + w2)
                .orElse(number.toString());
    }

    private boolean isMultiple(Integer divisor, Integer dividend) {
        return divisor % dividend == 0;
    }

    private boolean isContainFirstSpecialNumber(Integer number) {
        if (number.toString().contains(items.get(0).getNumber().toString())) {
            return true;
        }
        return false;
    }
    
    ...
}
複製程式碼

執行單元測試保證所有的測試通過

TDD 實踐-FizzFuzzWhizz(三)

TDD 成果

任務清單:

  1. 生成 3 個不重複的個位數數字。
  2. !!! 定義遊戲規則。
    1. 如果是第一個特殊數字的倍數,就報 Fizz。
    2. 如果是第二個特殊數字的倍數,就報 Buzz。
    3. 如果是第三個特殊數字的倍數,就報 Whizz。
    4. 如果同時是多個特殊數字的倍數,需要按特殊數字的順序把對應的單詞拼接起來再報出,比如 FizzBuzz、BuzzWhizz、FizzBuzzWhizz。
    5. 如果包含第一個特殊數字,只報 Fizz (忽略規則 1、2、3、4)
    6. 如果不是特殊數字的倍數,並且不包含第一個特殊數字,就報對應的序號。
    7. 重寫 Student 類,使用 Rule 替換 Student
  3. 驗證入參。

測試報告:

TDD 實踐-FizzFuzzWhizz(三)

測試覆蓋率:

TDD 實踐-FizzFuzzWhizz(三)

總結

壞訊息是一開始因缺乏分析和設計留下來的坑遲早要填,好訊息是通過上面的分析,這種問題是可以得到很大程度上的控制,先通過對問題進行分析和建模,再通過領域模型指導程式設計可以有效的降低錯誤設計的概率,在解決複雜問題域的時候效果更加明顯,不過需要注意的是 TDD 主張簡單設計,在保證程式碼可用的前提下追求程式碼簡潔,在重構中消除程式碼壞味道,並對原有的設計模型進行微觀層面的演化和提煉,這種方式可以避免不同程度的浪費(設計浪費、不必要的重寫、頻繁重構和糾結等)

閱讀系列文章

原始碼

github.com/lynings/tdd…


歡迎關注我的微信訂閱號,我將會持續輸出更多技術文章,希望我們可以互相學習。

TDD 實踐-FizzFuzzWhizz(三)

相關文章