Java改寫重構第2版第一個示例
寫在前面
《重構:改善既有程式碼的設計》是一本經典的軟體工程必讀書籍。作者馬丁·福勒強調重構技術是以微小的步伐修改程式。
但是,從國內的情況來而論,“重構”的概念表裡分離。大家往往喜歡打著“重構”的名號,實際上卻乾的是“刀劈斧砍”的勾當。產生這種現象的原因,一方面是程式設計師希望寫出可維護,可複用,可拓展,靈活性好的程式碼,使系統具長期生命力;另一方面,重構的紮實功夫要學起來、做起來,頗不是一件輕鬆的事,且不說詳盡到近乎瑣碎的重構手法,光是單元測試一事,怕是已有九成同行無法企及。所以,重構變質為重寫,研發團隊拿著公司的經費,幹著“重複造輪子”的事兒,最終“重構”後的軟體仍然不能使人滿意,反倒是一堆問題,使用者不願意買單,程式設計師不願意繼續維護,管理人員也擔著巨大的壓力。痛苦的滋味在心底蔓延。
轉頭來看,Martin Fowler 時隔 20 年後的第 2 版,沒有照搬第一版,而是把工夫做得更加紮實了,我有幸發現這本書,解我之惑,實屬幸事一件。由於第 2 版中使用 javascript 作為展現重構手法的語言,可是本人慣用的語言卻是 Java,因此本著 “實踐出真知” 的原則,我想嘗試用 Java 語言來對示例進行改寫,在分享思路的同時,也希望能夠有人與我討論,甚至指出我的錯誤,在此深表感謝。
廢話不多說了,我們趕緊開始
專案地址
git clone https://gitee.com/kendoziyu/code-refactoring-example.git
起點
有些看到文章的小夥伴,可能還沒拿到這本《重構2》,所以我先把原文需求貼出來,另外在改寫時,我會參考並結合《重構》第 1 版中的程式碼。
設想有一個戲劇演出團,演員們經常要去各種場合表演戲劇。通常客戶(customer)會指定幾齣劇目,而劇團則根據觀眾(audience)人數及劇目型別向客戶收費。該團目前出演兩種戲劇:悲劇(tragedy)和喜劇(comedy)。給客戶發出賬單時,劇團還根據到場觀眾的數量給出“觀眾量積分”(volume credit)優惠,下次客戶再請劇團表演時,可以使用積分獲得折扣————你可以把它看作一種提升客戶忠誠度的方式。
該劇團將 劇目 的資料儲存在一個簡單的 JSON 檔案中。
plays.json...
{
"hamlet":{"name":"Hamlet", "type":"tragedy"},
"as-like":{"name":"As You Like It", "type":"comedy"},
"othello":{"name":"Othello", "type":"tragedy"}
}
他們開出的 賬單 也儲存在一個 JSON 檔案裡。
invoices.json...
{
"customer":"BigCo",
"performances":[
{
"playId":"hamlet",
"audience":55
},
{
"playId":"as-like",
"audience":35
},
{
"playId":"othello",
"audience":40
}
]
}
等下我要來解析這兩組 JSON 物件,不妨先來分析一下實體類之間的關係:
發票(Invoice)
public class Invoice {
private String customer;
private List<Performance> performances;
public String getCustomer() {
return customer;
}
public void setCustomer(String customer) {
this.customer = customer;
}
public List getPerformances() {
return performances;
}
public void setPerformances(List performances) {
this.performances = performances;
}
}
表演(Performance)
public class Performance {
private String playId;
private int audience;
public String getPlayId() {
return playId;
}
public void setPlayId(String playId) {
this.playId = playId;
}
public int getAudience() {
return audience;
}
public void setAudience(int audience) {
this.audience = audience;
}
}
劇目(Play)
public class Play {
private String name;
private String type;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
}
接著,書中直接就給出了 列印賬單資訊 的函式 function statement(invoice, plays) {}。注意,《重構2》書中有提到,
當我在程式碼塊上方使用了斜體(中文對應楷體)標記的題頭 “function xxx” 時,表明該程式碼位於題頭所在函式、檔案或類的作用域內。
所以,結合《重構(第 1 版)》中的 Java 示例,我對第二版的示例做了一些改造:
Statement.java...
public class Statement {
private Invoice invoice;
private Map<String, Play> plays;
public Statement(Invoice invoice, Map<String, Play> plays) {
this.invoice = invoice;
this.plays = plays;
}
public String show() {
int totalAmount = 0;
int volumeCredits = 0;
String result = String.format("Statement for %s\n", invoice.getCustomer());
StringBuilder stringBuilder = new StringBuilder(result);
Locale locale = new Locale("en", "US");
NumberFormat format = NumberFormat.getCurrencyInstance(locale);
for (Performance performance : invoice.getPerformances()) {
Play play = plays.get(performance.getPlayId());
int thisAmount = 0;
switch (play.getType()) {
case "tragedy":
thisAmount = 40000;
if (performance.getAudience() > 30) {
thisAmount += 1000 * (performance.getAudience() - 30);
}
break;
case "comedy":
thisAmount = 30000;
if (performance.getAudience() > 20) {
thisAmount += 10000 + 500 *(performance.getAudience() - 20);
}
thisAmount += 300 * performance.getAudience();
break;
default:
throw new RuntimeException("unknown type:" + play.getType());
}
volumeCredits += Math.max(performance.getAudience() - 30, 0);
if ("comedy".equals(play.getType())) {
volumeCredits += Math.floor(performance.getAudience() / 5);
}
stringBuilder.append(String.format(" %s: %s (%d seats)\n", play.getName(), format.format(thisAmount/100), performance.getAudience()));
totalAmount += thisAmount;
}
stringBuilder.append(String.format("Amount owed is %s\n", format.format(totalAmount/100)));
stringBuilder.append(String.format("You earned %s credits\n", volumeCredits));
return stringBuilder.toString();
}
}
值得一提的有:
-
從 Java 1.7 開始,switch 開始支援字串了
-
NumberFormat.getCurrencyInstance 這個 API,可以為我們列印貨幣資訊
Main.java...
public class Main {
static final String plays = "{" +
"\"hamlet\":{\"name\":\"Hamlet\",\"type\":\"tragedy\"}," +
"\"as-like\":{\"name\":\"As You Like It\",\"type\":\"comedy\"}," +
"\"othello\":{\"name\":\"Othello\",\"type\":\"tragedy\"}" +
"}";
static final String invoices = "[{" +
"\"customer\":\"BigCo\",\"performances\":[" +
"{\"playId\":\"hamlet\",\"audience\":55}" +
"{\"playId\":\"as-like\",\"audience\":35}" +
"{\"playId\":\"othello\",\"audience\":40}" +
"]" +
"}]";
public static void main(String[] args) {
TypeReference<Map<String, Play>> typeReference = new TypeReference<Map<String, Play>>(){};
Map<String, Play> playMap = JSONObject.parseObject(plays, typeReference);
List<Invoice> invoiceList = JSONObject.parseArray(invoices, Invoice.class);
for (Invoice invoice : invoiceList) {
Statement statement = new Statement(invoice, playMap);
String result = statement.show();
System.out.println(result);
}
}
}
執行上面的 Main 主類,會得到如下輸出:
Statement for BigCo
Hamlet: $650.00 (55 seats)
As You Like It: $580.00 (35 seats)
Othello: $500.00 (40 seats)
Amount owed is $1,730.00
You earned 47 credits
新需求
在這個例子裡,我們的使用者希望對系統做幾個修改。首先,他們希望以 HTML 格式輸出詳單。另外,他們還希望增加表演(Play)的型別,雖然還沒決定增加哪種以及何時試演。這對戲劇場次的計費方式、積分方式都有影響。在這樣的需求前提下,如果你不想以後面對一堆莫名奇妙的 BUG,被逼著各種加班,那我們現在就要著手重構上面的示例了。
如果你要給程式增加一個特性,但是發現程式碼因缺乏良好的結構而不易於進行更改,那就先重構哪個程式,使其比較容易新增該特性,然後再新增該特性。
重構第一步
重構前,先檢查自己是否有一套可靠的測試集。這些測試必須有自我檢驗能力。
所以,我把 Main.java 稍微改變了一下,設計成了一個簡單的測試:
點選檢視 StatementTest.java
- 基於 Junit 的單元測試
public class StatementTest {
@Test
public void test() {
String expected = "Statement for BigCo\n" +
" Hamlet: $650.00 (55 seats)\n" +
" As You Like It: $580.00 (35 seats)\n" +
" Othello: $500.00 (40 seats)\n" +
"Amount owed is $1,730.00\n" +
"You earned 47 credits\n";
final String plays = "{" +
"\"hamlet\":{\"name\":\"Hamlet\",\"type\":\"tragedy\"}," +
"\"as-like\":{\"name\":\"As You Like It\",\"type\":\"comedy\"}," +
"\"othello\":{\"name\":\"Othello\",\"type\":\"tragedy\"}" +
"}";
final String invoices = "{" +
"\"customer\":\"BigCo\",\"performances\":[" +
"{\"playId\":\"hamlet\",\"audience\":55}" +
"{\"playId\":\"as-like\",\"audience\":35}" +
"{\"playId\":\"othello\",\"audience\":40}" +
"]" +
"}";
TypeReference
接下來的可以照著書上的要求執行,以微小的步伐開始你的重構之旅了,如果有不明白的也可以參考一下我的例子 code-refactoring-example
拆分計算階段和格式化階段
我們希望同樣的計算函式可以被 文字版 詳單和 HTML版 詳單共用。
實現複用有許多種方法,而我最喜歡的技術是 拆分階段。這裡我們的目標是將邏輯分成兩部分:一部分計算詳單所需的資料,另一部分將資料渲染成文字或者HTML。第一階段會建立一箇中轉資料結構,再它傳遞給第二階段。
我們可以建立一個 StatementData 作為兩個階段間傳遞的中間資料結構。建議大家根據書上的講解實際操練,這裡僅僅提供一種思路,我的實操過程已經放在了 Gitee 上面,有興趣的可以參考和修改。
我們這裡拆分函式時有一個目標:讓 renderPlainText 只操作通過 data 傳遞進來的資料(data 就是 StatementData 的例項物件),經過一系列搬移函式之後,我們可以達成這個目標:
/**
* 使用純文字渲染
* @param data 詳單資料
* @return
*/
private String renderPlainText(StatementData data) {
String result = String.format("Statement for %s\n", data.getCustomer());
StringBuilder stringBuilder = new StringBuilder(result);
for (Performance performance : data.getPerformances()) {
stringBuilder.append(String.format(" %s: %s (%d seats)\n", performance.getPlay().getName(), usd(performance.getAmount()), performance.getAudience()));
}
stringBuilder.append(String.format("Amount owed is %s\n", usd(data.getTotalAmount())));
stringBuilder.append(String.format("You earned %s credits\n", data.getTotalVolumeCredits()));
return stringBuilder.toString();
}
按計算過程重組計算過程
接下來我們將注意力集中到下一個特性改動:支援更多型別的戲劇,以及支援他們各自的價格計算和觀眾量積分計算。而改動的核心在 enrichPerformance 函式就是關鍵所在,因為正是它用每場演出的資料來填充中轉資料結構。目前它直接呼叫了計算價格函式 amountFor,和計算觀眾量積分函式 volumeCreditsFor 。我們需要建立一個類,通過這個類來呼叫這些函式。由於這個類存放了與每場演出相關資料的計算函式,於是我們把它稱為演出計算器 PerformanceCalculator。
我們把 amountFor, volumeCredits 都搬到了 PerformanceCalculator 中。play 欄位嚴格來說,是不需要搬移的,因為它並未體現出多型性。但是這樣可以把所有資料轉換集中到一處地方,保證了程式碼的一致性和清晰度。改動後如下:
private Performance enrichPerformance(Performance performance) {
PerformanceCalculator calculator = new PerformanceCalculator(performance, playFor(performance));
performance.setPlay(calculator.play());
performance.setAmount(calculator.amount());
performance.setVolumeCredits(calculator.volumeCredits());
return performance;
}
以工廠函式取代建構函式
private Performance enrichPerformance(Performance performance) {
PerformanceCalculator calculator = createPerformanceCalculator(performance, playFor(performance));
...(同上)
return performance;
}
private PerformanceCalculator createPerformanceCalculator(Performance performance, Play play) {
return new PerformanceCalculator(performance, play);
}
以子類取代型別碼,新建 ComedyCalculator 和 TragedyCalculator 並且讓他們繼承 PerformanceCalculator
private PerformanceCalculator createPerformanceCalculator(Performance performance, Play play) {
switch (play.getType()) {
case "tragedy": return new TragedyCalculator(performance, play);
case "comedy": return new ComedyCalculator(performance, play);
default:
throw new RuntimeException("unknown type:" + play.getType());
}
}
以多型取代條件表示式
public class ComedyCalculator extends PerformanceCalculator {
public ComedyCalculator(Performance performance, Play play) {
super(performance, play);
}
@Override
public int amount() {
int result = 30000;
if (performance.getAudience() > 20) {
result += 10000 + 500 *(performance.getAudience() - 20);
}
result += 300 * performance.getAudience();
return result;
}
@Override
public int volumeCredits() {
return (int) (super.volumeCredits() + Math.floor(performance.getAudience() / 5));
}
}
public class TragedyCalculator extends PerformanceCalculator {
public TragedyCalculator(Performance performance, Play play) {
super(performance, play);
}
@Override
public int amount() {
int result = 40000;
if (performance.getAudience() > 30) {
result += 1000 * (performance.getAudience() - 30);
}
return result;
}
}
總結
以一張圖總結本文內容:
- 例中我們用到了數種重構手法。包括提煉函式,內聯變數,搬移函式,以多型取代條件表示式等。
- 我們用 拆分階段 的技術分離計算邏輯與輸出格式化的邏輯。
好程式碼的檢驗標準就是人們能否輕而易舉地修改它!
與君共勉
程式設計時,需要遵循營地法則:希望我們都可以“保證你離開時的程式碼庫一定比你來時更健康”。