[30天快速上手TDD][Day 12]Refactoring - 職責分離

[30天快速上手TDD][Day 12]Refactoring - 職責分離

前言

在上一篇文章中,介紹了先透過理解程式碼,加上註解與排版後,讓我們看了程式碼心情不會再這麼不爽。

也因為抽象思考完,用自己的話在註解來描述程式碼的目的與行為,所以可以很輕鬆快速地透過擷取方法的方式,將每一件事抽取成一個 function , function 的名字就是事情本身的意義。而原本的註解也就是 function 的 API document 。

透過一開始這兩個方式讓程式碼變得相當好懂,讓程式碼本身能說話。

接下來就是要更抽象地運用一些 OO 的原則,來把每一件事情的職責釐清。這系列強調的是,誰都可以學會,所以一樣,只要用 3 ~ 5 分鐘,就保證您也能學會這一招:找出誰,在做什麼事

 

目前的程式碼

經過重構第二式:說人話,與重構第三式:垃圾分類

程式碼如下:


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

 

重構第四式:誰,做什麼事。

當垃圾分類完後,接下來要進行的動作相當重要。簡單的說,我們要定義出:『誰,做什麼事』,也就是職責。

要定義職責,有一個相當相當重要的原則:『要知道現在所屬的物件為何,並用該物件的角度去看世界』!

1

就像火影忍者中的身心轉換術,只有當我們把自己放到現在正在執行的物件中(context),才能用對的角度來釐清,事情到底該由誰來負責。

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

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

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

回到我們的程式碼,主要 function 有 3 個:

  1. CalculatedByBlackCat() : 透過黑貓來計算
  2. CalculatedByHsinchu() : 透過新竹貨運來計算
  3. CalculatedByPostOffice() : 透過郵局來計算

這邊要找出「誰,做什麼事」,有一個相當相當簡單的技巧,相信大家一學就會。針對前面透過人話所整理出來的 function ,只要找出該 function 代表的意義中的「主詞」、「動詞」、「受詞」即可。

什麼意思?很簡單:

  1. 主詞:代表類別
  2. 動詞:代表方法
  3. 受詞:通常是方法參數
  4. 形容詞:通常是呼叫物件行為後,物件產生的狀態變化

以上面的例子來說,就變成:

  1. CalculatedByBlackCat():黑貓,計算運費
  2. CalculatedByHsinchu():新竹貨運,計算運費
  3. CalculatedByPostOffice():郵局,計算運費

接著,就直接把我們的人話再寫成程式碼吧!

  1. CalculatedByBlackCat() 改成:
    
                BlackCat blackCat = new BlackCat();
                blackCat.Calculate();
  2. CalculatedByHsinchu() 改成:
    
                Hsinchu hsinchu = new Hsinchu();
                hsinchu.Calculate();
  3. CalculatedByPostOffice() 改成:
    
                PostOffice postOffice = new PostOffice();
                postOffice.Calculate();

定義完『誰,做什麼事』的程式碼版本如下:


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

你說我發呆隨便亂瞎掰,這樣的程式碼根本就編譯不過,當然啦,因為我還沒發功啊!

當定義出物件與行為後,接著可以透過 Visual Studio 的『產生』功能,來自動產生對應的物流商 Class 以及計算的 function 。

在 Class 上按下[產生],如下圖:

2

在方法上,按下產生,如下圖:

3

就可以看到 class 與 function 都被自動產生了,黑貓類別的程式碼如下:


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 – 跨專案使用類別庫。

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

請大家一定要感受一下這種 top-down 的設計方式,透過工具的輔助,可以讓我們把精神放在滿足使用者的需求上,而不必多花心思在實作細節中。

最後的實作細節,只要把單元完成,功能就可以完整的串起來,而不是一頭埋進去實作細節,想像了許多不存在的需求,最後才要把各式各樣形狀的物件,兜成太空梭、潛水艇、噴射機等等形狀,come on...使用者只是想要台腳踏車!

Top-down 的設計方式,還有另外一種實踐方式,稱為曳光彈開發方式(Tracer Bullet Development, TBD),這個開發方式,在軟體專案成功的管理之道Ship it! A Practical Guide to Successful Software Projects》,以及程序員修煉之道:從小工到專家The Pragmatic Programmer: From Journeyman to Master這兩本書中都有提到,讀者也可以看一下原作者對 Tracer Bullets 的解釋:Tracer Bullets and Prototypes ,這個方式跟這整個 TDD 系列在撰寫 production code 時,有點不謀而合,建議讀者可以花時間去了解與實際演練一下。

TBD 簡單的說,就是用最快速的方式,建立出 working prototype ,透過 interface 與 stub object 來做出類似 hard-code 的系統,過程中有一個重點,就是職責分離,透過相依於 interface 來切分職責,並將實作細節獨立出來,接著再透過 stub / mock 來建立測試案例,最後再實作細節與重構,整個系統的發展就會沿著一開始使用曳光彈所劃過的軌跡前進。

記得,站在當下物件的角度去思考與設計,把精神放在當下物件本身要處理的事情即可。不屬於自己職責的,就透過「找出誰,請它做什麼事」,就可以完成我們高層的抽象設計了。

 

小結

在物件導向的設計中,物件(也就是類別)是一個最基本的元素。

這也是為什麼介紹設計模式(design pattern)時,總是會輔以類別圖(class diagram)來進行說明。因為設計模式,就是把一些最常見的問題,建立一個被 tuning 過最普遍、風險最低、最原型的解決方式,獨立於實作以外,所以什麼樣的語言都可以套用與實作。因為重點是 problem-solution 。

設計模式也只是基於一些最基本的物件導向原則,以及實務經驗跟實務需求,所公認或建議遵循的設計方式。而拆解物件的基本原則,就是職責的區分,這也是我認為在軟體開發中,最抽象的部分。

根據單一職責原則(Single Responsibility Principle, SRP),一個物件就是一個職責,要避免一個物件擁有太多職責,也要避免一個職責分散在太多物件上。但這一直是我認為,設計中最難以區分的一個地方。單一職責原則,可以參考我之前的文章:[ASP.NET]91之ASP.NET由淺入深 不負責講座 Day17 – 單一職責原則

好在,我們懂人類的語言,而我們說的話,不管是什麼語言,總是會有主詞、動詞、形容詞、受詞等等...透過文法的剖析,可以協助我們快速的找出相關的物件。(其實如果各位有接觸過 domain model ,或使用 Domain-Driven Development ( DDD ) ,應該可以很快速的找出 domain entity ,但 DDD 對我來說,還是太抽象了,所以我選擇用主詞、動詞來區分)

再重複一次,到現在的重構步驟:

前提:一定要先建立測試,確保重構後的結果正確。

  1. 註解加上人話;
  2. 人話變成 function ;
  3. 找出人話中的主詞與動詞;
  4. 主詞變成類別(也就是物件);
  5. 動詞變成方法 (也就是行為);

到這邊,還是一環扣著一環,一塊小蛋糕般的輕鬆寫意。

這一個動作把職責分開,物件定義出來,行為突顯出來。看起來雖然沒什麼,卻是後續系統設計與架構調整的重要起步,因為,物件導向的原則與設計模式,都是基於物件的基礎。

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

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