🎯 什麼情境該想到我

當你「想靠設定、在執行期換上不同實作,而不改動程式碼」的時候。

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

意圖:用設定在執行期連上要用的實作類別,讓行為可插拔替換。

做法要點:

  • 針對某介面,在設定(檔案/環境變數)中指定要載入的實作類別。
  • 執行期由工廠依設定實例化對應實作並接上(常搭配分離介面)。
  • 換環境(正式/測試/不同資料庫)只要改設定,不必重編譯或改碼。

★★ 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。

  • 外掛崩潰不可以拖垮主程式,而子行程隔離正是行程外外掛的最大優勢。

🔗 相關工具