[ASP.NET]重構之路番外篇 –Refactoring to Patterns

[ASP.NET]重構之路番外篇 –Refactoring to Patterns

前言
先前應大澤木小鐵的邀約,負責Web Dev Party第二場的一個Session,題目是『ASP.NET: Refactoring to Patterns』,既然都已經有準備了,這邊就寫一篇文記錄一下。 這一次分享的內容,簡單的說就是一整套的重構過程,不懂 ASP.NET與Design patterns的朋友不用擔心,重點是Refactoring的過程與精神,建議搭配demo影片服用,因為透過demo的重構過程,可以更有感覺,也可以更加確定,我們每個人都絕對可以無痛重構!

投影片:
當天的錄影檔(重點是demo啊):

ASP.NET: Refactoring to Patterns from mOrris32 on Vimeo.


現況
我們所面臨的系統狀況,通常也就是Legacy Code(提到Legacy Code,就要順便介紹一本好書:Working Effectively with Legacy Code),就像下圖一樣:
image
圖片來源:
http://www.chancedia.com/?p=41470

每個Dev都喜歡乾淨的code,但是又喜歡把code弄髒。

重構的目的
我們希望可以把雜亂無章的code,乾淨整齊的放在它們所屬的位置上。
image
圖片來源:
http://jung9572002.pixnet.net/blog/post/1733351-%E6%94%B6%E7%B4%8D%E9%81%94%E4%BA%BA

重構的時機與目標
基本上最適合重構的時機有三類:

  1. Debug
  2. 需求異動
  3. 系統有Bad Smell的地方


簡單的說,就是要修改程式的時候,或是程式很髒的時候,適合重構。

