[30天快速上手TDD][Day 16]Refactoring - 介面導向
前言
上一篇文章中,將原本散落在頁面,屬於物流商職責的部分,搬移填入到物流商的物件中,並且通過了最原始的 selenium 測試,代表符合了使用者的需求。也通過了單元測試,代表物流商物件,符合頁面的需求。
到這邊,其實是最基本的重構。即使不重構下去,也不是什麼太大的問題。
但是,究竟要重構到什麼程度,才算是既沒有 bad smell ,又不會 over design 呢?簡單的說,要符合 SOLID 原則。只需要符合 OOD 的這些原則,基本上不管有沒有使用什麼 pattern ,這就是一個好的設計,也就足夠了。
其他的,就等著讓新的需求來 trigger ,再來針對特殊目的進行重構即可。
這一篇文章,則是用最簡單的方式,來引導讀者朋友們,進入介面導向的世界。這一招,也是3分鐘內可以迅速學會的, enjoy it !
回顧
截至目前為止,再與讀者朋友重述一下,我們目前重構的步驟與順序如下:
-
找到壞味道:
透過靜態程式碼分析等工具,找到需要重構的部份。 -
確認人不是我殺的:
確定現行程式碼可以正常運作,我們只是在重構,不是在 bug fix 或需求異動。 -
錄影存證:
針對可正常運作的網頁,建立 selenium test ,並且針對我們希望驗證的部分,加上 Assert 。 -
說人話:
打開程式碼,靜下心來了解這段程式碼的目的與意義,抽象地來思考每一段程式碼代表的每一件事,並進行排版、重新命名以及增加註解,提昇可讀性,讓自己下次可以快速了解這段程式碼的意義。 -
垃圾分類:
針對程式碼所代表的每一件事,透過重構技巧:擷取方法,依據人話來定義 function 名稱。讓 context 端僅剩下一堆會說話的 function ,而不需要看到太多細節。 -
職責分離:
找出誰,做什麼事。以當下物件的角度為出發點,確認哪一些職責是屬於當下物件,哪一些職責屬於其他物件。並透過分離 function 中的主詞與動詞,來建立對應的物件與行為。 -
找出需求:
把不屬於當下物件的職責都委託給其他物件,接著就是針對當下物件的需求,定義出物件應該需要提供哪些行為。當下物件定義好需求的行為後,不需了解其他物件背後的實作行為,便可著手完成當下物件所提供的功能。 -
驗貨:
確定其他物件給的,是滿足當下物件的需求。先建立其他物件的測試程式,單元測試案例則可以從 selenium 的測試案例找出端倪。這時執行測試會得到紅燈。 -
食神歸位:
將原本放在頁面上,屬於物流商職責的程式碼,搬到物流商物件中,目的是為了通過單元測試,因為通過測試即代表滿足頁面需求,滿足頁面需求,即可通過 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 裡面,實在太重要了,我再強調一次:『用該物件的角度去看世界,除了物件自己本身以外,看出去外面的世界,都是介面。』
這一點都不難,請跟著我這樣做。
- 我們站在頁面上,看到外面的世界有哪些?有三個物流商的物件,分別是黑貓、新竹貨運跟郵局。
- 這三個物流商,在這邊所屬的意義為何?可能有兩種,第一,物流商的介面。第二,運費的介面。我們該選哪一種呢?頁面除了需要運費以外,還需要物流商的名稱,所以在這邊我選擇前者:物流商的介面。
接著只要把原本「宣告物流商」的程式碼,改為「宣告成物流商的介面」,以黑貓為例,也就是把下面這行:
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 等等的需求,不過不在本系列文章中探討)
下一步,當然就是要把「頁面直接相依於物流商的關係」給移除。
總結一下這一篇的重要精神:
- 用當下物件的角度去看世界,物件與外部相依的部份,只看的到介面。就像你進入一個房子裡(當下物件),看出去只有門窗(介面/接口)。
- 當下物件,只思考自己職責,其他職責,都直接跟介面要(Tell, Don't Ask )。不必管介面後到底有沒有實作的物件,也不必管該物件實作的內容。只專注在當下物件職責的設計。
- 異動程式後,記得執行測試,以確保重構過程後,執行結果仍如同預期。
體會了介面導向後,會發現這是一個讓 developer 相當快樂跟興奮的設計方式。只要清楚了自己的職責要做什麼之後,跟其他 developer /物件/系統協同合作時,只需定義好彼此的介面,就可以進行開發、測試,而不會互相影響與干擾。
在分析與設計階段,更建議使用曳光彈開發方式(Tracer Bullet Development, TBD),曳光彈開發方式說明可見:[30天快速上手TDD][Day 12]Refactoring - 職責分離
這也是為什麼 TDD 可以迅速完成並測試每一個單獨物件的不二法門。
blog 與課程更新內容,請前往新站位置:http://tdd.best/