🎯 什麼情境該想到我
當你「想靠設定、在執行期換上不同實作,而不改動程式碼」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:用設定在執行期連上要用的實作類別,讓行為可插拔替換。
做法要點:
- 針對某介面,在設定(檔案/環境變數)中指定要載入的實作類別。
- 執行期由工廠依設定實例化對應實作並接上(常搭配分離介面)。
- 換環境(正式/測試/不同資料庫)只要改設定,不必重編譯或改碼。
★★ Go 有三種「外掛」,能力與代價差非常多
| 做法 | 何時決定 | ★ 代價 |
|---|---|---|
① ★ 編譯期註冊(import _ + init()) | ★ 編譯時 | ★★ 加外掛要重新編譯;但最簡單也最安全 |
| ② ★ 設定驅動的分派表 | 啟動時讀設定 | ★ 可選的實作必須全部編進去 |
③ ★★ plugin 套件(.so 動態載入) | 執行期 | ★★ 限制極多,見下 |
| ④ ★★ 行程外(gRPC/stdio 子行程) | 執行期 | ★ 多一次序列化與行程;★★ 但這是真正可行的動態外掛 |
★★ ① 編譯期註冊:Go 標準庫自己的做法
// ★ database/sql 的 driver 機制
import _ "github.com/jackc/pgx/v5/stdlib" // ★★ 只為了跑它的 init()
db, err := sql.Open("pgx", dsn) // ★ 用字串查註冊表
// ★ 實作方
func init() { sql.Register("pgx", &Driver{}) }★★ 為什麼這個形態是正當的(對比 登錄器 的反模式):
① ★ 註冊在 init() 一次完成,之後唯讀
② ★★ 依賴在 import 清單裡「看得見」—— import _ 是一個明確的宣告
③ ★ 目的就是「讓外部套件能加入」,這是真正的擴充點
⚠️ 但它的代價很實際:sql.Open("pgxx", …) 打錯字 → 執行期才失敗。
★ 對策:把 driver 名稱包成具名常數,或在啟動時就 db.PingContext。
★★ ③ plugin 套件的限制(為什麼幾乎沒人用)
★★ Go 的 plugin 套件限制清單:
① ★ 只支援 Linux、FreeBSD、macOS(★ Windows 不支援)
② ★★ 主程式與外掛「必須用完全相同的 Go 版本編譯」
③ ★★ 所有共同的依賴套件版本也必須完全一致
④ ★ 一旦載入就無法卸載
⑤ ★ 無法 cross-compile;★★ CGO 相關的組合更容易出問題
→ ★★ 實務結論:★ 除了極少數場景(同一個團隊、同一條 build pipeline),
★★ Go 的 plugin 套件不是一個可行的外掛機制
★ 這也是為什麼 Go 生態的外掛幾乎都走 ④(行程外)。
★★ ④ 行程外外掛:Go 生態的實際答案
★ 三個成熟的實例:
① ★★ HashiCorp 的 go-plugin(Terraform provider、Vault plugin)
→ gRPC over 本機 socket,★ 主程式與外掛可以用不同 Go 版本、甚至不同語言
② ★★ MCP(Model Context Protocol)—— stdio 或 HTTP 上的 JSON-RPC
→ ★ 語言無關,這是今天最活躍的外掛協定
③ ★ WASM(wazero 之類)—— ★ 沙箱化、可攜、可限制資源
→ ★★ 代價:WASI 的 I/O 能力有限,宿主要明確開放每個能力
★★ 行程外外掛的四個必須處理的問題:
① ★★ 協定版本協商 —— ★ 外掛可能比主程式新或舊(見 [[格式指示器]])
② ★★ 外掛崩潰不可以拖垮主程式 —— ★ 子行程隔離正是它的最大優勢
③ ★ 超時與資源限制 —— ★★ 一個壞外掛不該讓主程式卡住
④ ★★ 信任邊界 —— ★ 外掛執行任意程式碼 ⇒ 它的權限等於主程式的權限
→ ★ 要嘛只載入可信來源,要嘛用 WASM 沙箱
⚠️ stdio 協定的一個具體陷阱:外掛的 stdout 只能放協定訊息。
★ 任何 fmt.Println 的除錯輸出都會污染協定 → 日誌一律走 stderr。
★★ 什麼時候你其實不需要外掛
① ★★ 「可選的實作」只有兩三個,而且都是你自己維護
→ ★ 一個 switch 或分派表就好(見 [[以多型取代條件式]])
→ ★★ 這是最常見的過度設計:★ 為了「未來的擴充性」建了一個外掛機制,
而三年後仍然只有兩個實作(見 [[誇誇其談通用性]])
② ★ 擴充點其實是「設定」
★ 「使用者要能自訂驗證規則」→ ★★ 一個 DSL 或規則設定,比一個外掛系統簡單十倍
③ ★★ 你不需要第三方擴充
→ ★ 外掛機制的全部價值是「別人能加入而不改你的碼」
→ ★★ 只有你會加 → 直接加,不要建機制
★★ 判準:「加一個實作的人」是不是你?
是 → 不要外掛機制,用介面 + 分派表。
不是(第三方、其他團隊、使用者)→ 才需要。
★ 兄弟辨析
| 差別 | |
|---|---|
| 登錄器 | ★ 註冊表是外掛的機制(★ 這是登錄器唯一正當的用途) |
| 分離介面 | ★ 外掛的介面必須匯出且穩定(★ 它是公開契約) |
| 服務樁 | 測試替身 |
| 抽象工廠模式 | ★ 依設定產生一整組相關物件 |
| 工廠方法模式 | 「字串 → 實作」的對應 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
增加一層間接與設定管理成本;只有一種實作時不需要。
-
設定錯誤要到執行期才爆,需有清楚的錯誤訊息與預設。
-
常與分離介面、登錄器搭配才完整。
-
★★ Go 的
plugin套件實務上不可行:不支援 Windows、主程式與外掛必須用完全相同的 Go 版本與依賴版本、無法卸載、無法 cross-compile。 -
★★ Go 生態的外掛實際上都走「行程外」(go-plugin 的 gRPC、MCP 的 JSON-RPC、WASM)。
-
★★ stdio 協定的外掛,stdout 只能放協定訊息。 任何
fmt.Println除錯輸出都會污染協定——日誌一律走 stderr。 -
★★ 外掛執行任意程式碼 ⇒ 它的權限等於主程式的權限。 要嘛只載入可信來源,要嘛用 WASM 沙箱。
-
★★ 判準:「加一個實作的人」是不是你? 是 → 不要外掛機制,用介面 + 分派表。
-
★ 「可選的實作只有兩三個且都是你維護」是最常見的過度設計(見 誇誇其談通用性)。
-
★ 外掛的介面是公開契約,加方法就是破壞性變更。要有版本協商。
-
★
sql.Open("pgxx", …)打錯字執行期才失敗。 把名稱包成具名常數,啟動時就 Ping。 -
外掛崩潰不可以拖垮主程式,而子行程隔離正是行程外外掛的最大優勢。
🔗 相關工具
- 分離介面 Separated Interface(外掛連接的正是分離出來的介面)
- 服務樁 Service Stub(測試時常以外掛換上樁)
- 登錄器 Registry(放置並取得外掛實例)
- 企業應用架構模式
- 登錄器 —— ★ 註冊表的正當用途
- 分離介面 —— 外掛介面是公開契約
- 服務樁 —— 測試替身
- 抽象工廠模式、工廠方法模式 —— 依設定產生實作
- 格式指示器 —— 協定版本協商
- 誇誇其談通用性 —— 過度設計的訊號