[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),就像下圖一樣:
圖片來源:http://www.chancedia.com/?p=41470
每個Dev都喜歡乾淨的code,但是又喜歡把code弄髒。
重構的目的
我們希望可以把雜亂無章的code,乾淨整齊的放在它們所屬的位置上。
圖片來源:http://jung9572002.pixnet.net/blog/post/1733351-%E6%94%B6%E7%B4%8D%E9%81%94%E4%BA%BA
重構的時機與目標
基本上最適合重構的時機有三類:
- Debug
- 需求異動
- 系統有Bad Smell的地方
簡單的說,就是要修改程式的時候,或是程式很髒的時候,適合重構。
如何找出Bad Smell
這邊以SourceMonitor為例,來找出系統中複雜度太高的function,並將它當做我們重構的目標。(SourceMonitor的介紹,有興趣的朋友可以看之前這篇文章:[Tool]SourceMonitor - 程式碼掃瞄)
掃描後,按照Max Complexity排序,可以看到Prodcut_v0.aspx.cs,最大複雜度14,最大深度5。
再點開詳細資訊後,可以看到 btnCalculate_Click這個方法,就是造成最大複雜度與最大深度的原因。
接著,來看一下這個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)上也相當薄弱。
呈現的畫面與功能,如下圖所示:
找到目標後,如何開始重構
重構的循環有幾個階段,分別為綠燈、重構、紅燈、填入。如下圖所示:
當我們想要進行『重構』的動作:
就應該先進行『綠燈』的前置作業:
重構起手式:口說無憑、錄影存證
要記住,現況的程式碼,雖然彷彿一陀垃圾,但他是可以執行出正確結果的垃圾。寫得再好、再完美的程式,如果無法執行出正確的結果,那也沒啥價值可言。既然,我們要進行重構,重構的意義就在於:『不改變系統外在行為的條件下,改善系統內部的品質』,改程式很簡單,要確保只影響到我們改的程式,要確保原本的行為沒有改變,這個前提要比改程式重要得多。
所以,這邊透過Selenium IDE,來幫助我們記錄下來現在可以執行出正確結果的行為。時間,應該浪費在美好的事物上,而不是每次修改完程式,都還要手動去key in一堆沒意義的資料。用最少的effort,達到自動化的效果。(對Selenium IDE使用有興趣的朋友,可以參考小鐵的這篇文章:Web UI 測試的好幫手 - Selenium)
錄製過程中,請記得要在適當的步驟,加入verify的項目,確保到哪一個步驟時,應該有對應的預期結果。以這邊的例子來說,就是當選完物流商,重新點選計算運費時,我們會去驗證物流商的名稱,以及運費的結果,是否符合預期。
重構第二式:說人話
當把我們預期的行為錄製完之後,我們要開始對系統進行淨化的動作了。(相信我,每一招都是『易如反掌』,你絕對可以做到)
可以看一下我們原本重構的目標,那就是散落一地攤著的code,我們淨化的第一步,就是說人話:用人類的語言,去描述程式碼在做什麼事。正如同小說需要角色來說話,系統也需要程式碼來說話。
以這例子來說,下圖的程式碼,就可以用一句話來表達:『計算運費』。
所以,我們先為原本的程式加上人話,注意,不需要每一行程式都加上人話,而是針對事情、行為來描述。
這邊只加上了四行註解,分別是選了哪一間物流商,計算出運費,呈現物流商的名稱與運費結果。另一個註解是防呆的處理。(我們最終的目標是,讓程式碼自己會說話)
加上人話的版本:
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的輔助,只需要把原本人話所描述的區塊,選取後按滑鼠右鍵,選擇重構=>擷取方法,
接著把人話的意義當做新方法的名稱,這個動作就算完成了。
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,也就是頁面。而頁面要做什麼事?
- 蒐集頁面資訊供計算運費
- 呈現所選物流商名稱,以及計算完的運費結果
至於怎麼計算運費,那不是頁面該煩惱的事,我們交給所屬的物流商來計算運費即可。
定義完『誰,做什麼事』的版本:
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,結果如下:
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!』。
頁面需要什麼?
- 物流商的名稱
- 該物流商計算的運費結果。
物流商需要什麼?
- 商品資訊
定義出頁面要什麼,物流商計算運費需要什麼,就可以把程式修改一下:
- 頁面把商品資訊獨立出來(GetProduct的方法)
- 頁面把呈現結果獨立出來(SetResult的方法)
- 給物流商計算運費需要的資訊(初始化物流商時,對ShipProduct的初始化動作)
- 跟物流商要名稱(GetsCompanyName的方法)
- 跟物流商要運費結果(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;
}
這時候還是紅燈,因為還沒有物件內容。
重構第六式:確定對方給的,是我要的
上面已經定義出來『我們要什麼,跟誰要』,接著是要確定『物流商給我們的資訊,是不是我們要的』。
怎麼確定物流商給的資訊沒錯?對啦,就是用單元測試啦。正所謂羊毛出在羊身上,建立單元測試的測試案例,往往可以從一開始的整合測試案例中找到蛛絲馬跡,也就是我們一開始錄影存證的部份。
期望結果(以黑貓為例):
- 呼叫黑貓的GetsCompanyName方法,應該得到『黑貓』。
- 還沒呼叫黑貓計算運費前,呼叫黑貓的GetsFee方法,應該得到0。
- 當給了整合測試上的商品資訊後,呼叫黑貓的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: 方法或作業尚未實作』(因為我還沒發功啊)。
單元測試的動作,可以參考重構之路第五篇:[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;
}
}
歸位後,執行單元測試與整合測試結果
很好,我們又回到了綠燈了。
這邊打岔一下,請各位讀者記住,在重構的循環中,只要是綠燈的情況,就代表可以deploy到正式環境,就代表不管我們改了什麼,程式仍可如預期般的執行出正確的結果。以此為原則,各位讀者在進行重構的時候,就可以考量需求與資源,來調整要重構的細度需要到哪。
重構第八式:IoC
IoC,沒錯,又出現這個討厭難懂的字眼。如同GoF四人幫的Design patterns所說:『Program to an 'interface', not an 'implementation'.』,也就是系統應該以介面導向來進行設計。
什麼叫做介面導向?還記得重構第四式的『誰,做什麼事』的原則嗎?是的,我們又要觀落陰了用該物件的角度,去看世界了。簡單的說,『用該物件的角度去看世界,除了物件自己本身以外,看出去外面的世界,都是介面。』
這一點都不難,請跟著我這樣做。
- 我們站在頁面上,看到外面的世界有哪些?有三個物流商的物件,分別是黑貓、新竹貨運跟郵局。
- 這三個物流商,在這邊所屬的意義為何?可能有兩種,第一,物流商的介面。第二,運費的介面。我們該選哪一種呢?頁面除了需要運費以外,還需要物流商的名稱,所以選擇前者:物流商的介面。
接著把原本宣告物流商的程式碼,改為宣告成物流商的介面,也就是把下面這行
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。
比對下圖:
- Context的部份,就是頁面。
- Strategy interface,就是物流商介面(ILogistics)。
- ConcreateStrategyA, B, C,就是黑貓、新竹貨運與郵局。
圖片來源:http://en.wikipedia.org/wiki/File:StrategyPatternClassDiagram.svg
一樣,記得跑一下單元測試與整合測試,結果應該也都是綠燈。
重構第九式 part2 – Factory pattern
假設要把職責切的更乾淨,那麼建立物流商的動作,應該與頁面無關,而應該建立一個工廠來幫忙初始化物流商的物件執行個體。套用的就是工廠模式(工廠有很多種,這邊以簡單工廠為例,其他例如Factory Method Pattern、Abstract 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;
}
檢視重構結果
從三個角度來檢視我們重構完的設計:
很簡單,也很美好,不是嗎?
結論
- 請用身體記住重構循環的四個步驟:綠燈、重構、紅燈、填入。
- 只要綠燈,就可以deploy!
- 讓程式碼自己會說話,只補API Document,去除多餘的comment。
- 曾經進入過重構循環的程式,隨時可以再重構、隨時可以再修改,只要綠燈,隨時可以deploy。
- 只要大家都有著『在這程式變美之前,我不能睡』的信念,程式碼就可以得到淨化的。
最後提出一個問題:『在這整個重構過程後,有讀者記得三間物流商是怎麼計算運費的嗎?』相信絕大部分的讀者答案都是不記得。這樣就對了,這就是抽象。不需要把頭埋入細節中,把精神關注在物件的行為、職責以及互動上,才是重點。
Sample Code (包括所有版本與測試) :sample code.zip
在微軟活動分享的影片:下載連結
延伸概念
下圖是TDD的循環:
很熟悉,不是嗎?
其實,我們的重構循環,就是TDD的Refactor的細部動作,請見下圖:
如果沒有重構的能力,那TDD出來的成品,只是一陀可以正確執行的垃圾。TDD的重點在可以正確執行出期望的結果,而在很多時候,我們也只需要可以執行出正確的結果即可。
引用一下Ruddy老師的一段話,用來作整篇重構文章的原則:『重構,應該針對需要的部份重構,且適可而止。』
常常有工程師把只會被執行個幾回就一輩子不會再被執行到的程式;寫得完美的一蹋糊塗,真是太愛乾淨了。真是愛做白工… 還不如早一點睡來得有價值,起碼會活的健康些,程式只要在他被需要的生命週期內活得好好的,就ok了! 這就是done了。
blog 與課程更新內容,請前往新站位置:http://tdd.best/