上篇文章透過簡單的重構一個function,將相同的部份抽出判斷式外,讓不同的部份影響範圍最低。因此解決了我們有著重複程式碼的問題。
更重要的是,透過這一個過程,僅運用基本原則,解決眼前問題,結果卻就是設計模式中策略模式的方式。希望藉此帶出,設計模式其實重點是為了解決問題,只要運用基本精神、原則,也能達到一樣的效果。
重構到這,其實複雜度、可讀性、可維護性已經都相當漂亮,但筆者打算再舉個例子,來當重構這一系列手法的ENDING。因為這兩招,是所有重構中最常見的情況,也是筆者效益最高、成本最低的手法。
「把new的動作,放到同一個class裡」,我想,一樣3分鐘讀者朋友就可以pick up起來了。
上一篇文章:[Day 17]Refactoring - Strategy Pattern
本系列文章專區
@目前的程式碼
目前頁面的程式碼,已經讓context的邏輯清晰可讀,選擇哪一間物流商,只會影響在執行時,介面是由哪一個物流商實體物件來實際運作。
程式碼如下:
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);
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;
}
}
@前言
在[Day 13]Refactoring - 告訴我,你要什麼文章中,筆者留下了一個疑問:
為什麼在頁面職責的部分,有一個「建立物流商物件」的動作要被highlight出來呢?
前面幾篇文章一直提到,在物件導向的設計中,釐清楚每個物件所該負責的職責,是相當重要的一件事。除了要符合單一職責原則(SRP)以外,更重要的是,這可以幫助我們達到關注點分離,程式碼重複使用的目的。
回到眼前的程式碼,假設要把職責切的更乾淨,那麼建立物流商的動作,應該與頁面無關,而應該建立一個工廠來幫忙初始化物流商的物件執行個體。
這邊套用的就是工廠模式(工廠有很多種,這邊以簡單工廠為例,其他例如Factory Method Pattern、Abstract Factory Pattern也都是類似的作法)。
@重構第九式:運用Design 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);
}
}
}
執行所有的測試,確保整個重構過程沒有改變行為。
@小結
本篇文章的重構精神在於,把初始化物件的部份,抽離context端。
簡單的說:「生成物件的動作,與使用物件的動作,一定要分開。」
註:通常生成物件我們會透過Builder, Factory, Repository等pattern來實作。
為什麼生成物件的動作與使用物件的動作分開,有這麼重要呢?因為一個物件定義好之後,目的就是為了讓這個職責交給這個物件負責,大家使用時,不需要寫重複的程式碼,或開發相同職責的物件。所以一個物件可能會到處被使用。
當需求異動時,我們希望透過擴充,而不是修改(開放封閉原則:對擴充開放,對修改封閉),也就是新增一個新的物件,完成新的需求,就能直接取代掉舊的物件,甚至有條件的切換新物件與舊物件,讓這兩個商業邏輯可以並存。
這時,若生成物件的條件散落一地,或是到處都有生成物件的動作,那麼肯定要抽換物件時,需要修改許多地方。而在物件中,直接生成相依物件的動作,也違反了依賴反轉原則(DIP),這會使得物件之間耦合性太高。
舉個最簡單的例子:假設我們透過一個Hello的物件,呼叫其Say()方法,會print: "Hello, 91",程式碼如下圖所示:
如此一來,Program物件就與Hello物件綁在一起。假設今天需求異動,肯定不是要改Program物件,就是要改Hello物件的內容,而這兩者都違背了開放封閉原則。
假設今天需求異動變成,在某種情況下,Hello.Say()都要改成"您好,九一"。
如果有透過工廠來隔離,則原本程式碼會變成如何呢?如下圖所示:
只是把new的動作放到一個專門生成物件的工廠類別中,effort很小,可讀性與可維護性也仍很高。
接著碰到這樣的需求,我們只要新增一個類別:ChineseHello類別,繼承Hello類別,覆寫其Say()方法即可。如下圖所示:
您可能會說,如果Hello的方法,不是宣告成virtual怎麼辦?這時就用的到前面文章提到的:[Day 16]Refactoring - 介面導向
context端透過相依於介面,就沒有「不能override」的問題了。
透過這樣的方式來設計,幾乎可以將所有的判斷式,都拉到工廠類別中。每一個if/else if中的block,都可以是同一個介面,不同實作類別的方法。接著將if/else if放到工廠中,以切換提供不同的實體物件給context用。
未來需求異動,我們只需要:
只要上層抽象邏輯沒有異動,context端就不需要修改任何程式,舊的物件也不需要修改任何程式。只需要新增新類別,實作同介面,工廠切換回傳新類別即可。
讓我再重複一次,「在設計時,請務必把生成物件與使用物件的動作分開」,當未來需求異動時,您就會感謝之前的自己種的這棵小樹,而不是怨恨之前的王八蛋,挖的這個大洞。
最後要提醒一下,「靜態類別/方法」是相當不容易測試,也就代表其相當不容易抽換模組。所以,筆者目前的原則是:「除非必要,否則不宣告成static」
hatelove提到:
當未來需求異動時,您就會感謝之前的自己種的這棵小樹,而不是怨恨之前的王八蛋,挖的這個大洞。
這語氣....跟我從電視上認識的那隻加菲貓還滿吻合的~~