「重構是一種對軟體內部結構的改善,目的是在不改變軟體的可見行為的情況下,使其更易理解,修改成本更低。」——馬丁·福勒(Martin Fowler)
本章聚焦重構的「如何做」;關於「何時該還債、優先級如何排」請見 技術債務管理。
為什麼要重構?#
對專案的價值#
- 保持程式碼品質 - 防止程式碼腐化到無法維護
- 持續演進 - 無法一開始就設計完美,需求在變化
- 避免過度設計 - 遇到問題再重構,有的放矢
對個人的價值#
- 鍛煉設計能力 - 應用設計原則與模式的最佳練習場
- 成就感 - 將爛程式碼變好的滿足感
- 能力證明 - 重構能力是衡量程式設計師水平的有效指標
「初級工程師在維護程式碼,高級工程師在設計程式碼,資深工程師在重構程式碼」
重構什麼?#
大型重構#
對頂層設計的重構:
- 系統架構
- 模組劃分
- 類別之間的關係
- 程式碼分層
手段:分層、模組化、解耦、抽象可複用組件
特點:
- 改動範圍大
- 影響面廣
- 需要有計劃、有組織地進行
- 需要資深工程師主導
小型重構#
對程式碼細節的重構:
- 規範命名
- 消除重複程式碼
- 拆分過長的函式
- 消除過大的類別
手段:應用編碼規範
特點:
- 改動集中
- 風險較低
- 隨時可做
什麼時候重構?#
不要等到程式碼爛到無法維護才重構!
持續重構#
將重構作為開發的一部分,而不是獨立的大規模活動。
flowchart TD
A[日常開發流程] --> B[修改功能時]
A --> C[Code Review 時]
A --> D[閒暇時]
B --> E[順手重構相關程式碼]
C --> F[發現問題立即修正]
D --> G[主動改善可最佳化的程式碼]
E --> H[保持程式碼品質]
F --> H
G --> H持續重構意識#
技術在更新、需求在變化、人員在流動,程式碼品質總會下降。時刻保持持續重構意識比任何技巧都重要。
如何重構?#
大型重構策略#
- 制定計劃 - 明確目標和範圍
- 分階段進行 - 每階段完成一小部分
- 保持可執行 - 每次提交程式碼都能正常執行
- 控制影響範圍 - 考慮兼容性,必要時寫過渡程式碼
每個階段最好控制在一天內完成,避免與新功能開發衝突。
小型重構策略#
- 隨時進行
- 可借助靜態分析工具(CheckStyle、FindBugs、PMD)
- Code Review 時發現即修正
安全重構的保障:單元測試#
為什麼需要單元測試?#
重構改變的是程式碼結構,不改變行為。如何確保行為不變?
答案:充分的單元測試覆蓋
// 重構前:驗證 IP 地址
public boolean isValidIp(String ip) {
// 使用正則表達式
return ip.matches("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$");
}
// 重構後:使用更清晰的邏輯
public boolean isValidIp(String ip) {
String[] parts = ip.split("\\.");
if (parts.length != 4) return false;
for (String part : parts) {
int value = Integer.parseInt(part);
if (value < 0 || value > 255) return false;
}
return true;
}
// 單元測試確保行為一致
@Test
void testValidIp() {
assertTrue(isValidIp("192.168.1.1"));
assertTrue(isValidIp("0.0.0.0"));
assertTrue(isValidIp("255.255.255.255"));
assertFalse(isValidIp("256.1.1.1"));
assertFalse(isValidIp("1.2.3"));
}什麼是好的單元測試?#
FIRST 原則:
| 原則 | 說明 |
|---|---|
| Fast | 執行速度快 |
| Independent | 測試之間獨立 |
| Repeatable | 可重複執行 |
| Self-validating | 自動判斷成敗 |
| Timely | 及時編寫 |
提高程式碼可測試性#
flowchart LR
subgraph 難以測試
direction TB
T1[Transaction] -->|new| U1[UserRepo]
T1 -->|靜態呼叫| I1[IdGenerator]
T1 -->|全域變數| R1[Runtime]
end
subgraph 可測試
direction TB
T2[Transaction] -.->|注入| U2[UserRepo]
T2 -.->|注入| I2[IdValidator]
T2 -.->|注入| R2[MemoryChecker]
end
難以測試 -->|依賴注入重構| 可測試難以測試的程式碼:
public class Transaction {
public boolean execute(String id, Long buyerId) {
// 直接 new 物件,無法替換為 mock
User buyer = new UserRepo().getUser(buyerId);
// 直接呼叫靜態方法
if (!IdGenerator.isValid(id)) {
return false;
}
// 使用全域變數
if (Runtime.getRuntime().freeMemory() < 1024) {
return false;
}
return true;
}
}可測試的程式碼:
public class Transaction {
private UserRepo userRepo;
private IdValidator idValidator;
private MemoryChecker memoryChecker;
// 依賴注入
public Transaction(UserRepo userRepo,
IdValidator idValidator,
MemoryChecker memoryChecker) {
this.userRepo = userRepo;
this.idValidator = idValidator;
this.memoryChecker = memoryChecker;
}
public boolean execute(String id, Long buyerId) {
User buyer = userRepo.getUser(buyerId);
if (!idValidator.isValid(id)) {
return false;
}
if (!memoryChecker.hasEnoughMemory()) {
return false;
}
return true;
}
}可測試性設計要點:
- 使用依賴注入 - 便於替換為 mock 物件
- 避免靜態方法 - 或封裝成可注入的物件
- 避免全域變數 - 不可預測的狀態影響測試
- 避免在建構函式中執行複雜邏輯
解耦合技巧#
為什麼要解耦?#
高耦合程式碼的問題:
- 改一處影響多處
- 難以理解
- 難以測試
- 難以複用
解耦方法#
flowchart LR
subgraph 耦合
A1[模組 A] -->|直接依賴| B1[模組 B]
end
subgraph 解耦
A2[模組 A] --> I[介面層]
I --> B2[模組 B]
end
耦合 -->|重構| 解耦1. 封裝與抽象
// 耦合:直接使用實作類別
RedisClient client = new RedisClient();
// 解耦:使用介面
Cache cache = new RedisCache();2. 中間層
// 耦合:模組 A 直接依賴模組 B
A -> B
// 解耦:通過介面層
A -> Interface <- B3. 依賴注入
// 耦合:內部創建依賴
class OrderService {
private UserService userService = new UserService();
}
// 解耦:外部注入依賴
class OrderService {
private UserService userService;
public OrderService(UserService userService) {
this.userService = userService;
}
}4. 事件驅動
// 耦合:直接呼叫
orderService.createOrder();
paymentService.processPayment();
inventoryService.reduceStock();
// 解耦:發布事件
eventBus.publish(new OrderCreatedEvent());
// 各服務訂閱事件自行處理避免過度設計#
什麼是過度設計?#
- 為不存在的需求預先設計
- 為極小概率的場景做複雜處理
- 盲目應用設計模式
- 過度抽象和封裝
如何避免?#
YAGNI 原則:You Ain’t Gonna Need It
- 只解決當前問題 - 預留擴展點,但不提前實作
- 簡單方案優先 - 能用簡單方式解決就不要複雜化
- 持續重構 - 需要時再演化設計
簡單與直觀是永恆的解決方案#
// 過度設計:為了可能的擴展引入工廠 + 策略
interface PaymentStrategy { }
class PaymentStrategyFactory { }
class WechatPaymentStrategy implements PaymentStrategy { }
class AlipayPaymentStrategy implements PaymentStrategy { }
// 簡單直觀:目前只有一種支付方式
class PaymentService {
void pay(Order order) {
wechatApi.pay(order);
}
}
// 等需要多種支付方式時再重構接縫與遺留碼安全重構#
前面的安全保障建立在「已有測試」之上。但遺留碼最大的困境正是:它沒有測試,而且因為糾纏的依賴,連測試都寫不出來。
先用特性化測試罩住#
當你不確定一段程式碼「應該」做什麼,只知道它「目前」做什麼時,先寫 characterization test(特性化測試)把現狀凍結起來。
// 不知道正確輸出該是什麼,就先讓測試告訴你目前輸出
@Test
void characterize() {
String actual = legacyReport(sampleInput);
// 先填一個猜的值,跑一次,把實際輸出貼回來
assertEquals("PASTE_ACTUAL_OUTPUT_HERE", actual);
}它的目的不是驗證「對不對」,而是「重構後行為有沒有變」。有了這張網,才敢動手。
接縫(Seam):在不改行為下插入測試點#
接縫是「可以改變程式行為、卻不必在該處動刀」的位置。找到接縫,就能把寫死的依賴替換成可控的替身。
| 接縫類型 | 替換時機 | Java 典型做法 |
|---|---|---|
| 物件接縫(Object Seam) | 執行期 | 把 new 出來的依賴改成建構子注入,測試傳 mock |
| 編譯期接縫 | 編譯期 | 以介面取代具體類別,編譯時換實作 |
| 連結期接縫 | 連結/載入期 | 換掉 classpath 上的實作(測試用替身 jar) |
物件接縫最常用,也最安全:
// 沒有接縫:依賴寫死在內部,無從替換
class OrderService {
boolean place(Order o) {
return new PaymentGateway().charge(o); // 寫死
}
}
// 開出物件接縫:注入依賴,測試可傳替身
class OrderService {
private final PaymentGateway gateway;
OrderService(PaymentGateway gateway) { this.gateway = gateway; }
boolean place(Order o) {
return gateway.charge(o);
}
}抽芽方法:在舊碼裡長出可測的新碼#
當你必須在一大坨難測的舊方法裡加新邏輯,不要直接塞進去。改用 Sprout Method(抽芽方法):把新邏輯寫成一個全新的、可獨立測試的方法,舊方法只負責呼叫它。
// 舊方法又長又難測,但新規則可以「抽芽」成獨立方法
class Invoice {
void post() {
// ...原有大量難測邏輯...
applyLateFee(entries); // 新增:呼叫抽芽出來的新方法
// ...原有邏輯...
}
// 新方法乾淨、無糾纏依賴,可直接單元測試
List<Entry> applyLateFee(List<Entry> entries) {
// 新規則的純邏輯
return ...;
}
}舊碼維持原樣(風險最低),新邏輯卻從第一行起就被測試覆蓋。隨著一次次抽芽,可測的孤島逐漸連成片。
重構過程中聞到的各種壞味道(神秘命名、過長函式、特性嫉妒…),可對照 程式碼壞味道 查找對應手法。
重構實戰清單#
重構時機檢查
需要重構的信號:
- 函式超過 50 行
- 類別超過 500 行
- 參數超過 5 個
- 重複程式碼出現三次以上
- 深層嵌套超過 3 層
- 難以為類別取名
- 修改一處需要改多處
重構步驟
- 確保有測試覆蓋
- 小步修改
- 每步都能編譯執行
- 頻繁提交
- 保持行為不變
常見重構手法
| 手法 | 適用情況 |
|---|---|
| 提取方法 | 函式過長 |
| 內聯方法 | 方法體比方法名更清晰 |
| 提取類別 | 類別職責過多 |
| 內聯類別 | 類別幾乎沒做什麼事 |
| 移動方法 | 方法在錯誤的類別中 |
| 重命名 | 名稱不能表達意圖 |
| 參數物件 | 參數過多 |
| 使用多型取代條件 | 大量 if-else 或 switch |
破窗效應:一旦有人往專案裡堆砌爛程式碼,就會有更多人跟進。資深工程師要負起責任,保持程式碼品質處於良好狀態。