[30天快速上手TDD][Day 16]Refactoring - 介面導向

[30天快速上手TDD][Day 16]Refactoring - 介面導向

前言

上一篇文章中,將原本散落在頁面,屬於物流商職責的部分,搬移填入到物流商的物件中,並且通過了最原始的 selenium 測試,代表符合了使用者的需求。也通過了單元測試,代表物流商物件,符合頁面的需求。

到這邊,其實是最基本的重構。即使不重構下去,也不是什麼太大的問題。

但是,究竟要重構到什麼程度,才算是既沒有 bad smell ,又不會 over design 呢?簡單的說,要符合 SOLID 原則。只需要符合 OOD 的這些原則,基本上不管有沒有使用什麼 pattern ,這就是一個好的設計,也就足夠了。

其他的,就等著讓新的需求來 trigger ,再來針對特殊目的進行重構即可。

這一篇文章,則是用最簡單的方式,來引導讀者朋友們,進入介面導向的世界。這一招,也是3分鐘內可以迅速學會的, enjoy it !

 

回顧

截至目前為止,再與讀者朋友重述一下,我們目前重構的步驟與順序如下:

  1. 找到壞味道
    透過靜態程式碼分析等工具,找到需要重構的部份。
  2. 確認人不是我殺的
    確定現行程式碼可以正常運作,我們只是在重構,不是在 bug fix 或需求異動。
  3. 錄影存證
    針對可正常運作的網頁,建立 selenium test ,並且針對我們希望驗證的部分,加上 Assert 。
  4. 說人話
    打開程式碼,靜下心來了解這段程式碼的目的與意義,抽象地來思考每一段程式碼代表的每一件事,並進行排版、重新命名以及增加註解,提昇可讀性,讓自己下次可以快速了解這段程式碼的意義。
  5. 垃圾分類
    針對程式碼所代表的每一件事,透過重構技巧:擷取方法,依據人話來定義 function 名稱。讓 context 端僅剩下一堆會說話的 function ,而不需要看到太多細節。
  6. 職責分離
    找出誰,做什麼事。以當下物件的角度為出發點,確認哪一些職責是屬於當下物件,哪一些職責屬於其他物件。並透過分離 function 中的主詞與動詞,來建立對應的物件與行為。
  7. 找出需求
    把不屬於當下物件的職責都委託給其他物件,接著就是針對當下物件的需求,定義出物件應該需要提供哪些行為。當下物件定義好需求的行為後,不需了解其他物件背後的實作行為,便可著手完成當下物件所提供的功能。
  8. 驗貨
    確定其他物件給的,是滿足當下物件的需求。先建立其他物件的測試程式,單元測試案例則可以從 selenium 的測試案例找出端倪。這時執行測試會得到紅燈。
  9. 食神歸位
    將原本放在頁面上,屬於物流商職責的程式碼,搬到物流商物件中,目的是為了通過單元測試,因為通過測試即代表滿足頁面需求,滿足頁面需求,即可通過 selenium test ,即代表滿足使用者需求。

 

目前的程式碼

頁面的程式碼如下:


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

