設計模式不是具體的程式碼,而是解決特定問題的思維框架——1994 年 GoF 四位作者把反覆出現的問題整理成通用解法,順帶讓開發者之間有了共同的「設計語言」。模式是重構的終點,不是起點:先寫出會痛的程式碼,才知道要往哪個模式收斂。
五條原則單看定義都很抽象,真正有用的是「違反時長什麼樣」。每條配一個具體反例與修正方向。
java.io 是裝飾器、Spring AOP 是代理、JdbcTemplate 是範本方法。框架裡全是模式,讀原始碼比背定義有效。先用上面的分類篩選,點任一列看完整說明與 Java 範例。
精選最常見的四種模式,都附上 Spring 框架裡的實際對應——這些不是教科書練習,是你每天在用的東西。
創建型 · 確保資源唯一性。
public class DatabasePool {
// volatile 確保多執行緒下的記憶體可見性
private static volatile DatabasePool instance;
private DatabasePool() {}
// 雙重檢查鎖定 (DCL) — 執行緒安全且效能較好
public static DatabasePool getInstance() {
if (instance == null) { // 第一次檢查(無鎖,快速)
synchronized (DatabasePool.class) {
if (instance == null) { // 第二次檢查(有鎖,安全)
instance = new DatabasePool();
}
}
}
return instance;
}
}
// Spring 中更推薦:直接用 @Component,框架保證 Bean 唯一 行為型 · 消除 if-else,動態切換演算法。
// 1. 定義策略介面
interface PaymentStrategy {
void pay(int amount);
String getType();
}
// 2. 具體策略(Spring Bean)
@Service("CreditCard")
class CreditCardPay implements PaymentStrategy {
public void pay(int amount) { System.out.println("信用卡支付: " + amount); }
public String getType() { return "CreditCard"; }
}
@Service("LinePay")
class LinePayStrategy implements PaymentStrategy {
public void pay(int amount) { System.out.println("LinePay 支付: " + amount); }
public String getType() { return "LinePay"; }
}
// 3. 上下文:Spring 自動將所有 PaymentStrategy 注入 Map
@Service
class PaymentService {
@Autowired
private Map<String, PaymentStrategy> strategies; // key = BeanName
public void process(String type, int amount) {
strategies.get(type).pay(amount); // 動態選擇,不需要 if-else!
}
} 行為型 · 解耦業務邏輯與後續通知。
// 1. 定義事件
class OrderCreatedEvent extends ApplicationEvent {
private final Order order;
public OrderCreatedEvent(Object source, Order order) {
super(source); this.order = order;
}
public Order getOrder() { return order; }
}
// 2. 發布者(不需知道有誰在監聽)
@Service
class OrderService {
@Autowired ApplicationEventPublisher publisher;
public void createOrder(Order order) {
// 核心業務邏輯...
publisher.publishEvent(new OrderCreatedEvent(this, order)); // 發布
}
}
// 3. 訂閱者(可以有多個,互相獨立)
@Component class EmailListener {
@EventListener
public void sendConfirmEmail(OrderCreatedEvent e) { /* 發確認信 */ }
}
@Component class InventoryListener {
@EventListener
public void deductStock(OrderCreatedEvent e) { /* 扣庫存 */ }
} 行為型 · 複用流程骨架,子類只決定細節。
// 抽象類別定義固定流程骨架
abstract class DataExporter {
// 範本方法(final 防止子類改變骨架順序)
public final void export(String filename) {
List<Object> data = fetchData(); // ← 子類實作
String formatted = format(data); // ← 子類實作
writeToFile(formatted, filename); // 固定步驟
System.out.println("匯出完成: " + filename);
}
protected abstract List<Object> fetchData();
protected abstract String format(List<Object> data);
private void writeToFile(String data, String f) {
System.out.println("寫入檔案 " + f);
}
}
// CSV 子類只需實作差異部分
class CsvExporter extends DataExporter {
protected List<Object> fetchData() { return List.of("Alice", "Bob"); }
protected String format(List<Object> d) {
return String.join(",", d.stream().map(Object::toString).toList());
}
}
// Spring 中的 JdbcTemplate 就是典型的範本方法應用 這一節原本是舊筆記「全域底層架構修煉」的第七章(那頁的七個主題已全部拆成獨立內容,原頁不再保留),2026-08-04 併進本頁——因為它談的三件事(識別 Code Smell、用模式消滅條件判斷、防禦性編程)跟模式清單是同一個主題的兩端:模式是重構走到最後收斂出來的形狀,不是設計第一天就該挑的選單。內容依照本站章節的寫法補上推導過程、真實失敗場景與「什麼時候不要用」。
談 Code Smell 與模式之前,先把要解決的問題看清楚:程式碼只寫一次,卻會被讀幾十次、改幾十次。重構的目標從來不是美觀,而是讓「多加一個需求」的成本不要隨專案年齡往上翹。
第 1 個月:calculateShipping() 12 行,一個 if-else 兩個分支
第 4 個月:加了離島加價 → 巢狀 if 第二層
第 7 個月:加了促銷免運 → 第三層,方法變成 80 行
第 11 個月:要改「離島 + 促銷」的組合
→ 得先讀懂全部 80 行才敢動一行
第 13 個月:沒人敢動了,新需求改成複製一份 calculateShippingV2()
第 18 個月:兩份的行為已經不一致,但沒有人說得出差在哪Martin Fowler 的定義是:在不改變外部可觀察行為的前提下,改善內部結構。「不改變行為」不是附註,是整件事的前提——一旦順手改了行為,出事時你就無法判斷是重構弄壞的,還是新需求本來就有 bug。
Code Smell 常被當成「寫得爛的地方」,但它原本的意思精確得多:一個表面徵狀,暗示底下可能有結構問題。像味道一樣——不一定是病,但值得走過去看一眼。
這兩個是同一個問題的兩面,方向剛好相反,合起來就是單一職責原則的操作型定義:
發散式修改 Divergent Change
一個類別,因為「很多不同的理由」而要改
→ OrderService 這季改了 5 次:運費規則、通知模板、
稽核欄位、發票格式、優惠計算
→ 職責放太多 ⇒ 要「拆開」
霰彈式修改 Shotgun Surgery
一個理由,卻要改「很多個類別」
→ 幣別小數位從 2 位改成 4 位,動了 11 個檔案
→ 同一個職責被撒得太散 ⇒ 要「聚攏」double、幣別用 String。徵狀是「同一段校驗邏輯到處複製」——因為型別本身不保證任何事。修法是包一個 Money 值物件,把校驗收進建構子,之後全系統只需要驗一次。String city, String district, String zip, String addr 這四個參數總是一起出現、一起傳、一起改。它們其實已經是一個概念(Address),只是還沒有被命名。order.getUser().getProfile().getCompany().getName()。每一個 . 都是一次「我知道你內部長什麼樣」的假設,中間任何一層改結構,你就得跟著改。「用設計模式消滅 if-else」大概是最常被誤解的一句話。if-else 本身沒有錯,它是最直白、最好讀的控制流。真正的問題是同一組分支散落在好幾個地方——加一個新分支要改 N 個檔案,也就是上一張卡的霰彈式修改。
L1 衛語句(Guard Clause)
「不成立就早點 return」提到最前面,消滅巢狀深度
成本:幾乎為零,任何情況都值得做
L2 表驅動(Map 查表)
分支只是「輸入 → 一個值」的對應,就不該寫成流程控制
Map<String, BigDecimal> RATE = Map.of("TW", ..., "JP", ...);
成本:低。但分支帶的是「行為」而不只是「值」時就不夠用
L3 策略模式(+ Spring Bean Map 注入)
分支決定的是「用哪一整套演算法」,而且被多處共用
寫法見本頁「程式碼範例」分頁的 PaymentStrategy
成本:中,見下方「真正的代價」
L4 狀態模式
分支決定的是「下一個狀態是什麼」,狀態之間還有轉移規則
訂單:待付款 → 已付款 → 出貨中 → 完成/取消
成本:高(類別數=狀態數),但比一堆散落的
if (status == X && status != Y) 便宜得多徵狀:新人接手兩週後說「我看不懂運費到底是怎麼算出來的」
診斷:原本 3 個分支的 if-else 被拆成
ShippingStrategy 介面 + 3 個實作 + 1 個 Factory + 1 個 enum
讀懂一條流程要開 5 個檔案,
而這 3 個分支從頭到尾只被一個方法用到
惡化:後來「本島與外島共用同一段折扣」,兩個策略類開始互相繼承
→ 改一個地區的規則會影響另一個地區,比原本的 if-else 更危險
修法:合回一個方法,用衛語句整平,共用的折扣抽成一個私有方法strategies.get(type) 之後就查不到了——靜態分析、重構工具、死碼偵測全部一起失效。@Service,編譯完全通過,上線才發現。這正是後端底層 ch06談的 IoC 取捨:容器幫你組裝的代價,是組裝錯誤被延後到啟動或呼叫時才暴露。strategies.get(未知的type) 回傳 null,接著就是 NPE。原本的 else 至少會丟一個講得清楚的例外。switch 對 enum 還能讓編譯器檢查「有沒有漏掉哪個分支」——這是策略模式做不到的。到處加 if (x != null) 之所以永遠加不完,是因為它治的是徵狀。根因是 null 這個值同時承載了三種完全不同的意思:
userRepository.findByEmail(email) 回傳 null,可能代表:
① 查得到這個人,但他沒有填 email (資料的正常狀態)
② 查無此人 (合法、但呼叫端要處理的結果)
③ 查詢本身出錯,例外被吞掉了 (bug)
呼叫端拿到 null 之後,無法區分這三件事,只好一律檢查;
於是檢查散落到每一個呼叫點 —— 漏掉任何一個就是 NPE。Collections.emptyList(),不要回 null(呼叫端可以直接 for,少一層檢查);公開方法的參數用 Objects.requireNonNull(orderId, "orderId 不可為 null") 快速失敗——讓它在「傳錯的那一刻」炸,而不是三層之後在某個不相干的地方炸。Optional<User> findByEmail(...) 是在型別上宣告「可能查不到」,呼叫端不處理就編不過。用 map/flatMap/orElse 串起來;.get() 只是把 NPE 換成 NoSuchElementException,不算處理。GuestUser(權限永遠是空集合)、NoOpNotifier(send 是空實作)。呼叫端的分支直接消失。@NonNull/@Nullable 加上 IDE 與靜態檢查,把「這裡可能是 null」從口耳相傳變成編譯期就看得到的資訊。徵狀:Map<String, Boolean> featureFlags;
if (featureFlags.get("newCheckout")) { ... } ← NPE
堆疊只指到這一行,但這一行「看起來沒有任何 null 存取」
診斷:key 不存在時 get() 回傳 null(型別是 Boolean 物件),
而 if 需要的是 boolean 基本型別
→ 編譯器偷偷插了一個 .booleanValue()
NPE 來自那個原始碼上看不見的方法呼叫(自動拆箱)
修法:featureFlags.getOrDefault("newCheckout", false)
更好的做法是別讓設定值走 Map<String, Boolean>,
改成有型別的設定物件(@ConfigurationProperties)
同類陷阱:flag ? 1 : someInteger
三元運算子兩邊型別不同時,編譯器會把兩邊都拆箱,
someInteger 為 null 就炸 —— 同樣看不見null 進來,你等於要檢查兩件事。要表達可選參數,用多載或 Builder。map 都會配置新物件。一秒幾萬次的路徑上這是實際成本(見後端底層 ch04的物件配置與 GC)。重構的定義是「不改變外部可觀察行為」。這句話立刻帶出一個問題:你要怎麼證明沒有改變?靠仔細看是不夠的——會出事的地方,通常正是你沒想到要看的地方。
面對一段沒有測試、也沒人說得清規格的老程式碼,正確的第一步不是讀懂它,而是把它現在的行為原封不動地錄下來:
① 挑幾組真實輸入(含邊界值與看起來很怪的 case)丟進去
② 把「實際跑出來的結果」直接寫成斷言 —— 即使那個結果看起來像 bug
③ 這些測試現在全綠。它們不驗證「應該是什麼」,只釘住「現在是什麼」
④ 開始重構。任何一條變紅 = 你動到行為了
⑤ 真的要修那個 bug,另外開一個 commit,並且明確地改掉那條斷言徵狀:refactor/order-module 開了三週,合不回去
診斷:期間 main 前進了 200 多個 commit,其中 30 幾個動到同一批檔案
每次 rebase 都要重新解一輪相同的衝突,
而且沒有任何測試能證明「這次解對了」
結果:分支放棄,三週的工作丟掉
修法:改用絞殺榕(Strangler Fig)——新舊實作並存,
用一個開關逐條路徑切過去,每切一條就合併一次,
任何一刻都可以退回舊實作git checkout 丟掉重來,而不需要 debug。把這一頁四個分頁串起來:GoF 的 23 種模式不是設計時拿來挑一個套用的目錄,而是重構重複走到最後,自然收斂出來的形狀。Fowler 的說法是「往模式的方向重構」(refactoring towards patterns)——先有痛,才有模式。
① 痛過才用 —— 說得出「上次改這裡花了多久、動了幾個檔案」再談模式
② 數得出第二個 —— 講不出第二種情況,就先別抽介面
③ 讀者成本 —— 新人追一條流程要開幾個檔案?
從 1 變成 5,換到的是什麼?| Code Smell | 你會觀察到什麼 | 根因 | 常用手法/模式 |
|---|---|---|---|
| 長方法 Long Method | 「要改這一行,得先讀完前面 60 行」 | 一個方法裡混了多個抽象層級 | Extract Method、衛語句 |
| 上帝類 God Class | 檔案 1200 行、建構子 20 個依賴 | 職責邊界從來沒被畫出來 | Extract Class(依 SRP 拆) |
| 發散式修改 | 同一個檔案的 commit 主題天差地遠 | 一個類別有多個變化的理由 | 拆開:Extract Class |
| 霰彈式修改 | 一個小需求要動 11 個檔案 | 同一個職責被撒得太散 | 聚攏:Move Method/Inline Class |
| 重複出現的 switch | 同一組 case 在 3 個地方各寫一次 | 分支跟著資料走,而不是跟著行為走 | 多型/策略模式/enum 帶行為 |
| 基本型別偏執 | 同一段校驗邏輯到處複製 | 型別不承載任何規則 | 值物件(Money、Email、Address) |
| 訊息鏈 | a.getB().getC().getD() | 對別人內部結構的一連串假設 | Hide Delegate、得墨忒耳定律 |
| 註解在解釋「這段在幹嘛」 | 註解比程式碼還長 | 命名與結構沒有表達出意圖 | Extract Method + 用註解內容當方法名 |
| 層次 | 什麼時候用 | 換到的好處 | 付出的代價 |
|---|---|---|---|
| L1 衛語句 | 任何巢狀超過兩層的方法 | 巢狀變平,主線邏輯浮出來 | 幾乎沒有——所以總是先做這個 |
| L2 表驅動 Map | 分支只是「輸入 → 一個值」 | 新增分支=加一筆資料,不動程式碼 | 分支帶行為時就不夠用;查不到要自己處理預設 |
| L3 策略模式 | 分支是「一整套演算法」且多處共用 | 新增分支不動既有程式碼(OCP) | Find Usages 失效、錯字延後到執行期、檔案數上升 |
| L4 狀態模式 | 分支決定「下一個狀態」且有轉移規則 | 非法轉移能在單一位置擋掉 | 類別數=狀態數;小型狀態機不划算 |
| 手法 | 擋在哪一層 | 擋掉什麼 | 注意事項 |
|---|---|---|---|
| 回空集合不回 null | 方法回傳邊界 | 呼叫端每個 for 迴圈前的檢查 | 「空集合」與「查詢失敗」仍要用別的方式區分 |
| Objects.requireNonNull | 方法入口 | 錯誤傳播——在傳錯的當下就炸 | 訊息要寫清楚是哪一個參數 |
| Optional 當回傳型別 | 型別系統 | 呼叫端「忘記處理查無資料」 | 不要當欄位或參數;.get() 不算處理 |
| Null Object 模式 | 物件設計 | 呼叫端到處都是的 if 分支 | 它會安靜地什麼都不做,要有辦法觀察得到 |
GoF 把 23 種模式歸成三大類。行為型佔了將近一半(11 種),這個比例本身就在說明:物件怎麼分工、怎麼互相通知,才是設計上最難的部分。
@Bean(Factory)·@Aspect(Proxy)·@EventListener(Observer)·JdbcTemplate(Template Method)·FilterChain(Chain of Responsibility)