[30天快速上手TDD][Day 17]Refactoring - Strategy Pattern

[30天快速上手TDD][Day 17]Refactoring - Strategy Pattern

前言

在上篇文章中,我們將各個物流商的物件,抽象化出來一個物流商的介面,這個介面提供了當下頁面物件所需要的功能:

  1. 計算運費
  2. 取得運費結果
  3. 取得物流商名稱

雖然頁面物件仍與物流商物件直接相依,但在 context 端已經是「使用介面」,而不管各物流商物件背後的實作了。

這篇文章,標題雖然帶著「Strategy Pattern」,也就是策略模式,但不熟 Design Patterns 的讀者朋友不用擔心,保持著心中無招即可。我們只需要把程式碼的壞味道用最自然的方式重構,您就會體會到 Strategy Pattern 的樣子、目的、用法, Strategy Pattern 將會自動的浮現出來。

記得,雖是心中無招,但仍有心法,也就是 OO 的 SOLID 原則,是我們重構的底限。

只是重構一個判斷式,把一樣的東西留著,不一樣的東西抽成 function ,我想... 3 分鐘應該還是很夠用了

 

目前的程式碼

為方便閱讀重構前後的程式碼比較,這邊先列出截至目前為止,我們的頁面程式碼如下所示:


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

        var companyName = "";
        double fee = 0;

        //選黑貓,計算出運費
        if (this.drpCompany.SelectedValue == "1")
        {
            //計算
            //BlackCat blackCat = new BlackCat() { ShipProduct = product };
            //blackCat.Calculate();
            //companyName = blackCat.GetsComapanyName();
            //fee = blackCat.GetsFee();
            ILogistics logistics = new BlackCat() { ShipProduct = product };
            logistics.Calculate();
            companyName = logistics.GetsComapanyName();
            fee = logistics.GetsFee();


        }
        //選新竹貨運,計算出運費
        else if (this.drpCompany.SelectedValue == "2")
        {
            //計算
            //Hsinchu hsinchu = new Hsinchu() { ShipProduct = product };
            //hsinchu.Calculate();
            //companyName = hsinchu.GetsComapanyName();
            //fee = hsinchu.GetsFee();

            ILogistics logistics = new Hsinchu() { ShipProduct = product };
            logistics.Calculate();
            companyName = logistics.GetsComapanyName();
            fee = logistics.GetsFee();
        }
        //選郵局,計算出運費
        else if (this.drpCompany.SelectedValue == "3")
        {
            //計算
            //PostOffice postOffice = new PostOffice() { ShipProduct = product };
            //postOffice.Calculate();
            //companyName = postOffice.GetsComapanyName();
            //fee = postOffice.GetsFee();

            ILogistics logistics = new PostOffice() { ShipProduct = product };
            logistics.Calculate();
            companyName = logistics.GetsComapanyName();
            fee = logistics.GetsFee();
        }
        //發生預期以外的狀況,呈現警告訊息,回首頁
        else
        {
            var js = "alert('發生不預期錯誤,請洽系統管理者');location.href='http://tw.yahoo.com/';";
            this.ClientScript.RegisterStartupScript(this.GetType(), "back", js, true);
        }

        //呈現結果
        this.SetResult(companyName, fee);
    }
}

 

回顧

重構到這,其實已經很充足了,職責已經分離,也透過介面來降低耦合,也有對應的整合測試與單元測試。

不過如同一開始重構的時機點所說,當我們為了需求或 bug 而修改功能時,其實可以再思考一下,這樣類似的需求會不會再發生。這樣的情況,有沒有合適的 pattern 可以解決我們的需求與問題。

首先切換回人話模式,眼前的功能需求,用人話來描述就是:『不同物流商,使用對應的計價方法』。用 Design Pattern 的用詞來說,就是:『根據條件,決定對應的演算法』。也就是策略模式(strategy pattern)。

雖然提到了策略模式,但不熟 Design Patterns 的讀者朋友不用擔心,我們只需要把程式碼的壞味道用最自然的方式重構,您就會體會到 Strategy Pattern 的樣子、目的、用法, Strategy Pattern 將會自動的浮現出來。

 

重構第九式:運用Design Pattern-策略模式

