🎯 什麼情境該想到我
當你「想讓呼叫端只依賴介面、不依賴具體實作」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:把介面與其實作放在不同套件/模組,降低相依。
做法要點:
- 在使用端所在(或獨立)的套件定義介面,實作放另一個套件。
- 呼叫端只 import 介面套件,執行期才接上實作(常搭配工廠、外掛或注入)。
- 可讓相依方向反過來:實作依賴介面,而非使用端依賴實作。
★★ 它解決的是「依賴方向」,而 Go 的隱式介面讓它幾乎免費
★ 問題:領域層需要「發送郵件」,但不該依賴 SMTP 套件
★ Java 的做法:介面放 domain package,實作放 infrastructure package
→ ★ infrastructure import domain,而 domain 不 import infrastructure ✅
→ ★★ 但實作必須明確宣告 implements domain.Mailer
★★ Go 的做法:★ 介面放在「使用它的那一側」,而且實作完全不必知道它存在
// ★★ 消費方宣告它需要什麼(未匯出、只放用到的方法)
package checkout
type mailer interface { // ★ 未匯出
Send(ctx context.Context, to string, tmpl string, data any) error
}
func New(m mailer) *Service { … }
// ★ 實作在別的 package,它不 import checkout,也沒有 "implements"
package smtpmail
type Mailer struct{ … }
func (m *Mailer) Send(ctx context.Context, to, tmpl string, data any) error { … }★★ 這是 Go 相對 Java 最實質的一個差異:
「分離介面」在 Java 是一個需要刻意設計的模式;
在 Go 它是預設做法,而且成本是零。
→ ★ 見 工具-針對介面編程:介面屬於消費者,不屬於實作者。
★★ 三條 Go 的紀律
① ★★ 介面定義在消費方,而且未匯出
→ ★ 未匯出讓「這個介面只是我的需求」這件事在語法上明確
→ ★★ 匯出它 = 你在宣告一個公開契約,而那通常不是你的意圖
② ★★ 介面只放「這個消費者用到的方法」
❌ type Store interface { Get; Put; List; Delete; Watch; Stats }
✅ type getter interface { Get(context.Context, string) ([]byte, error) }
→ ★ 十個方法的介面只有原本那個型別滿足得了 ⇒ 替換性是零
③ ★★ 「回傳具體型別、接受介面」(accept interfaces, return structs)
func NewMailer(cfg Config) *smtpmail.Mailer // ✅ 回傳具體型別
func NewMailer(cfg Config) Mailer // ★ 呼叫端拿不到完整能力
★★ 什麼時候真的需要「把介面放到另一個 package」
★ Go 的預設做法(介面在消費方)已經解決 90% 的情況。剩下的 10%:
① ★★ 兩個 package 互相需要 → 循環 import(Go 的硬性編譯錯誤)
package order → 需要 billing.Charger
package billing → 需要 order.Order
→ ★ 解法:★★ 把共用型別下移到第三個 package(domain),
或讓其中一方「介面定義在自己這側」(★ 通常這一招就夠)
② ★ 一個介面真的有多個獨立的實作,而且它是產品的一部分
★ 例如你發布一個函式庫,使用者要自己實作 Storage
→ ★★ 這時介面必須匯出,而且它是公開契約(★ 加方法就是破壞性變更)
③ ★ 需要在 plugin 邊界上約定(見 [[外掛]])
★★ ①是實務上唯一常遇到的,而它的解法通常不是「新增一個介面 package」,
而是重新想依賴方向(見 搬移函式 的循環 import 討論)。
★★ 一個常見的過度使用:為每個型別配一個介面
// ❌★★ 這不是分離介面,是介面污染
package user
type Service interface { … } // ★ 只有一個實作
type service struct{ … }
func NewService(…) Service { … } // ★★ 回傳介面★★ 三個具體的傷害:
① ★ 「跳到定義」跳到介面,要再找一次實作
② ★ 無法內聯、多一次動態分派(★ 通常無感,但確實存在)
③ ★★ 沒有任何好處 —— 只有一個實作,測試也沒在替換
→ ★ 見 冗員元素、工具-Go介面小而隱式。
★★ 判準:拿掉這個介面,有任何程式碼會壞嗎? 不會 → 拿掉。
★ 怎麼在 CI 裡守住依賴方向
# ★ golangci-lint 的 depguard
linters-settings:
depguard:
rules:
domain:
files: ["**/internal/domain/**"]
deny:
- pkg: "net/http"
- pkg: "database/sql"
- pkg: "github.com/**" # ★ 領域層不該依賴任何第三方★★ 這一段設定比任何文件或 code review 有效——
它讓「依賴方向錯了」變成 CI 紅燈。
★ 兄弟辨析
| 差別 | |
|---|---|
| 層超型 | ★ 共用基礎型別;分離介面是「依賴方向」 |
| 閘道 | ★ 封裝外部資源(常配分離介面) |
| 外掛 | ★ 執行期選擇實作 |
| 工具-好萊塢原則與依賴反轉 | ★ 同一個原則的一般形式 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
會多出間接層,簡單情況下未必划算。
-
需搭配某種機制在執行期取得實作(外掛、工廠、登錄器等)。
-
只有當跨套件相依真的造成問題時才用,別為分離而分離。
-
★★ 在 Go 裡「介面定義在消費方」是預設做法,成本是零。 不需要刻意設計一個「介面 package」。
-
★★ 介面要未匯出、只放用到的方法。 匯出它等於宣告一個公開契約(加方法就是破壞性變更)。
-
★★ 「接受介面、回傳具體型別」。 回傳介面讓呼叫端拿不到完整能力。
-
★★ 為每個型別配一個同名介面不是分離介面,是介面污染。 判準:拿掉它有任何程式碼會壞嗎?
-
★ 循環 import 的解法通常是「重新想依賴方向」,不是新增一個介面 package。
-
★ 用
golangci-lint的depguard把依賴方向放進 CI——比任何文件或 review 有效。 -
★ 真的需要匯出介面的情況只有「你發布函式庫,使用者要自己實作」——那時它是公開契約。
-
十個方法的介面只有原本那個型別滿足得了,替換性是零。
🔗 相關工具
- 外掛 Plugin(用設定在執行期連上實作)
- 登錄器 Registry(提供全域入口取得服務)
- 對應器 Mapper(雙方完全不知情的更強解耦)
- 企業應用架構模式
- 層超型 —— 共用基礎型別
- 閘道 —— 常配分離介面
- 外掛 —— 執行期選擇實作
- 冗員元素 —— 介面污染
- 工具-針對介面編程、工具-Go介面小而隱式 —— 介面屬於消費者
- 工具-好萊塢原則與依賴反轉 —— 一般形式
- 搬移函式 —— 循環 import 的解法