🎯 什麼情境該想到我

當你「看到一串連續的取值呼叫,像 a.getB().getC().getD() 一路點下去」的時候——呼叫端被迫知道整條物件導覽路徑的內部結構。

⚙️ 怎麼用(步驟 / 公式)

這味道是什麼徵兆:訊息鏈讓呼叫端緊緊耦合到物件之間的導覽結構——鏈中任何一環的關係改變,呼叫端就得跟著改。它洩漏了太多內部結構的細節。

通常用哪些重構手法對治:

  • 隱藏委託關係(Hide Delegate):在鏈的前端物件上開一個方法,替呼叫端把後面的委託關係藏起來,呼叫端只需認識第一個物件。
  • 提煉函式 + 搬移函式(Extract Function + Move Function):把「用這串鏈算出來的東西」抽成一個函式,再把它搬到鏈上更靠近資料的地方。

★ 定義與具體樣貌

// ★ 呼叫端必須知道整條路上每一個中間物件的存在與結構
manager := order.Customer().Department().Manager().Name()
//                ↑ 換了 Department 的結構,這行就壞了 —— 而它可能有五十個呼叫點

★★ 為什麼會痛:具體情境

// ① 結構改變的波及面
//    Department 從 Manager *Person 改成 Managers []*Person
//    → 所有 .Department().Manager() 的地方都要改,編譯器會告訴你,但有五十處
 
// ② ★ nil 在鏈的中間
o.Customer().Department().Manager()
//  ↑ Customer 可能是 nil     ↑ Department 可能是 nil
//    → 要嘛每一層都判 nil(超醜),要嘛等著 panic
 
// ③ ★ 測試要建整條鏈
//    測「顯示經理名字」這件事,得建 Order → Customer → Department → Person 四個物件

★★ 什麼時候這其實不是味道(最重要的一段)

Fowler 自己吐槽德摩特爾法則「該叫偶爾有用的德摩特爾建議」。以下都不是味道:

// ✅ ① 純資料結構的路徑存取 —— 那個形狀就是它的公開契約
cfg.Server.TLS.MinVersion
resp.Request.URL.Host
 
// ✅ ② 流暢介面(fluent / builder)—— 刻意設計成鏈式
strings.NewReplacer("-", "").Replace(s)
req.WithContext(ctx)
qb.Where("a = ?", 1).OrderBy("b").Limit(10)
 
// ✅ ③ 標準庫的正常用法
resp.Body.Close()
tx.QueryRowContext(ctx, q).Scan(&v)
 
// ✅ ④ 只有一、兩個呼叫點
//    包一個轉發方法的維護成本,高於直接寫這條鏈

★★ 判準:中間那幾層是「你在依賴的實作細節」,還是「本來就是公開的資料形狀」?
cfg.Server.TLS —— Server、TLS 就是 config 的形狀,藏起來要寫三十個轉發方法,純虧。
order.Customer().Department() —— Department 是 Customer 的內部組織方式,呼叫端不該知道。

★ 對治的三條路(不是只有「包起來」)

做法什麼時候代價
隱藏委託關係鏈的終點是「取一個值」,且呼叫點多★ 轉發方法太多會變成 中間人
★ 搬移函式你在鏈的終點「做事」,不只是取值常常是最好的解
什麼都不做一、兩個呼叫點;或中間層是公開資料形狀—
// ★ 第二條路的例子 —— 這才是最好的解
// before:呼叫端鑽進去做事
if order.Customer().Department().Manager().Level >= 3 { approve() }
 
// after:把「誰能核准」變成它該在的地方的責任
if order.CanBeApprovedAutomatically() { approve() }
//    ★ 鏈消失了,而且業務規則有了名字(可以被測試、可以被改)

★ 鏈的出現通常表示「呼叫端在做一件本該由別人做的事」——
那是 依戀情結,而 搬移函式 才是根治。包一層轉發只是把鏈藏起來。

★ Go 的三個特殊細節

// ① ★ Go 沒有 ?. —— 鏈上每一層的 nil 都要自己處理
//    但有一個漂亮的替代:nil 接收者上的方法(見 [[引入特例]])
func (d *Department) ManagerName() string {
    if d == nil { return "" }        // ★ 鏈上的 nil 由鏈自己吸收
    return d.manager.Name()
}
 
// ② ★ 方法鏈 vs 欄位鏈
o.Customer().Department()    // ★ 方法鏈:可以在任一層加 nil 保護與邏輯
o.Customer.Department        // ★ 欄位鏈:完全無法攔截 —— 這種更該處理
 
// ③ 錯誤怎麼在鏈裡傳?Go 沒有 Result 型別
//    → 這是 Go 不適合長鏈的根本原因;鏈上任何一步可能出錯時,就不要用鏈

★ 所以在 Go 裡優先處理「欄位鏈」而不是「方法鏈」:
欄位鏈沒有任何攔截點,改結構就是全面波及。

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 別為了消滅每一個點號而把大量委託方法轉進中間物件,那會走向「中間人」味道——要拿捏。

  • 流暢介面(fluent API)刻意的鏈式呼叫不是此味道。

  • ★★ 純資料結構的路徑存取不是味道(cfg.Server.TLS.MinVersion)。硬包會產生幾十個無意義的轉發方法。

  • ★ 流暢介面/builder 是刻意設計的,qb.Where().OrderBy().Limit() 不要「修」。

  • ★ 包轉發方法只是把鏈藏起來。如果你在鏈的終點「做事」而不只是取值,根治是 搬移函式。

  • 轉發方法超過三、四個就會變成 中間人——那時該直接暴露委託物件。

  • ★ Go 裡優先處理「欄位鏈」,它沒有攔截點;方法鏈至少還能在中間加保護。

  • Go 沒有 ?.,鏈上的 nil 要自己吸收。nil 接收者上的方法是最乾淨的做法。

  • 只有一兩個呼叫點就別動。包一層的成本高於收益。

🔗 相關工具