1. 程式人生 > >Java改寫重構第2版第一個示例

Java改寫重構第2版第一個示例

# 寫在前面 **《重構:改善既有程式碼的設計》**是一本經典的軟體工程必讀書籍。作者馬丁·福勒強調**重構技術是以微小的步伐修改程式**。 **但是**,從國內的情況來而論,“重構”的概念表裡分離。大家往往喜歡打著“重構”的名號,實際上卻乾的是“刀劈斧砍”的勾當。產生這種現象的原因,一方面是程式設計師希望寫出可維護,可複用,可拓展,靈活性好的程式碼,使系統具長期生命力;另一方面,重構的紮實功夫要學起來、做起來,頗不是一件輕鬆的事,且不說詳盡到近乎瑣碎的重構手法,光是單元測試一事,怕是已有九成同行無法企及。所以,重構變質為重寫,研發團隊拿著公司的經費,幹著“重複造輪子”的事兒,最終“重構”後的軟體仍然不能使人滿意,反倒是一堆問題,使用者不願意買單,程式設計師不願意繼續維護,管理人員也擔著巨大的壓力。痛苦的滋味在心底蔓延。 轉頭來看,Martin Fowler 時隔 20 年後的第 2 版,沒有照搬第一版,而是把工夫做得更加紮實了,我有幸發現這本書,解我之惑,實屬幸事一件。由於第 2 版中使用 javascript 作為展現重構手法的語言,可是本人慣用的語言卻是 Java,因此本著 “實踐出真知” 的原則,我想嘗試用 Java 語言來對示例進行改寫,在分享思路的同時,也希望能夠有人與我討論,甚至指出我的錯誤,在此深表感謝。 > 廢話不多說了,我們趕緊開始 # 專案地址 [Gitee 專案地址](https://gitee.com/kendoziyu/code-refactoring-example) ```bash git clone https://gitee.com/kendoziyu/code-refactoring-example.git ``` # 起點 > 有些看到文章的小夥伴,可能還沒拿到這本《重構2》,所以我先把原文需求貼出來,另外在改寫時,我會參考並結合《重構》第 1 版中的程式碼。 設想有一個戲劇演出團,演員們經常要去各種場合表演戲劇。通常客戶(customer)會指定幾齣劇目,而劇團則根據觀眾(audience)人數及劇目型別向客戶收費。該團目前出演兩種戲劇:悲劇(tragedy)和喜劇(comedy)。給客戶發出賬單時,劇團還根據到場觀眾的數量給出“觀眾量積分”(volume credit)優惠,下次客戶再請劇團表演時,可以使用積分獲得折扣————你可以把它看作一種提升客戶忠誠度的方式。 該劇團將 **劇目** 的資料儲存在一個簡單的 JSON 檔案中。 **_plays.json..._** ```java { "hamlet":{"name":"Hamlet", "type":"tragedy"}, "as-like":{"name":"As You Like It", "type":"comedy"}, "othello":{"name":"Othello", "type":"tragedy"} } ``` 他們開出的 **賬單** 也儲存在一個 JSON 檔案裡。 **_invoices.json..._** ```java { "customer":"BigCo", "performances":[ { "playId":"hamlet", "audience":55 }, { "playId":"as-like", "audience":35 }, { "playId":"othello", "audience":40 } ] } ``` 等下我要來解析這兩組 JSON 物件,不妨先來分析一下實體類之間的關係: ![](https://img2020.cnblogs.com/blog/1730512/202010/1730512-20201016231652659-1425591927.jpg)
發票(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..._ ```java public class Statement { private Invoice invoice; private Map plays; public Statement(Invoice invoice, Map 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(); } } ``` 值得一提的有: 1. 從 Java 1.7 開始,**switch** 開始支援字串了 2. **_NumberFormat.getCurrencyInstance_** 這個 API,可以為我們列印貨幣資訊 _Main.java..._ ```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> typeReference = new TypeReference>(){}; Map playMap = JSONObject.parseObject(plays, typeReference); List 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 主類,會得到如下輸出: ```json 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> typeReference = new TypeReference>(){};
        Map playMap = JSONObject.parseObject(plays, typeReference);
        Invoice invoice = JSONObject.parseObject(invoices, Invoice.class);
        Statement statement = new Statement(invoice, playMap);
        String result = statement.show();
        Assert.assertEquals(expected, result);
    }
}
  
接下來的可以照著書上的要求執行,以微小的步伐開始你的重構之旅了,如果有不明白的也可以參考一下我的例子 [code-refactoring-example](https://gitee.com/kendoziyu/code-refactoring-example) # 拆分計算階段和格式化階段 我們希望同樣的計算函式可以被 **文字版** 詳單和 **HTML版** 詳單共用。 實現複用有許多種方法,而我最喜歡的技術是 **拆分階段**。這裡我們的目標是將邏輯分成兩部分:一部分計算詳單所需的資料,另一部分將資料渲染成文字或者HTML。第一階段會建立一箇中轉資料結構,再它傳遞給第二階段。 我們可以建立一個 StatementData 作為兩個階段間傳遞的中間資料結構。建議大家根據書上的講解實際操練,這裡僅僅提供一種思路,我的實操過程已經放在了 Gitee 上面,有興趣的可以參考和修改。 我們這裡拆分函式時有一個目標:讓 renderPlainText 只操作通過 data 傳遞進來的資料(data 就是 StatementData 的例項物件),經過一系列**搬移函式**之後,我們可以達成這個目標: ```java /** * 使用純文字渲染 * @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 欄位嚴格來說,是不需要搬移的,因為它並未體現出多型性。但是這樣可以把所有資料轉換集中到一處地方,保證了程式碼的一致性和清晰度。改動後如下: ```java 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; } ``` **以工廠函式取代建構函式** ```java 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 ```java 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()); } } ``` **以多型取代條件表示式** ```java 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)); } } ``` ```java 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; } } ``` # 總結 以一張圖總結本文內容: ![](https://img2020.cnblogs.com/blog/1730512/202010/1730512-20201018184133725-465864060.jpg) 1. 例中我們用到了數種重構手法。包括**提煉函式**,**內聯變數**,**搬移函式**,**以多型取代條件表示式**等。 2. 我們用 **拆分階段** 的技術分離計算邏輯與輸出格式化的邏輯。 **好程式碼的檢驗標準就是人們能否輕而易舉地修改它!** # 與君共勉 程式設計時,需要遵循營地法則:希望我們都可以“保證你離開時的程式碼庫一定比你來時更健