問題背景#

這是 legacy 系統中最令人沮喪的事情之一:你需要做一個修改,以為「就這樣了」,然後發現有十幾個地方需要做同樣的修改。

如果你了解 refactoring,你就知道移除重複不需要是大工程——可以在日常工作中以小塊的方式進行。只要人們不持續引入新的重複,系統就會隨時間改善。

關鍵問題是:當我們徹底地把一個區域的重複消除,結果會怎樣?答案往往令人驚喜。


範例:Command 類別的重複#

一個 Java 網路系統需要發送 command 到 server。有兩個 command class:AddEmployeeCmdLoginCommand

初始狀態#

兩個 class 各自獨立,幾乎沒有共享程式碼:

public class AddEmployeeCmd {
    String name;
    String address;
    String city;
    String state;
    String yearlySalary;

    private static final byte[] header = {(byte)0xde, (byte)0xad};
    private static final byte[] commandChar = {0x02};
    private static final byte[] footer = {(byte)0xbe, (byte)0xef};
    private static final int SIZE_LENGTH = 1;
    private static final int CMD_BYTE_LENGTH = 1;

    public void write(OutputStream outputStream)
            throws Exception {
        outputStream.write(header);
        outputStream.write(getSize());
        outputStream.write(commandChar);
        outputStream.write(name.getBytes());
        outputStream.write(0x00);
        outputStream.write(address.getBytes());
        outputStream.write(0x00);
        // ... 更多欄位 ...
        outputStream.write(footer);
    }
}

LoginCommand 的結構幾乎完全一樣,只是欄位不同(userNamepasswd)且 commandChar 不同。

Figure 21.1: AddEmployeeCmd and LoginCommand

乍看之下,重複量不大。但讓我們一步一步地移除,看看最終結果。


First Steps#

原則:從小處開始#

面對重複時,作者的第一反應是退後一步看全局。但隨即意識到自己過度思考了。先移除小片段的重複,這會讓大片段的重複更容易被看見。

第一步:抽取 writeField 方法#

LoginCommandwrite 方法中,每寫一個字串都跟著一個 null 字元(0x00)。把這個模式抽取為 writeField

void writeField(OutputStream outputStream, String field) {
    outputStream.write(field.getBytes());
    outputStream.write(0x00);
}

替換後 LoginCommand.write 變成:

public void write(OutputStream outputStream) throws Exception {
    outputStream.write(header);
    outputStream.write(getSize());
    outputStream.write(commandChar);
    writeField(outputStream, userName);
    writeField(outputStream, passwd);
    outputStream.write(footer);
}

第二步:引入 Command 超類別#

因為兩個 class 都是 command,引入一個 Command 超類別,將 writeField 上拉:

Figure 21.2: Command hierarchy

// Command 超類別
class Command {
    protected void writeField(OutputStream outputStream,
                              String field) {
        outputStream.write(field.getBytes());
        outputStream.write(0x00);
    }
}

Figure 21.3: Pulling up writeField

第三步:抽取 writeBody#

兩個 class 的 write 方法有相同的結構:write header → write size → write commandChar → write 欄位 → write footer。差異只在「write 欄位」的部分。將這部分抽取為 writeBody

// LoginCommand
private void writeBody(OutputStream outputStream) throws Exception {
    writeField(outputStream, userName);
    writeField(outputStream, passwd);
}

當兩個方法看起來大致相同時,抽取差異的部分到其他方法中。這樣就能讓它們變得完全相同,然後消除其中一個。

第四步:上拉共同資料#

headerfooterSIZE_LENGTHCMD_BYTE_LENGTH 在兩個 class 中的值完全相同。將它們上拉到 Command,改為 protected

對於值不同的 commandChar,引入 abstract getter:

public class Command {
    protected static final byte[] header = {(byte)0xde, (byte)0xad};
    protected static final byte[] footer = {(byte)0xbe, (byte)0xef};
    protected static final int SIZE_LENGTH = 1;
    protected static final int CMD_BYTE_LENGTH = 1;

    protected abstract char[] getCommandChar();
    protected abstract void writeBody(OutputStream outputStream);
    // ...
}

第五步:上拉 write 方法#

現在 write 方法在兩個 class 中完全相同,可以上拉到 Command

public void write(OutputStream outputStream) throws Exception {
    outputStream.write(header);
    outputStream.write(getSize());
    outputStream.write(commandChar);
    writeBody(outputStream);
    outputStream.write(footer);
}

