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

寫在前面

《重構:改善既有代碼的設計》是一本經典的軟件工程必讀書籍。作者馬丁·福勒強調重構技術是以微小的步伐修改程序

但是,從國內的情況來而論,「重構」的概念表裡分離。大家往往喜歡打着「重構」的名號,實際上卻乾的是「刀劈斧砍」的勾當。產生這種現象的原因,一方面是程序員希望寫出可維護,可復用,可拓展,靈活性好的代碼,使系統具長期生命力;另一方面,重構的紮實功夫要學起來、做起來,頗不是一件輕鬆的事,且不說詳盡到近乎瑣碎的重構手法,光是單元測試一事,怕是已有九成同行無法企及。所以,重構變質為重寫,研發團隊拿着公司的經費,幹着「重複造輪子」的事兒,最終「重構」後的軟件仍然不能使人滿意,反倒是一堆問題,用戶不願意買單,程序員不願意繼續維護,管理人員也擔著巨大的壓力。痛苦的滋味在心底蔓延。

轉頭來看,Martin Fowler 時隔 20 年後的第 2 版,沒有照搬第一版,而是把工夫做得更加紮實了,我有幸發現這本書,解我之惑,實屬幸事一件。由於第 2 版中使用 javascript 作為展現重構手法的語言,可是本人慣用的語言卻是 Java,因此本着 「實踐出真知」 的原則,我想嘗試用 Java 語言來對示例進行改寫,在分享思路的同時,也希望能夠有人與我討論,甚至指出我的錯誤,在此深表感謝。

廢話不多說了,我們趕緊開始

項目地址

Gitee 項目地址

git clone //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();
    }
}

值得一提的有:

  1. 從 Java 1.7 開始,switch 開始支持字符串了

  2. 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> 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

拆分計算階段和格式化階段

我們希望同樣的計算函數可以被 文本版 詳單和 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;
    }
}

總結

以一張圖總結本文內容:

  1. 例中我們用到了數種重構手法。包括提煉函數內聯變量搬移函數以多態取代條件表達式等。
  2. 我們用 拆分階段 的技術分離計算邏輯與輸出格式化的邏輯。

好代碼的檢驗標準就是人們能否輕而易舉地修改它!

與君共勉

編程時,需要遵循營地法則:希望我們都可以「保證你離開時的代碼庫一定比你來時更健康」。