1. 導論
Go 中的介面,不只是「憑空出現的型別」,而是程式各個部分之間合約的一部分。這裡就開始有點哲學意味了(別擔心,沒有鬍子和修道院):誰來定義合約,誰就制定規則。如果介面「放錯地方」了,你的專案就會慢慢長出奇怪的依賴關係:某個套件只是為了型別而拖著另一個套件走,而替換實作則會變成一場任務。
把介面想成插座,而具體實作則像某個電器。如果插座是直接鎖在茶壺上的,那麼要接上筆電,你就得把茶壺拖著走。雖然能用,但看起來很怪,而且來訪的客人很快就會開始發問。
在 Go 中,最重要的實務規則是這樣說的:
介面通常應該宣告在它被需要的地方——也就是在使用者那一側。
要讓這句話不只是口號,而是真正可操作的方法,我們會用一個小型迷你應用程式來拆解。
2. 「介面在使用者那一側」這條規則
「在使用的地方宣告介面」的意思,不是「永遠放在 main」,而是「放在依賴抽象的套件/檔案裡」。也就是說,放在程式碼希望呼叫方法,但不想知道背後到底是哪個具體實作的地方(記憶體、檔案、資料庫,或任何其他東西)。
我們延續一個假想的教學應用程式 Tasker——一個最簡單的任務管理器。我們有領域模型 Task 與新增/列出邏輯。現在我們需要一個任務儲存層。從應用邏輯的角度來看,重要的是動作:「儲存任務」、「回傳任務清單」。它是怎麼做到的——那是細節。
這個「儲存層」的小合約(介面)最合理的做法,就是宣告在它被使用的地方:也就是管理任務的程式碼裡。
package tasker
type TaskStore interface {
Add(text string) error
List() []string
}
請注意:介面不必很「大」也不必很「正式」。相反地,它通常很小,而且非常務實:只包含使用者真正需要的方法。
現在,任務「服務」可以依賴 TaskStore,而不是依賴某個具體的 MemStore、FileStore 等等。
package tasker
type Service struct {
store TaskStore
}
func NewService(store TaskStore) Service {
return Service{store: store}
}
在這一刻,你就獲得了一種很舒服的自由:服務端不在乎 store 裡面是什麼,只要它會 Add 和 List 就行。
Tasker 迷你範例:把介面放在呼叫端附近
當這條規則看起來過於抽象時,最容易的方法就是把它落實到具體的檔案結構上。假設我們有三個套件:tasker(邏輯)、memstore(記憶體實作)和 main(進入點)。這不是「成熟架構」,只是整齊的分工,方便看清依賴方向。
閱讀這個架構圖的方式是:main「組裝」依賴,tasker 定義合約(介面),memstore 提供實作。同時,tasker 不需要匯入 memstore。
flowchart LR main --> tasker main --> memstore memstore -. "implements TaskStore" .-> tasker
tasker 套件宣告介面並使用它。memstore 套件只是實作所需的方法——就這樣而已。它不需要「知道」介面的存在。
下面是簡單的「記憶體」實作範例:
package memstore
type Store struct {
items []string
}
func (s *Store) Add(text string) error {
s.items = append(s.items, text)
return nil
}
再來是第二個方法:
package memstore
func (s *Store) List() []string {
return s.items
}
現在由 main 把所有東西組合起來。請注意:main 依賴的是具體實作(memstore.Store),但 tasker.Service 只依賴介面(tasker.TaskStore)。
package main
import (
"fmt"
"example/memstore"
"example/tasker"
)
func main() {
st := &memstore.Store{}
svc := tasker.NewService(st)
_ = svc // 先只是把依賴組起來
fmt.Println("tasker 已啟動") // tasker 已啟動
}
關鍵點是:介面是宣告在不是 memstore,而是 tasker,因為真正定義需求的是 tasker:「我需要能夠 Add 和 List。」
如果介面宣告在實作那一側:會壞掉什麼
現在來看另一種新手很常「憑直覺」做的方式:既然我們有 memstore 套件,那就直接在那裡宣告 Store 介面(或 Repository、Storage,或者「某個很嚴肅的名字」)。
問題在於,這樣介面就變成了「來自實作的禮物」。而這幾乎總是顛倒的邏輯:使用者為了描述自己需要什麼,反而開始依賴提供者。
假設 memstore 宣告:
package memstore
type Storage interface {
Add(text string) error
List() []string
}
現在 tasker(邏輯)想要接收 memstore.Storage。結果會怎樣?
首先,tasker 被迫匯入 memstore,即使從語意上來說,tasker 根本不應該知道儲存層是「mem」還是其他東西。今天是 memstore,明天是 filestore,後天是 sqlstore,然後你的商業邏輯就突然開始拖著一堆「技術性」套件走。
其次,介面會活在一個你也許會想刪掉或替換的套件裡。而介面本身就是 API 的一部分。如果你把「應用程式的 API」藏在實作套件內,那它就會變得很難管理:一旦換了實作,連合約放在哪裡也一起變了。
再次,介面幾乎難免會「朝著實作膨脹」。實作方總會有個誘惑:既然都在這裡了,不如再加一個 DebugDump() 方法……也許以後會用到。結果介面就不再是最小化的了。
健康的直覺應該是:介面屬於使用者,不是供應者。供應者只是滿足需求而已。
3. 匯出的介面,是一個昂貴的承諾
介面,尤其是匯出的介面(大寫開頭),是一份公開合約。如果你已經把它公開了,那麼新增一個方法就會讓所有實作這個介面的人都壞掉。這有點像宣布:「從明天開始,進大樓不只要鑰匙,還要貓掌印。」沒有貓的人,通常就會開始沮喪。
在 Go 裡,API 相容性非常重要,而任何公開的 API 都被視為承諾。所以對於套件中的介面,實用的規則是:如果介面只需要在套件內部使用,那就把它做成不匯出的。
例如,在 tasker 套件內可以這樣寫:
package tasker
type store interface {
Add(text string) error
List() []string
}
而對外你可以匯出具體的函式/型別,讓它們接收所需的依賴,而不要把介面公開成「公開憲法」的一部分。
如果介面真的屬於公開 API 的一部分(例如你在寫函式庫),那它就必須小、穩定,並且從使用者的角度來定義。
4. 介面是抽象的邊界
還有一個很細,但非常實用的想法:介面不只是方法的集合,它也是一條邊界,讓細節從這條邊界「滲出」來。如果你在介面中包含了來自實作套件的型別,你其實等於把所有使用者綁死在那些細節上。
介面和錯誤的道理是一樣的:如果內部實作的細節開始向外滲漏,呼叫端就可能開始依賴這些細節,而內部一旦改動就會變得很痛苦。
不好的設計大概長這樣(這個例子故意「歪」一點,方便看出問題):
package tasker
import "example/memstore"
type TaskStore interface {
SaveRow(row memstore.Row) error
}
現在 tasker 依賴 memstore.Row。也就是說,「管理任務」的邏輯已經知道「記憶體儲存」的內部結構了。這就是抽象洩漏。
較好的思考方向是:介面應該使用領域語言(任務、字串、ID),而不是實作語言(「Row」、「SQLTx」、「BoltBucket」、「MagicCursor3000」)。
5. 為什麼這能讓替換實作更容易
當介面是由使用者那一側宣告,而且保持最小化時,你就能在不重寫商業邏輯的情況下輕鬆更換實作。即使你現在還沒寫測試,也還沒搭建複雜架構,這依然很有用:先把任務存在記憶體裡,之後再換成檔案,但應用的「核心」不需要動。
要直觀地看出這個概念,只要做第二個實作,例如一個「空實作」,永遠回傳空清單。這不是測試,不是 mock 框架,也不是魔法——只是另一個具有相同方法的型別。
package tasker
type EmptyStore struct{}
func (EmptyStore) Add(text string) error { return nil }
func (EmptyStore) List() []string { return nil }
現在,任何使用 TaskStore 的程式碼都可以接收 EmptyStore{},而不需要知道這是一個「特殊」實作。這就是合約的力量:型別不一定要叫 Store,才能成為儲存層,它只需要實作所需的方法即可。
同時也要記住:如果你把方法定義在 *T 上,那麼實作這個介面的是 *T,而不是 T。這不是「不好」,而只是合約的一部分;在組裝依賴時,必須傳入正確形式的值。
6. 如何在專案中選擇介面位置
直覺上你可能會問:「好吧,那如果我所有東西都在同一個套件裡,要怎麼判斷哪裡算是“使用者那一側”?」這是很正常的問題,尤其是在教學或小型專案裡。
可行的做法是:把介面宣告在盡可能接近「作為依賴而使用它」的程式碼旁邊。通常這表示「放在接受這個依賴的函式或結構附近」。
例如,如果你有一個負責列印任務的函式,而它不在乎任務從哪裡來,那麼介面可以直接在旁邊宣告:
package tasker
type Lister interface {
List() []string
}
func PrintAll(x Lister) []string {
return x.List()
}
這看起來幾乎像一個「局部合約」:函式已經說明它需要什麼,就這樣。
如果介面在套件內的多個地方都會用到,就把它往上提到檔案層級,或抽到像 interfaces.go 這樣的獨立檔案。如果介面只在套件內部需要,那就不要匯出它。如果介面是套件外使用者需要的,那就把它匯出,但要準備好承擔這個承諾(而且不要因為「以防萬一」就把介面越做越大)。
用一個小表格當速查表會很方便:
| 情境 | 通常怎麼做 |
|---|---|
| 介面只供一個函式/結構使用 | 放在附近宣告(同一個檔案,靠近使用位置) |
| 介面供套件內多個實體使用 | 提升到套件層級,但保持不匯出 |
| 介面是套件公開 API 的一部分 | 匯出它,並保持小而穩定 |
| 介面是「關於實作」的(SQL/Redis/Mem) | 大概率是味道不對:介面應該描述行為,而不是技術 |
7. 選擇介面位置時的典型錯誤
錯誤 1:介面宣告在實作套件中,而業務邏輯為了介面而匯入實作。
這很常是無意間發生的:「我都已經在 memstore 裡了,那就直接在這裡寫 type Store interface 吧。」結果就是,邏輯套件開始依賴「記憶體」或「SQL」套件,接著你就會奇怪,為什麼替換實作這麼困難。若介面描述的是邏輯需求,它就應該放在邏輯套件裡。
錯誤 2:匯出的介面長得太大,現在誰都不敢動。
一開始你加了 Save,接著是 Load,然後「再加個 Clear() 也許用得到」,之後又變成「要不要 Stats()」。然後你才發現,三個實作裡有兩個無法誠實地完成這一切,只能靠奇怪的補丁。匯出的介面是一種承諾,而承諾的代價很高:修改這種合約很痛,因為你會破壞那些已經依賴它的人。
錯誤 3:介面裡露出了具體實作的型別。
如果介面的方法接受或回傳的是實作套件中的型別,你就把抽象的概念弄壞了。這就像內部細節開始向外滲漏,而呼叫端開始依賴那些細節。介面最好使用領域型別或標準函式庫的簡單型別。
錯誤 4:建立介面只是因為「介面很重要」,而不是因為真的有使用者。
有時候人們會提早宣告一個介面,先為未來鋪路,甚至在真正受益的程式碼還沒出現之前就先做了。最後得到的是一個沒有意義的抽象:沒有第二個實作、簽章更複雜、檔案更多,但完全沒有好處。Go 中的介面是工具,不是宗教:只有當它能降低程式碼對細節的依賴時,它才值得存在。
錯誤 5:介面名稱反映的是技術,而不是行為。
把 SQLStore、FileRepo、RedisManager 當成介面名稱,通常是個壞訊號。介面最好用行為來命名(例如 Store、Saver、Lister),而具體型別則用技術來命名(SQLStore、FileStore)。這樣閱讀程式碼時,大腦不會把「合約」和「實作」混在一起,整個專案也會明顯更安定。
GO TO FULL VERSION