iT邦幫忙

DAY 16
5

30天快速上手TDD系列 第 16

[Day 16]Refactoring - 介面導向

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

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

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

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

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

上一篇文章:[Day 15]Refactoring - 食神歸位
本系列文章專區
@前言
截至目前為止,再與讀者朋友重述一下,我們目前重構的步驟與順序如下:

  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,來注入interface的實體物件

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

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

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

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

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

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

  1. 用當下物件的角度去看世界,物件與外部相依的部份,只看的到介面。就像你進入一個房子裡(當下物件),看出去只有門窗(介面/接口)。

  2. 當下物件,只思考自己職責,其他職責,都直接跟介面要(搭配運用Tell, Don't Ask)。不必管介面後到底有沒有實作的物件,也不必管該物件實作的內容。只專注在當下物件職責的設計。

  3. 異動程式後,記得執行測試,以確保重構過程後,執行結果仍如同預期。

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

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


上一篇
[Day 15]Refactoring - 食神歸位
下一篇
[Day 17]Refactoring - Strategy Pattern
系列文
30天快速上手TDD31
0
ted99tw
iT邦高手 1 級 ‧ 2012-10-24 18:36:00

讚讚讚

拜讀再拜讀呀!!!

筆記筆記筆記

我要留言

立即登入留言