上面已經提到了,這段程式碼一言以蔽之,就是「不同物流商,使用對應的計價方法」,讓我們回過頭來看現在的程式碼,有哪些部分是相同的,哪些部分是不同的,如下圖所示:

相同與不同的部分

可以看到經過抽象地使用介面之後,紅色方塊中的程式碼,已經是一模一樣了。不同的部分,是黃色方塊中的程式碼,也就是上面人話描述的「選擇不同物流商時,要使用不同的計價方法」。

如同 DRY (Don't Repeat Yourself) 設計原則所說,在設計系統時,應避免同樣一樣事,卻有著重複的程式碼的情況。一式多份,代表需求異動時,需要變更多份,代表不符合單一職責原則(SRP),也代表著可能會有漏改的情況。

以這例子來說,聰明如各位讀者,肯定知道,怎麼把相同的部分與不同的部分,抽到一個 function 中,只需要讓不同的部分變成參數傳入即可。

重構後的程式碼如下所示:


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

        var companyName = "";
        double fee = 0;

        ////選黑貓,計算出運費
        //if (this.drpCompany.SelectedValue == "1")
        //{
        //    //計算
        //    //BlackCat blackCat = new BlackCat() { ShipProduct = product };
        //    //blackCat.Calculate();
        //    //companyName = blackCat.GetsComapanyName();
        //    //fee = blackCat.GetsFee();
        //    ILogistics logistics = new BlackCat() { ShipProduct = product };
        //    logistics.Calculate();
        //    companyName = logistics.GetsComapanyName();
        //    fee = logistics.GetsFee();


        //}
        ////選新竹貨運,計算出運費
        //else if (this.drpCompany.SelectedValue == "2")
        //{
        //    //計算
        //    //Hsinchu hsinchu = new Hsinchu() { ShipProduct = product };
        //    //hsinchu.Calculate();
        //    //companyName = hsinchu.GetsComapanyName();
        //    //fee = hsinchu.GetsFee();

        //    ILogistics logistics = new Hsinchu() { ShipProduct = product };
        //    logistics.Calculate();
        //    companyName = logistics.GetsComapanyName();
        //    fee = logistics.GetsFee();
        //}
        ////選郵局,計算出運費
        //else if (this.drpCompany.SelectedValue == "3")
        //{
        //    //計算
        //    //PostOffice postOffice = new PostOffice() { ShipProduct = product };
        //    //postOffice.Calculate();
        //    //companyName = postOffice.GetsComapanyName();
        //    //fee = postOffice.GetsFee();

        //    ILogistics logistics = new PostOffice() { ShipProduct = product };
        //    logistics.Calculate();
        //    companyName = logistics.GetsComapanyName();
        //    fee = logistics.GetsFee();
        //}
        ////發生預期以外的狀況,呈現警告訊息,回首頁
        //else
        //{
        //    var js = "alert('發生不預期錯誤,請洽系統管理者');location.href='http://tw.yahoo.com/';";
        //    this.ClientScript.RegisterStartupScript(this.GetType(), "back", js, true);
        //}

        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;
    }
}

相同的部分,也就是頁面(在這為 context ,使用場景端)所關心的職責,如下圖所示:

context

讀者朋友們,從程式碼去閱讀這個計算運費按鈕的邏輯,去體會一下,程式碼會說話的感覺:

  1. 如果頁面 Validation 通過驗證
  2. 取得頁面上商品資訊
  3. 取得對應的物流商
  4. 請物流商計算運費
  5. 取得物流商名稱
  6. 取得運費結果
  7. 將名稱與運費結果呈現到頁面上

不同的部分,則是要想辦法限縮到最小範圍,也就是:究竟這個條件,只會影響哪些東西不同。相同的部分,請放到判斷式以外。如下圖所示:

不同的部分

不同的部分,指的是「畫面上選擇哪一間物流商」,而這個判斷,只會影響要使用哪一個物流商物件。而所有的物流商物件,都符合「物流商介面」(不論是繼承或實作,都是 Is-A 的關係)。

到這邊,就只是透過一個 function ,將不同的部分放到參數中,以決定回傳哪一個物流商物件。相同的部分,則放到判斷式之外,用來描述 context 的流程與商業邏輯。

恭喜您,這就是策略模式。

如 wiki 上的描述:

