[30天快速上手TDD][Day 18]Refactoring - Factory Pattern

[30天快速上手TDD][Day 18]Refactoring - Factory Pattern

前言

上篇文章透過簡單的重構一個 function ,將相同的部份抽出判斷式外,讓不同的部份影響範圍最低。因此解決了我們有著重複程式碼的問題。

更重要的是,透過這一個過程,僅運用基本原則,解決眼前問題,結果卻就是設計模式中策略模式的方式。希望藉此帶出,設計模式其實重點是為了解決問題,只要運用基本精神、原則,也能達到一樣的效果。

重構到這,其實複雜度、可讀性、可維護性已經都相當漂亮,但筆者打算再舉個例子,來當重構這一系列手法的 ending 。因為這兩招,是所有重構中最常見的情況,也是筆者效益最高、成本最低的手法。

「把 new 的動作,放到同一個 class 裡」,我想,一樣 3 分鐘讀者朋友就可以 pick up 起來了。

 

目前的程式碼

目前頁面的程式碼,已經讓 context 的邏輯清晰可讀,選擇哪一間物流商,只會影響在執行時,介面是由哪一個物流商實體物件來實際運作。

程式碼如下:

protected void btnCalculate_Click(object sender, EventArgs e)
{
    //若頁面通過驗證
    if (this.IsValid)
    {
        //取得畫面資料
        var product = this.GetProduct();

        var companyName = "";
        double fee = 0;

        ILogistics logistics = this.GetILogistics(this.drpCompany.SelectedValue, product);
        if (logistics != null)
        {
            logistics.Calculate();
            companyName = logistics.GetsComapanyName();
            fee = logistics.GetsFee();

            //呈現結果
            this.SetResult(companyName, fee);
        }
        else
        {
            var js = "alert('發生不預期錯誤,請洽系統管理者');location.href='http://tw.yahoo.com/';";
            this.ClientScript.RegisterStartupScript(this.GetType(), "back", js, true);
        }            
    }
}

/// <summary>
/// 將ILogistics的instance,交給工廠來決定
/// </summary>
/// <param name="p"></param>
/// <param name="product"></param>
/// <returns></returns>
private ILogistics GetILogistics(string company, Product product)
{
    if (company == "1")
    {
        return new BlackCat() { ShipProduct = product };
    }
    else if (company == "2")
    {
        return new Hsinchu() { ShipProduct = product };
    }
    else if (company == "3")
    {
        return new PostOffice() { ShipProduct = product };
    }
    else
    {            
        return null;
    }
}

 

回顧

[30天快速上手TDD][Day 13]Refactoring - 告訴我,你要什麼 文章中,筆者留下了一個疑問:

為什麼在頁面職責的部分,有一個「建立物流商物件」的動作要被highlight出來呢?

前面幾篇文章一直提到,在物件導向的設計中,釐清楚每個物件所該負責的職責,是相當重要的一件事。除了要符合單一職責原則(SRP)以外,更重要的是,這可以幫助我們達到關注點分離,程式碼重複使用的目的。

回到眼前的程式碼,假設要把職責切的更乾淨,那麼建立物流商的動作,應該與頁面無關,而應該建立一個工廠來幫忙初始化物流商的物件執行個體。

這邊套用的就是工廠模式(工廠有很多種,這邊以簡單工廠為例,其他例如 Factory Method PatternAbstract Factory Pattern 也都是類似的作法)。

 

重構第九式:運用 Design Pattern -工廠模式

在建立工廠之前,可以用一樣的重構循環來做,先定義工廠應該回傳什麼結果,建立單元測試。

/// <summary>
///GetILogistics 的測試
///</summary>
[TestMethod()]
public void GetILogisticsTest_GetBlackCat()
{
    //arrange
    string p = "1";
    Product product = new Product();
    ILogistics expected = new BlackCat();

    ILogistics actual;

    //act
    actual = FactoryRepository.GetILogistics(p, product);

    //assert
    Assert.AreEqual(expected.GetType(), actual.GetType());
}

/// <summary>
///GetILogistics 的測試
///</summary>
[TestMethod()]
public void GetILogisticsTest_Get新竹貨運()
{
    //arrange
    string p = "2";
    Product product = new Product();
    ILogistics expected = new Hsinchu();

    ILogistics actual;

    //act
    actual = FactoryRepository.GetILogistics(p, product);

    //assert
    Assert.AreEqual(expected.GetType(), actual.GetType());
}

/// <summary>
///GetILogistics 的測試
///</summary>
[TestMethod()]
public void GetILogisticsTest_Get郵局()
{
    //arrange
    string p = "3";
    Product product = new Product();
    ILogistics expected = new PostOffice();

    ILogistics actual;

    //act
    actual = FactoryRepository.GetILogistics(p, product);

    //assert
    Assert.AreEqual(expected.GetType(), actual.GetType());
}

這時候為紅燈。

接著把頁面根據條件建立物流商的內容,填入到工廠類別中。