第六步:統一 getSize#

Figure 21.4: Pulling up getSize

兩個 class 的 getSize 都是計算 header + SIZE_LENGTH + CMD_BYTE_LENGTH + footer + body size。抽取 getBodySize 讓差異更明顯,然後上拉 getSize

// Command
private int getSize() {
    return header.length + SIZE_LENGTH
        + CMD_BYTE_LENGTH + footer.length
        + getBodySize();
}

第七步:消除 getBodySize 中的重複#

getBodySize 計算每個 field 的 getBytes().length + 1。抽取 getFieldSize

protected int getFieldSize(String field) {
    return field.getBytes().length + 1;
}

第八步:泛化——使用 List#

兩個 class 都接受一組參數、計算大小、寫出去。唯一的差異是 commandChar。在 base class 中宣告一個 fields list,讓子類別在 constructor 中加入欄位:

class LoginCommand extends Command {
    public LoginCommand(String userName, String passwd) {
        fields.add(userName);
        fields.add(passwd);
    }

    protected char[] getCommandChar() {
        return new char[] { 0x01 };
    }
}

getBodySizewriteBody 都變成基於 list 的迴圈,可以上拉到 Command

最終結果#

public class Command {
    private static final byte[] header = {(byte)0xde, (byte)0xad};
    private static final byte[] footer = {(byte)0xbe, (byte)0xef};
    private static final int SIZE_LENGTH = 1;
    private static final int CMD_BYTE_LENGTH = 1;

    protected List fields = new ArrayList();
    protected abstract char[] getCommandChar();

    private void writeBody(OutputStream outputStream) { /* 迴圈 fields */ }
    private int getFieldSize(String field) { return field.getBytes().length + 1; }
    private int getBodySize() { /* 迴圈 fields */ }
    private int getSize() { /* 計算總大小 */ }
    private void writeField(OutputStream outputStream, String field) { /* ... */ }

    public void write(OutputStream outputStream) throws Exception {
        outputStream.write(header);
        outputStream.write(getSize());
        outputStream.write(commandChar);
        writeBody(outputStream);
        outputStream.write(footer);
    }
}

子類別變得極為精簡——只有 constructor(設定 fields)和 getCommandChar()

Figure 21.5: Command hierarchy with duplication pulled up


移除重複的好處#

新增 Command 變得簡單#

要新增一個 command,只需繼承 Command、在 constructor 中加入 fields、實作 getCommandChar()。不再需要 copy/paste 大量重複程式碼。

彈性未減#

如果需要發送非字串的欄位怎麼辦?AddEmployeeCommand 已經展示了做法——在 constructor 中轉換成字串。如果需要完全不同格式的 command 怎麼辦?

public class AggregateCommand extends Command {
    private List commands = new ArrayList();

    protected char[] getCommandChar() {
        return new char[] { 0x03 };
    }

    public void appendCommand(Command newCommand) {
        commands.add(newCommand);
    }

    protected void writeBody(OutputStream out) {
        out.write(commands.getSize());
        for (Iterator it = commands.iterator(); it.hasNext(); ) {
            Command innerCommand = (Command)it.next();
            innerCommand.write(out);
        }
    }
}

如果沒有先移除重複,實作這種 composite command 會困難得多。

Orthogonality(正交性)#

當你移除跨 class 的重複時,最終會得到非常小且聚焦的方法。每個方法做的事情沒有其他方法在做——這給了我們極大的優勢:正交性。改變一個方法的行為只影響一件事,不會波及其他。

命名一致性#

作者也提到要將 AddEmployeeCmd 重新命名為 AddEmployeeCommand——保持子類別命名一致,減少困惑。

Class 和 method 名稱中的縮寫是有問題的。它們可以接受,前提是使用一致。作者曾見過一個團隊同時使用 XXXXMgrXXXXMngr 來縮寫 manager,超過 50% 的時間你猜不到該用哪個後綴。


總結#

移除重複的核心策略:

  1. 從小處開始——先移除小片段的重複,讓大片段的重複顯現
  2. 抽取差異,統一相同——當兩個方法大致相同時,把不同的部分抽出來,讓它們完全相同
  3. 逐步上拉——將共同的方法和變數逐步上拉到超類別
  4. 泛化——尋找可以用更一般化的方式表達的模式(如使用 list 取代固定欄位)
  5. 享受成果——移除重複後,新增功能變得更簡單、更安全,程式碼獲得正交性