the strategy pattern (also known as the policy pattern) is a particular software design pattern, whereby algorithms can be selected at runtime.

也就是,在執行階段時,可以依據不同情況選擇不同的演算法。

來看一下 wiki 上 strategy pattern 的 class diagram :

在這個例子裡,我們的程式碼若畫成 class diagram ,就是按照這樣的 pattern 所設計。如下圖所示:

strategy pattern class diagram

 

小結

策略模式,難嗎?如果您已經把程式碼重構成這副模樣,相信我,你真的不必懂「策略模式」這四個字。因為我們重構用的就只是最基本的物件導向精神與設計原則。

但,這也不代表著開發人員就不需要瞭解或學習設計模式。設計模式,就像 UML 一樣,除了可以拿來當作特定類型問題的 guidance 藍圖,也很常拿來溝通。當開發人員或分析設計人員,針對某一個情境、需求或問題時,可能只需要用「策略模式」四個字,就可以讓每個人心裡面有著基本的 class model ,並快速的 mapping 到眼前的情境。

想像一下,以這例子,每個人眼前面對的是重構前的程式碼,一個人提出:我們可以透過「策略模式」來重構,來把重複的程式碼降到最低,職責分離,並且對擴充開放,對修改封閉。這時,如果學習並瞭解過策略模式,大家腦袋裡基本上就會把頁面放到 context ,把抽象職責相同的部分淬練出一個介面,讓每個物件不同的實作細節封裝起來,頁面只需要透過介面,就能保持一致。

心中無招,就能不被設計模式的框框給設限住。但無招不代表亂七八糟,而是掌握最基本的精神、原則,針對眼前的問題,使用者的需求來解決。

 

建議

讀者朋友可以試試,當碰到一個問題或需求時,先別去尋找哪一個 pattern 適用,而是透過這一系列的方式,先動手重構。直到您覺得重構完成了,接著去看這樣的問題,適合用哪一種 pattern ,接著比對您的設計與 GoF 原生的 design pattern ,有何異同。

接著用心去體會,不同的地方,是否屬於自己情境或問題下,需客製化或變形的部分。還是單純設計的冗贅,不夠精簡、精準。

如果是後者,恭喜你,你趁機學到了自己之前的盲點,再下一次的需求,您就更能使出 pattern 中的精妙之處。

如果是前者,恭喜你,您可以理解在自己的問題領域中,除了最原生的問題解決了,還更彈性地符合了使用者的需求。

去體會箇中差異,才能活學活用。設計模式,只是一些常見的問題領域,所衍生出常見的模式解決方式,它是一種最普遍、最抽象、最基礎的解決方式,不要去強求自己的設計所產生出來的 class diagram 一定要跟原著或 wiki 上圖形一模一樣,但絕對要能清楚說出來,為何不一樣。

最後,在重構中設計模式的確是一種很方便、快速、好用的手法,但這邊要強調的是,開發人員應該要能由需求、問題、 legacy code 當出發點,在重構的過程中,實踐並體會出,由原始程式碼演變成某一種或多種設計模式所搭配設計的最終結果。如此一來,您才真的能體會到設計模式的髓。(因為設計模式的演變過程,絕大部分也正是從重構而來)

當您已經能完全體會且累積了許多相同問題領域的重構手法後,面對這篇文章範例這類的問題,心中無想,就會自然而然的使出策略模式來解決。

 


最後搞笑一下,下面是大家很熟稔的一段台詞:

張三豐:無忌,你有九陽神功護體,學什麼武功都特別快,太極拳只重其義,不重其招,你忘記所有招式,就練成太極拳了!

張三豐:你記住了沒有?
張無忌:沒記住!
張三豐:這套叫什麼拳?
張無忌:不知道!
張三豐:你老爸姓什麼呢?
張無忌:我忘了!
張三豐:好!你只要記住把這兩渾蛋打成廢人就行了!

忘記了
(圖片來源:youtube: http://www.youtube.com/watch?v=FaGUA-hUsys)

基本上就是這樣,物件導向的基本意義、目的、精神與原則,就像這邊的九陽神功,有九陽神功護體,學什麼 pattern 都快。

不必強記這是什麼 pattern ,只要記得:可以解決你的問題,滿足使用者的需求就行了!

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

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

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