public class FactoryRepository
{
    /// <summary>
    /// 將ILogistics的instance,交給工廠來決定
    /// </summary>
    /// <param name="company"></param>
    /// <param name="product"></param>
    /// <returns></returns>
    public static ILogistics GetILogistics(string company, Product product)
    {
        if (company == "1")
        {
            return new BlackCat() { ShipProduct = product };
        }
        else if (company == "2")
        {
            return new Hsinchu() { ShipProduct = product };
        }
        else if (company == "3")
        {
            return new PostOffice() { ShipProduct = product };
        }
        else
        {
            return null;
        }
    }
}

接著頁面改為呼叫工廠來決定使用哪一個物流商類別,對頁面來說,根本就不管用哪一個物流商,只管對物流商介面取得物流商名稱,以及計算運費的結果。

protected void btnCalculate_Click(object sender, EventArgs e)
{
    //若頁面通過驗證
    if (this.IsValid)
    {
        //取得畫面資料
        var product = this.GetProduct();

        var companyName = "";
        double fee = 0;          

        //ILogistics logistics = this.GetILogistics(this.drpCompany.SelectedValue, product);
        ILogistics logistics = FactoryRepository.GetILogistics(this.drpCompany.SelectedValue, product);

        if (logistics != null)
        {
            logistics.Calculate();
            companyName = logistics.GetsComapanyName();
            fee = logistics.GetsFee();

            //呈現結果
            this.SetResult(companyName, fee);
        }
        else
        {
            var js = "alert('發生不預期錯誤,請洽系統管理者');location.href='http://tw.yahoo.com/';";
            this.ClientScript.RegisterStartupScript(this.GetType(), "back", js, true);
        }            
    }
}

執行所有的測試,確保整個重構過程沒有改變行為。

 

小結

本篇文章的重構精神在於:「把初始化物件的部份,抽離 context 端。」

slide

簡單的說:「生成物件的動作,與使用物件的動作,一定要分開。

通常生成物件我們會透過Builder, Factory, Repository 等 pattern 來實作。

為什麼生成物件的動作與使用物件的動作分開,有這麼重要呢?因為一個物件定義好之後,目的就是為了讓這個職責交給這個物件負責,大家使用時,不需要寫重複的程式碼,或開發相同職責的物件。所以一個物件可能會到處被使用。

當需求異動時,我們希望透過擴充,而不是修改(開放封閉原則:對擴充開放,對修改封閉),也就是新增一個新的物件,完成新的需求,就能直接取代掉舊的物件,甚至有條件的切換新物件與舊物件,讓這兩個商業邏輯可以並存。

這時,若生成物件的條件散落一地,或是到處都有生成物件的動作,那麼肯定要抽換物件時,需要修改許多地方。而在物件中,直接生成相依物件的動作,也違反了依賴反轉原則( DIP ),這會使得物件之間耦合性太高。

舉個最簡單的例子:假設我們透過一個 Hello 的物件,呼叫其 Say() 方法,會 print: "Hello, 91",程式碼如下圖所示:

hello

如此一來, Program 物件就與 Hello 物件綁在一起。假設今天需求異動,肯定不是要改 Program 物件,就是要改 Hello 物件的內容,而這兩者都違背了開放封閉原則。

假設今天需求異動變成,在某種情況下, Hello.Say() 都要改成 "您好,九一"。

如果有透過工廠來隔離,則原本程式碼會變成如何呢?如下圖所示:

Factory

只是把 new 的動作放到一個專門生成物件的工廠類別中, effort 很小,可讀性與可擴充性也仍很高。

接著碰到這樣的需求,我們只要新增一個類別: ChineseHello 類別,繼承 Hello 類別,覆寫其 Say() 方法即可。如下圖所示:

chineseHello

您可能會說,如果 Hello 的方法,不是宣告成 virtual 怎麼辦?這時就用的到前面文章提到的:[30天快速上手TDD][Day 16]Refactoring - 介面導向

Context 端透過相依於介面,就沒有「不能 override 」的問題了。

透過這樣的方式來設計,幾乎可以將所有的判斷式,都拉到工廠類別中。每一個 if/else if 中的 block ,都可以是同一個介面,不同實作類別的方法。接著將 if/else if 放到工廠中,以切換提供不同的實體物件給 context 用。

未來需求異動,我們只需要:

  1. 新增新的 class ,實作同一個介面;
  2. 在工廠類別中,將原本生成舊物件的部份,切到回傳新的物件。

只要上層抽象邏輯沒有異動, context 端就不需要修改任何程式,舊的物件也不需要修改任何程式。只需要新增新類別,實作同介面,工廠切換回傳新類別即可。

讓我再重複一次,「在設計時,請務必把生成物件與使用物件的動作分開」,當未來需求異動時,您就會感謝之前的自己種的這棵小樹,而不是怨恨之前的王八蛋,挖的這個大洞。

最後要提醒一下,「靜態類別/方法」是相當不容易測試,也就代表其相當不容易抽換模組。所以,筆者目前的原則是:「除非必要,否則不宣告成 static 」。

對敏捷開發有興趣的朋友,可以參考我的粉絲專頁:91敏捷開發之路

對 TDD 課程有興趣的朋友,課程內容、大綱與學員心得,可以參考 skilltree 的公開課程:自動測試與 TDD 實務開發

若需要聯絡我,可以透過粉絲專頁私訊或是側欄的關於我。