如何找出Bad Smell
這邊以SourceMonitor為例,來找出系統中複雜度太高的function,並將它當做我們重構的目標。(SourceMonitor的介紹,有興趣的朋友可以看之前這篇文章:[Tool]SourceMonitor - 程式碼掃瞄

掃描後,按照Max Complexity排序,可以看到Prodcut_v0.aspx.cs,最大複雜度14,最大深度5。
image

再點開詳細資訊後,可以看到 btnCalculate_Click這個方法,就是造成最大複雜度與最大深度的原因。
image

接著,來看一下這個function的程式碼,如下所示:

    protected void btnCalculate_Click(object sender, EventArgs e)
    {
        if (this.IsValid)
        {
            if (this.drpCompany.SelectedValue == "1")
            {
                this.lblCompany.Text = "黑貓";
                var weight = Convert.ToDouble(this.txtProductWeight.Text);
                if (weight > 20)
                {
                    this.lblCharge.Text = "500";
                }
                else
                {
                    var fee = 100 + weight * 10;
                    this.lblCharge.Text = fee.ToString();
                }
            }
            else if (this.drpCompany.SelectedValue == "2")
            {
                this.lblCompany.Text = "新竹貨運";
                var length = Convert.ToDouble(this.txtProductLength.Text);
                var width = Convert.ToDouble(this.txtProductWidth.Text);
                var height = Convert.ToDouble(this.txtProductHeight.Text);

                var size = length * width * height;

                //長 x 寬 x 高(公分)x 0.0000353
                if (length > 100 || width > 100 || height > 100)
                {
                    this.lblCharge.Text = (size * 0.0000353 * 1100 + 500).ToString();
                }
                else
                {
                    this.lblCharge.Text = (size * 0.0000353 * 1200).ToString();
                }
            }
            else if (this.drpCompany.SelectedValue == "3")
            {
                this.lblCompany.Text = "郵局";

                var weight = Convert.ToDouble(this.txtProductWeight.Text);
                var feeByWeight = 80 + weight * 10;

                var length = Convert.ToDouble(this.txtProductLength.Text);
                var width = Convert.ToDouble(this.txtProductWidth.Text);
                var height = Convert.ToDouble(this.txtProductHeight.Text);
                var size = length * width * height;
                var feeBySize = size * 0.0000353 * 1100;

                if (feeByWeight < feeBySize)
                {
                    this.lblCharge.Text = feeByWeight.ToString();
                }
                else
                {
                    this.lblCharge.Text = feeBySize.ToString();
                }
            }
            else
            {
                var js = "alert('發生不預期錯誤,請洽系統管理者');location.href='http://tw.yahoo.com/';";
                this.ClientScript.RegisterStartupScript(this.GetType(), "back", js, true);
            }
        }
    }



就是一陀攤在角落的code,一眼望過去,每個字都認識,但卻要動腦袋猜測,甚至動手測試才能了解這一段code是什麼意思。除了難以理解以外,這樣巢狀if的設計方式,健壯性(robustness)上也相當薄弱。

呈現的畫面與功能,如下圖所示:
image

找到目標後,如何開始重構
重構的循環有幾個階段,分別為綠燈、重構、紅燈、填入。如下圖所示:
image

當我們想要進行『重構』的動作:
image

就應該先進行『綠燈』的前置作業:
image

重構起手式:口說無憑、錄影存證
要記住,現況的程式碼,雖然彷彿一陀垃圾,但他是可以執行出正確結果的垃圾。寫得再好、再完美的程式,如果無法執行出正確的結果,那也沒啥價值可言。既然,我們要進行重構,重構的意義就在於:『不改變系統外在行為的條件下,改善系統內部的品質』,改程式很簡單,要確保只影響到我們改的程式,要確保原本的行為沒有改變,這個前提要比改程式重要得多。

所以,這邊透過Selenium IDE,來幫助我們記錄下來現在可以執行出正確結果的行為。時間,應該浪費在美好的事物上,而不是每次修改完程式,都還要手動去key in一堆沒意義的資料。用最少的effort,達到自動化的效果。(對Selenium IDE使用有興趣的朋友,可以參考小鐵的這篇文章:Web UI 測試的好幫手 - Selenium
image

錄製過程中,請記得要在適當的步驟,加入verify的項目,確保到哪一個步驟時,應該有對應的預期結果。以這邊的例子來說,就是當選完物流商,重新點選計算運費時,我們會去驗證物流商的名稱,以及運費的結果,是否符合預期。

重構第二式:說人話
當把我們預期的行為錄製完之後,我們要開始對系統進行淨化的動作了。(相信我,每一招都是『易如反掌』,你絕對可以做到)

可以看一下我們原本重構的目標,那就是散落一地攤著的code,我們淨化的第一步,就是說人話:用人類的語言,去描述程式碼在做什麼事。正如同小說需要角色來說話,系統也需要程式碼來說話。

以這例子來說,下圖的程式碼,就可以用一句話來表達:『計算運費』。
image

所以,我們先為原本的程式加上人話,注意,不需要每一行程式都加上人話,而是針對事情、行為來描述。

這邊只加上了四行註解,分別是選了哪一間物流商,計算出運費,呈現物流商的名稱與運費結果。另一個註解是防呆的處理。(我們最終的目標是,讓程式碼自己會說話)

加上人話的版本:

    protected void btnCalculate_Click(object sender, EventArgs e)
    {
        if (this.IsValid)
        {
            //選黑貓,計算出運費,呈現物流商名稱與運費
            if (this.drpCompany.SelectedValue == "1")
            {
                this.lblCompany.Text = "黑貓";
                var weight = Convert.ToDouble(this.txtProductWeight.Text);
                if (weight > 20)
                {
                    this.lblCharge.Text = "500";
                }
                else
                {
                    var fee = 100 + weight * 10;
                    this.lblCharge.Text = fee.ToString();
                }
            }
            //選新竹貨運,計算出運費,呈現物流商名稱與運費
            else if (this.drpCompany.SelectedValue == "2")
            {
                this.lblCompany.Text = "新竹貨運";
                var length = Convert.ToDouble(this.txtProductLength.Text);
                var width = Convert.ToDouble(this.txtProductWidth.Text);
                var height = Convert.ToDouble(this.txtProductHeight.Text);

                var size = length * width * height;

                //長 x 寬 x 高(公分)x 0.0000353
                if (length > 100 || width > 100 || height > 100)
                {
                    this.lblCharge.Text = (size * 0.0000353 * 1100 + 500).ToString();
                }
                else
                {
                    this.lblCharge.Text = (size * 0.0000353 * 1200).ToString();
                }
            }
            //選郵局,計算出運費,呈現物流商名稱與運費
            else if (this.drpCompany.SelectedValue == "3")
            {
                this.lblCompany.Text = "郵局";

                var weight = Convert.ToDouble(this.txtProductWeight.Text);
                var feeByWeight = 80 + weight * 10;

                var length = Convert.ToDouble(this.txtProductLength.Text);
                var width = Convert.ToDouble(this.txtProductWidth.Text);
                var height = Convert.ToDouble(this.txtProductHeight.Text);
                var size = length * width * height;
                var feeBySize = size * 0.0000353 * 1100;

                if (feeByWeight < feeBySize)
                {
                    this.lblCharge.Text = feeByWeight.ToString();
                }
                else
                {
                    this.lblCharge.Text = feeBySize.ToString();
                }
            }
            //發生預期以外的狀況,呈現警告訊息,回首頁
            else
            {
                var js = "alert('發生不預期錯誤,請洽系統管理者');location.href='http://tw.yahoo.com/';";
                this.ClientScript.RegisterStartupScript(this.GetType(), "back", js, true);
            }
        }
    }



重構第三式:垃圾分類
程式碼的現況,跟一陀垃圾沒什麼兩樣,又髒又臭。但有了人話的描述之後,就可以簡單的為其進行垃圾分類的動作。

什麼是垃圾分類?簡單的說,就是重構中的『擷取方法』,透過Visual Studio的輔助,只需要把原本人話所描述的區塊,選取後按滑鼠右鍵,選擇重構=>擷取方法,
image

接著把人話的意義當做新方法的名稱,這個動作就算完成了。

image

垃圾分類完的版本:

    protected void btnCalculate_Click(object sender, EventArgs e)
    {
        //若頁面通過驗證
        if (this.IsValid)
        {
            //選黑貓,計算出運費,呈現物流商名稱與運費
            if (this.drpCompany.SelectedValue == "1")
            {
                CalculatedByBlackCat();
            }
            //選新竹貨運,計算出運費,呈現物流商名稱與運費
            else if (this.drpCompany.SelectedValue == "2")
            {
                CalculatedByHsinchu();
            }
            //選郵局,計算出運費,呈現物流商名稱與運費
            else if (this.drpCompany.SelectedValue == "3")
            {
                CalculatedByPostOffice();
            }
            //發生預期以外的狀況,呈現警告訊息,回首頁
            else
            {
                var js = "alert('發生不預期錯誤,請洽系統管理者');location.href='http://tw.yahoo.com/';";
                this.ClientScript.RegisterStartupScript(this.GetType(), "back", js, true);
            }
        }
    }

    private void CalculatedByPostOffice()
    {
        this.lblCompany.Text = "郵局";

        var weight = Convert.ToDouble(this.txtProductWeight.Text);
        var feeByWeight = 80 + weight * 10;

        var length = Convert.ToDouble(this.txtProductLength.Text);
        var width = Convert.ToDouble(this.txtProductWidth.Text);
        var height = Convert.ToDouble(this.txtProductHeight.Text);
        var size = length * width * height;
        var feeBySize = size * 0.0000353 * 1100;

        if (feeByWeight < feeBySize)
        {
            this.lblCharge.Text = feeByWeight.ToString();
        }
        else
        {
            this.lblCharge.Text = feeBySize.ToString();
        }
    }

    private void CalculatedByHsinchu()
    {
        this.lblCompany.Text = "新竹貨運";
        var length = Convert.ToDouble(this.txtProductLength.Text);
        var width = Convert.ToDouble(this.txtProductWidth.Text);
        var height = Convert.ToDouble(this.txtProductHeight.Text);

        var size = length * width * height;

        //長 x 寬 x 高(公分)x 0.0000353
        if (length > 100 || width > 100 || height > 100)
        {
            this.lblCharge.Text = (size * 0.0000353 * 1100 + 500).ToString();
        }
        else
        {
            this.lblCharge.Text = (size * 0.0000353 * 1200).ToString();
        }
    }

    private void CalculatedByBlackCat()
    {
        this.lblCompany.Text = "黑貓";
        var weight = Convert.ToDouble(this.txtProductWeight.Text);
        if (weight > 20)
        {
            this.lblCharge.Text = "500";
        }
        else
        {
            var fee = 100 + weight * 10;
            this.lblCharge.Text = fee.ToString();
        }
    }

由於有改到程式了,請記得跑一下Selenium的測試腳本,以確保垃圾分類完後,結果仍如預期般正確。

到這邊,其實各位也可以參考之前重構之路的第一篇:[ASP.NET]重構之路系列v1 – UI, Business logic, Data access概念分開

重構第四式:誰,做什麼事
當垃圾分類完後,接下來要進行的動作相當重要,簡單的說,我們要定義出:『誰,做什麼事』,也就是職責。要定義職責,有一個相當相當重要的原則:『要知道現在所屬的物件為何,並用該物件的角度去看世界』!

以這個例子來說,當下所屬的物件為何?答案是Page,也就是頁面。而頁面要做什麼事?

  1. 蒐集頁面資訊供計算運費
  2. 呈現所選物流商名稱,以及計算完的運費結果


至於怎麼計算運費,那不是頁面該煩惱的事,我們交給所屬的物流商來計算運費即可。

定義完『誰,做什麼事』的版本:

    protected void btnCalculate_Click(object sender, EventArgs e)
    {
        //若頁面通過驗證
        if (this.IsValid)
        {
            //選黑貓,計算出運費,呈現物流商名稱與運費
            if (this.drpCompany.SelectedValue == "1")
            {
                //CalculatedByBlackCat();
                //取得畫面資料

                //計算
                BlackCat blackCat = new BlackCat();
                blackCat.Calculate();

                //呈現
            }
            //選新竹貨運,計算出運費,呈現物流商名稱與運費
            else if (this.drpCompany.SelectedValue == "2")
            {
                //CalculatedByHsinchu();
                //取得畫面資料

                //計算
                Hsinchu hsinchu = new Hsinchu();
                hsinchu.Calculate();

                //呈現
            }
            //選郵局,計算出運費,呈現物流商名稱與運費
            else if (this.drpCompany.SelectedValue == "3")
            {
                //CalculatedByPostOffice();
                //取得畫面資料

                //計算
                PostOffice postOffice = new PostOffice();
                postOffice.Calculate();

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

可以看到,上面的版本把計算運費的步驟,交給了新建的物流商物件來處理。計算的行為則交給Calculate()來做。以第一段『選黑貓,計算出運費,呈現物流商名稱與運費』來說,『誰=黑貓』,『做什麼事=計算運費』。這個時候可以透過Visual Studio的『產生』功能,來自動產生對應的物流商Class以及計算的function,結果如下:
image

image

    public class BlackCat
    {
        public void Calculate()
        {
            throw new NotImplementedException();
        }


        public Product ShipProduct { get; set; }

        public string GetsComapanyName()
        {
            throw new NotImplementedException();
        }

        public double GetsFee()
        {
            throw new NotImplementedException();
        }
    }

這個動作,可以參考重構之路的第三篇:[ASP.NET]重構之路系列v3 – 跨專案使用類別庫

注意!這個時候執行測試,會出現紅燈,因為我們將物件職責分離,但還沒有完成物件的內容。

重構第五式:給你錢,趕快做
在前一個動作,只有將計算的動作交給物流商處理,但頁面職責的部份還沒修改。所謂的『給你錢,趕快做』指的就是,頁面需要什麼資訊,直接跟物流商要。但物流商要動作,可能也需要一些資訊。只要給物流商他要的資訊,他就要給我們要的。

原則就是『Tell, Don't Ask!』。

頁面需要什麼?

  1. 物流商的名稱
  2. 該物流商計算的運費結果。


物流商需要什麼?

  1. 商品資訊


定義出頁面要什麼,物流商計算運費需要什麼,就可以把程式修改一下:

  1. 頁面把商品資訊獨立出來(GetProduct的方法)
  2. 頁面把呈現結果獨立出來(SetResult的方法)
  3. 給物流商計算運費需要的資訊(初始化物流商時,對ShipProduct的初始化動作)
  4. 跟物流商要名稱(GetsCompanyName的方法)
  5. 跟物流商要運費結果(GetsFee的方法)。


『給你錢,趕快做』的版本:

    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();
            }
            //選新竹貨運,計算出運費
            else if (this.drpCompany.SelectedValue == "2")
            {
                //計算
                Hsinchu hsinchu = new Hsinchu() { ShipProduct = product };
                hsinchu.Calculate();
                companyName = hsinchu.GetsComapanyName();
                fee = hsinchu.GetsFee();
            }
            //選郵局,計算出運費
            else if (this.drpCompany.SelectedValue == "3")
            {
                //計算
                PostOffice postOffice = new PostOffice() { ShipProduct = product };
                postOffice.Calculate();
                companyName = postOffice.GetsComapanyName();
                fee = postOffice.GetsFee();
            }
            //發生預期以外的狀況,呈現警告訊息,回首頁
            else
            {
                var js = "alert('發生不預期錯誤,請洽系統管理者');location.href='http://tw.yahoo.com/';";
                this.ClientScript.RegisterStartupScript(this.GetType(), "back", js, true);
            }

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

    }

    /// <summary>
    /// 呈現結果
    /// </summary>
    /// <param name="companyName"></param>
    /// <param name="fee"></param>
    private void SetResult(string companyName, double fee)
    {
        this.lblCompany.Text = companyName;
        this.lblCharge.Text = fee.ToString();
    }

    /// <summary>
    /// 取得畫面資料
    /// </summary>
    /// <returns></returns>
    private Product GetProduct()
    {
        var result = new Product
        {
            Name = this.txtProductName.Text.Trim(),
            Weight = Convert.ToDouble(this.txtProductWeight.Text),
            Size = new Size()
            {
                Length = Convert.ToDouble(this.txtProductLength.Text),
                Width = Convert.ToDouble(this.txtProductWidth.Text),
                Height = Convert.ToDouble(this.txtProductHeight.Text)
            },
            IsNeedCool = this.rdoNeedCool.SelectedValue == "1"
        };

        return result;
    }

這時候還是紅燈,因為還沒有物件內容。

重構第六式:確定對方給的,是我要的
上面已經定義出來『我們要什麼,跟誰要』,接著是要確定『物流商給我們的資訊,是不是我們要的』。

怎麼確定物流商給的資訊沒錯?對啦,就是用單元測試啦。正所謂羊毛出在羊身上,建立單元測試的測試案例,往往可以從一開始的整合測試案例中找到蛛絲馬跡,也就是我們一開始錄影存證的部份。

期望結果(以黑貓為例):

  1. 呼叫黑貓的GetsCompanyName方法,應該得到『黑貓』。
  2. 還沒呼叫黑貓計算運費前,呼叫黑貓的GetsFee方法,應該得到0。
  3. 當給了整合測試上的商品資訊後,呼叫黑貓的Calculate方法後,呼叫GetsFee方法,應該得到200。


依據測試案例,建立的單元測試如下:

        /// <summary>
        ///GetsComapanyName 的測試
        ///</summary>
        [TestMethod()]
        public void GetsComapanyNameTest_v3()
        {
            BlackCat target = new BlackCat();
            string expected = "黑貓";
            string actual;
            actual = target.GetsComapanyName();
            Assert.AreEqual(expected, actual);
        }

        /// <summary>
        ///GetsFee 的測試
        ///</summary>
        [TestMethod()]
        public void GetsFeeTest_v3()
        {
            BlackCat target = new BlackCat();
            double expected = 0F;
            double actual;
            actual = target.GetsFee();
            Assert.AreEqual(expected, actual);
        }

        /// <summary>
        ///Calculate 的測試
        ///</summary>
        [TestMethod()]
        public void CalculateTest_v3()
        {
            //從整合測試的test case,來當做單元測試的test case

            //arrange
            BlackCat target = new BlackCat()
            {
                ShipProduct = new Product
                {
                    IsNeedCool = true,
                    Name = "商品測試1",
                    Size = new Size
                    {
                        Height = 10,
                        Length = 30,
                        Width = 20
                    },
                    Weight = 10
                }
            };

            //act
            target.Calculate();

            
            var expectedName = "黑貓";
            var expectedFee = 200;

            var actualName = target.GetsComapanyName();
            var actualFee = target.GetsFee();

            //assert
            Assert.AreEqual(expectedName, actualName);
            Assert.AreEqual(expectedFee, actualFee);
        }

建立完我們預期物流商的行為,執行一下測試的結果,得到了9個紅燈,得到的例外都是『System.NotImplementedException: 方法或作業尚未實作』(因為我還沒發功啊)。
image

單元測試的動作,可以參考重構之路第五篇:[ASP.NET]重構之路系列v5 –單元測試, Just Do It!!

重構第七式:食神歸位
既然已經定義好,誰該做什麼事,也定義好大家應該有的產出結果,接下來就是要進行重構循環的『填入』動作。這個動作的重點在於,要想辦法讓紅燈(包括單元測試與整合測試),變成綠燈。

我們將原本頁面上計算運費的方法內容,分別搬到所屬的物流商計算運費的方法裡面。

食神歸位的版本,以黑貓為例(頁面程式沒有改變,就不在此列出):

    public class BlackCat 
    {
        private double _fee;
        private readonly string _companyName = "黑貓";

        public Product ShipProduct { get; set; }

        public void Calculate()
        {
            ////頁面呈現物流商名稱
            //this.lblCompany.Text = "黑貓";

            ////頁面取值
            //var weight = Convert.ToDouble(this.txtProductWeight.Text);

            ////計算運費邏輯
            //if (weight > 20)
            //{
            //    //頁面呈現計算的運費結果
            //    this.lblCharge.Text = "500";
            //}
            //else
            //{
            //    //頁面呈現計算的運費結果
            //    var fee = 100 + weight * 10;
            //    this.lblCharge.Text = fee.ToString();
            //}

            var weight = this.ShipProduct.Weight;

            //計算運費邏輯
            if (weight > 20)
            {
                this._fee = 500;
            }
            else
            {
                //頁面呈現計算的運費結果
                var fee = 100 + weight * 10;
                this._fee = fee;
            }
        }        

        public string GetsComapanyName()
        {
            return this._companyName;
        }

        public double GetsFee()
        {
            return this._fee;
        }
    }

歸位後,執行單元測試與整合測試結果
image

image

很好,我們又回到了綠燈了。

這邊打岔一下,請各位讀者記住,在重構的循環中,只要是綠燈的情況,就代表可以deploy到正式環境,就代表不管我們改了什麼,程式仍可如預期般的執行出正確的結果。以此為原則,各位讀者在進行重構的時候,就可以考量需求與資源,來調整要重構的細度需要到哪。

重構第八式:IoC
IoC,沒錯,又出現這個討厭難懂的字眼。如同GoF四人幫的Design patterns所說:『Program to an 'interface', not an 'implementation'.』,也就是系統應該以介面導向來進行設計。

什麼叫做介面導向?還記得重構第四式的『誰,做什麼事』的原則嗎?是的,我們又要觀落陰了用該物件的角度,去看世界了。簡單的說,『用該物件的角度去看世界,除了物件自己本身以外,看出去外面的世界,都是介面。』

這一點都不難,請跟著我這樣做。

  1. 我們站在頁面上,看到外面的世界有哪些?有三個物流商的物件,分別是黑貓、新竹貨運跟郵局。
  2. 這三個物流商,在這邊所屬的意義為何?可能有兩種,第一,物流商的介面。第二,運費的介面。我們該選哪一種呢?頁面除了需要運費以外,還需要物流商的名稱,所以選擇前者:物流商的介面。


接著把原本宣告物流商的程式碼,改為宣告成物流商的介面,也就是把下面這行

BlackCat blackCat = new BlackCat() { ShipProduct = product };


改成這樣

ILogistics logistics = new BlackCat() { ShipProduct = product };


這個動作,也可以透過在物流商的class裡面,透過重構=>擷取介面,來淬鍊出介面。也可以在使用場景,也就是頁面的程式,透過『產生』的動作來產生介面,就像產生類別與方法一樣。

IoC版本:

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

    }

介面

    public interface ILogistics
    {
        void Calculate();
        string GetsComapanyName();
        double GetsFee();
    }

修改後,記得執行一下單元測試與整合測試,確保沒有影響結果。

這個動作可以參考重構系列第四篇:[ASP.NET]重構之路系列v4 – 簡單使用interface之『你也會IoC』

重構第九式:萬佛朝宗『Patterns』
重構到這,其實已經很充足了,職責已經分離,也透過介面來降低耦合,也有對應的整合測試與單元測試。不過如同一開始重構的時機點所說,當我們為了需求或bug而修改功能時,其實可以再思考一下,這樣類似的需求會不會再發生。這樣的情況,有沒有合適的pattern可以解決我們的需求與問題。

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

以這例子,要做的事情很簡單,接著前面重構一到八式的基礎,我們的第九式其實就是前面一到八式串起來(不是吧…這不是星爺的降龍十八掌啊…)。我們只需要把條件式抽出來,條件只影響到選擇哪一間物流商,頁面要做的事,都是呼叫介面計算運費,呼叫介面取得物流商名稱與計算運費結果。

Strategy Pattern版本:

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

比對一下註解的部份,相信各位讀者都有能力做這樣的動作。就只是把相同的部份抽出來。這,就是Strategy Pattern。

比對下圖:

  1. Context的部份,就是頁面。
  2. Strategy interface,就是物流商介面(ILogistics)。
  3. ConcreateStrategyA, B, C,就是黑貓、新竹貨運與郵局。


File:StrategyPatternClassDiagram.svg
圖片來源:http://en.wikipedia.org/wiki/File:StrategyPatternClassDiagram.svg

一樣,記得跑一下單元測試與整合測試,結果應該也都是綠燈。

重構第九式 part2 – Factory pattern
假設要把職責切的更乾淨,那麼建立物流商的動作,應該與頁面無關,而應該建立一個工廠來幫忙初始化物流商的物件執行個體。套用的就是工廠模式(工廠有很多種,這邊以簡單工廠為例,其他例如Factory Method PatternAbstract Factory 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);
            }            
        }
    }


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