物流商物件的測試程式如下:


        /// <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);
        }
		
        /// <summary>
        ///Calculate 的測試
        ///</summary>
        [TestMethod()]
        public void CalculateTest_v3()
        {
            Hsinchu target = new Hsinchu()
            {
                ShipProduct = new Product
                {
                    IsNeedCool = true,
                    Name = "商品測試1",
                    Size = new Size
                    {
                        Height = 10,
                        Length = 30,
                        Width = 20
                    },
                    Weight = 10
                }
            };

            //act
            target.Calculate();

            //assert
            var expectedName = "新竹貨運";
            var expectedFee = 254.16;

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

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

        /// <summary>
        ///GetsComapanyName 的測試
        ///</summary>
        [TestMethod()]
        public void GetsComapanyNameTest_v3()
        {
            Hsinchu target = new Hsinchu();
            string expected = "新竹貨運";
            string actual;
            actual = target.GetsComapanyName();
            Assert.AreEqual(expected, actual);
        }

        /// <summary>
        ///GetsFee 的測試
        ///</summary>
        [TestMethod()]
        public void GetsFeeTest_v3()
        {
            Hsinchu target = new Hsinchu();
            double expected = 0F;
            double actual;
            actual = target.GetsFee();
            Assert.AreEqual(expected, actual);
        }
		
        /// <summary>
        ///Calculate 的測試
        ///</summary>
        [TestMethod()]
        public void CalculateTest_v3()
        {
            PostOffice target = new PostOffice()
            {
                ShipProduct = new Product
                {
                    IsNeedCool = true,
                    Name = "商品測試1",
                    Size = new Size
                    {
                        Height = 10,
                        Length = 30,
                        Width = 20
                    },
                    Weight = 10
                }
            };

            //act
            target.Calculate();

            //assert
            var expectedName = "郵局";
            var expectedFee = 180;

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

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

        }

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

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

物流商的程式碼如下:


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

        public Product ShipProduct { get; set; }

        public void Calculate()
        {          
            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;
        }
    }

    public class Hsinchu
    {
        private double _fee;
        private readonly string _companyName = "新竹貨運";

        public void Calculate()
        {
            var length = this.ShipProduct.Size.Length;
            var width = this.ShipProduct.Size.Width;
            var height = this.ShipProduct.Size.Height;

            var size = length * width * height;

            //計算運費邏輯
            //長 x 寬 x 高(公分)x 0.0000353
            if (length > 100 || width > 100 || height > 100)
            {
                this._fee = size * 0.0000353 * 1100 + 500;
            }
            else
            {
                this._fee = size * 0.0000353 * 1200;
            }

        }

        public Product ShipProduct { get; set; }

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

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

    public class PostOffice
    {
        private double _fee;
        private readonly string _companyName = "郵局";

        public void Calculate()
        {
            var weight = this.ShipProduct.Weight;
            var feeByWeight = 80 + weight * 10;

            var length = this.ShipProduct.Size.Length;
            var width = this.ShipProduct.Size.Width;
            var height = this.ShipProduct.Size.Height;

            var size = length * width * height;
            var feeBySize = size * 0.0000353 * 1100;

            //計算運費邏輯
            if (feeByWeight < feeBySize)
            {
                this._fee = feeByWeight;
            }
            else
            {
                this._fee = feeBySize;
            }
        }

        public Product ShipProduct { get; set; }

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

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

接下來,我們又要繼續重構下去了,目的是隔離物件相依性,以符合開放封閉原則(OCP)與依賴反轉原則(DIP)。

Let's GO!

重構

 

重構第八式:介面導向

如同 GoF 四人幫的 Design patterns 所說:『Program to an 'interface', not an 'implementation'.』,也就是系統應該以介面導向來進行設計。

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

這句話在 OOD/OOP 裡面,實在太重要了,我再強調一次:『用該物件的角度去看世界,除了物件自己本身以外,看出去外面的世界,都是介面。

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

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

接著只要把原本「宣告物流商」的程式碼,改為「宣告成物流商的介面」,以黑貓為例,也就是把下面這行:


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

改成這樣:


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

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

接著記得要在三間物流商的類別上,實作該介面。因為是擷取介面,所以原本方法都不需要改變。只需增加實作介面即可。

把宣告的部份,替換成介面,程式碼如下:


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』

這邊補充說明一下, IoC 跟介面導向不完全相同,本篇文章的方式,因為是頁面,所以僅用到 DIP ,也就是依賴反轉原則。若要做到 IoC/DI ,應該要針對 code-behind 中 .aspx.cs 裡的 Page class,來注入 interface 的實體物件。

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

到這邊,頁面並非是只相依於介面,而是同時直接相依於介面,以及三個物流商的物件。

 

小結

有跟著一系列文章前進的讀者朋友,應該可以把這一篇文章,與之前的 [30天快速上手TDD][Day 5]如何隔離相依性 - 基本的可測試性 結合起來。

串接起來的共通部分,就是物件導向設計的原則與技巧。系統的存在,最重要的目的,是為了滿足使用者需求。而在一般重構上,其他非使用者的需求,則是可讀性、可維護性、架構與設計的彈性。(當然還有其他如 performance, security 等等的需求,不過不在本系列文章中探討)

下一步,當然就是要把「頁面直接相依於物流商的關係」給移除。

總結一下這一篇的重要精神:

  1. 用當下物件的角度去看世界,物件與外部相依的部份,只看的到介面。就像你進入一個房子裡(當下物件),看出去只有門窗(介面/接口)。
  2. 當下物件,只思考自己職責,其他職責,都直接跟介面要(Tell, Don't Ask )。不必管介面後到底有沒有實作的物件,也不必管該物件實作的內容。只專注在當下物件職責的設計。
  3. 異動程式後,記得執行測試,以確保重構過程後,執行結果仍如同預期。

體會了介面導向後,會發現這是一個讓 developer 相當快樂跟興奮的設計方式。只要清楚了自己的職責要做什麼之後,跟其他 developer /物件/系統協同合作時,只需定義好彼此的介面,就可以進行開發、測試,而不會互相影響與干擾。

在分析與設計階段,更建議使用曳光彈開發方式(Tracer Bullet Development, TBD),曳光彈開發方式說明可見:[30天快速上手TDD][Day 12]Refactoring - 職責分離

這也是為什麼 TDD 可以迅速完成並測試每一個單獨物件的不二法門。

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

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

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