打完收工
重構完成後(嚴格來說,應該是過程中),記得把不必要的程式碼清除,並且把Class與Function相關的API Document補上。

重構完成體版本(體會一下,現在的程式碼,是不是自己會說話):

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

            ILogistics logistics = FactoryRepository.GetILogistics(this.drpCompany.SelectedValue, product);
            if (logistics != null)
            {
                logistics.Calculate();
                var companyName = logistics.GetsComapanyName();
                var 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>
    /// 呈現結果
    /// </summary>
    /// <param name="companyName"></param>
    /// <param name="fee"></param>
    private void SetResult(string companyName, double fee)
    {
        this.lblCompany.Text = companyName;
        this.lblCharge.Text = fee.ToString();
    }

    /// <summary>
    /// 取得畫面資料
    /// </summary>
    /// <returns></returns>
    private Product GetProduct()
    {
        var result = new Product
        {
            Name = this.txtProductName.Text.Trim(),
            Weight = Convert.ToDouble(this.txtProductWeight.Text),
            Size = new Size()
            {
                Length = Convert.ToDouble(this.txtProductLength.Text),
                Width = Convert.ToDouble(this.txtProductWidth.Text),
                Height = Convert.ToDouble(this.txtProductHeight.Text)
            },
            IsNeedCool = this.rdoNeedCool.SelectedValue == "1"
        };

        return result;
    }


檢視重構結果
從三個角度來檢視我們重構完的設計:

  1. UML(套用strategy pattern後,未來增加物流商,符合開放封閉原則)
    WebDevPartyAfterRefactoring
  2. 複雜度(從14降到4)
    image
  3. 程式碼覆蓋率(從0%到92.86%)
    image


很簡單,也很美好,不是嗎?

結論

  1. 請用身體記住重構循環的四個步驟:綠燈、重構、紅燈、填入。
  2. 只要綠燈,就可以deploy!
  3. 讓程式碼自己會說話,只補API Document,去除多餘的comment。
  4. 曾經進入過重構循環的程式,隨時可以再重構、隨時可以再修改,只要綠燈,隨時可以deploy。
  5. 只要大家都有著『在這程式變美之前,我不能睡』的信念,程式碼就可以得到淨化的。


最後提出一個問題:『在這整個重構過程後,有讀者記得三間物流商是怎麼計算運費的嗎?』相信絕大部分的讀者答案都是不記得。這樣就對了,這就是抽象。不需要把頭埋入細節中,把精神關注在物件的行為、職責以及互動上,才是重點。

Sample Code (包括所有版本與測試) :sample code.zip
在微軟活動分享的影片:下載連結

延伸概念
下圖是TDD的循環:
image
很熟悉,不是嗎?

其實,我們的重構循環,就是TDD的Refactor的細部動作,請見下圖:
image

如果沒有重構的能力,那TDD出來的成品,只是一陀可以正確執行的垃圾。TDD的重點在可以正確執行出期望的結果,而在很多時候,我們也只需要可以執行出正確的結果即可。

引用一下Ruddy老師的一段話,用來作整篇重構文章的原則:『重構,應該針對需要的部份重構,且適可而止。』

常常有工程師把只會被執行個幾回就一輩子不會再被執行到的程式;寫得完美的一蹋糊塗,真是太愛乾淨了。真是愛做白工… 還不如早一點睡來得有價值,起碼會活的健康些,程式只要在他被需要的生命週期內活得好好的,就ok了! 這就是done了。


blog 與課程更新內容,請前往新站位置:http://tdd.best/