🎯 什麼情境該想到我
當你「不想讓應用程式碼直接依賴訊息收發 API,希望把訊息操作包成普通的方法呼叫」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:用一個類別封裝訊息系統的存取,對外只提供領域相關的方法,讓應用程式不需要直接接觸訊息 API。
做法 / 結構要點:
- 建立 Gateway 類別,內部處理連線、通道、送收訊息、組 / 解訊息等細節。
- 對外暴露如
submitOrder(order)這類業務方法。 - 應用只呼叫這些方法,完全不感知底層是訊息系統。
★ Go 的實作:介面定義在「使用它的那一側」
// ★ 在業務 package 裡宣告你需要的最小介面(不是在 broker 的 package)
package order
type EventPublisher interface {
PublishOrderPlaced(ctx context.Context, e OrderPlaced) error
}
func (s *Service) Place(ctx context.Context, req Request) error {
// …業務邏輯…
return s.pub.PublishOrderPlaced(ctx, OrderPlaced{OrderID: o.ID, …})
}// ★ 閘道的實作在另一個 package,它才認識 kafka
package kafkagw
type Gateway struct{ w *kafka.Writer }
func (g *Gateway) PublishOrderPlaced(ctx context.Context, e order.OrderPlaced) error {
b, err := json.Marshal(envelope(e)) // ★ 信封在這裡包([[信封包裝器]])
if err != nil { return fmt.Errorf("marshal: %w", err) }
return g.w.WriteMessages(ctx, kafka.Message{Key: []byte(e.OrderID), Value: b})
}★★ 這是 Go 相對其他語言最便宜的一次抽象:隱式滿足讓
kafkagw.Gateway
自動滿足order.EventPublisher,而kafkagw完全不必 importorder的介面宣告
(見 工具-針對介面編程、工具-Go介面小而隱式)。
★★ 收益一:測試不需要 broker
type fakePublisher struct{ sent []order.OrderPlaced }
func (f *fakePublisher) PublishOrderPlaced(_ context.Context, e order.OrderPlaced) error {
f.sent = append(f.sent, e); return nil
}
func TestPlace(t *testing.T) {
pub := &fakePublisher{}
svc := order.New(repo, pub)
// …斷言 pub.sent 的內容
}★ 十行的 fake 取代一個 testcontainer。
⚠️ 但要配至少一個「真的連 broker」的整合測試——
fake 不會告訴你「訊息大小超限」「序列化失敗」「topic 不存在」。
★★ 收益二:橫切關注點有一個統一的地方放
★ 閘道是唯一該放這些東西的地方:
① 信封的組裝(message-id、correlation-id、trace)
② 序列化與 schema 版本
③ 重試與退避(發送端)
④ metrics(發送量、失敗率、延遲)
⑤ ★ Outbox 的寫入(見 [[工具-Transactional-Outbox-Pattern]])
★ 沒有閘道的後果很具體:這五件事會散落在每一個發送點,
而且一定有幾個地方漏掉其中一項(最常漏的是 correlation-id)。
★★ 閘道的介面該多細:這是唯一需要判斷的地方
// ① ★ 一個方法對一種訊息 —— 型別安全,但方法會很多
PublishOrderPlaced(ctx, OrderPlaced) error
PublishOrderCanceled(ctx, OrderCanceled) error
// ② 泛型化 —— 方法少,但呼叫端要自己決定 topic
Publish[T any](ctx context.Context, topic string, msg T) error
// ③ ★★ 折衷:泛型 + 型別化的 Topic(見 [[資料型別通道]])
Publish(ctx, OrderPlacedTopic, OrderPlaced{…}) // ★ 傳錯型別編譯不過★ 判準:這個 package 發幾種訊息?
少(2–5 種)→ ①,明確好讀。
多 → ③。
⚠️ 不要選 ②:呼叫端拼字串 topic 是拼錯就上線的那種 bug。
★ 消費端也需要閘道
// ★ 業務邏輯只認識「處理一則領域事件」,不認識 broker 的 Message
type OrderPlacedHandler interface {
HandleOrderPlaced(ctx context.Context, e OrderPlaced) error
}
// ★ 閘道負責:解信封、解序列化、錯誤分類、ack/nack、冪等去重★★ 消費端閘道更有價值,因為它承載了整個消費迴圈的正確性
(ack 時機、錯誤分類、每則訊息獨立超時——見 訊息端點)。
這些東西寫錯一次就會在生產環境丟訊息,所以只該寫一次。
★ 兄弟辨析
| 差別 | |
|---|---|
| 訊息對應器 | ★ 只管「領域物件 ↔ 訊息」的對應;閘道還管 broker API |
| 通道配接器 | ★ 在應用程式外面;閘道在裡面 |
| 服務啟動器 | 讓同一個服務同時能被訊息與同步呼叫觸發 |
| 閘道 | ★ PoEAA 的一般化版本(封裝任何外部資源) |
| 外觀模式 | 行程內的同一想法 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
閘道方法可設計成同步(阻塞等回覆)或非同步(帶回呼),要選對。
-
封裝良好時,抽換訊息中介或改走非訊息呼叫都較容易。
-
別在閘道裡摻入業務邏輯,它只負責轉接。
-
★★ 介面要定義在使用它的 package(業務側),不是 broker 的 package。Go 的隱式滿足讓這件事免費。
-
★★ 消費端閘道比發送端更有價值:ack 時機、錯誤分類、每則訊息獨立超時——寫錯一次就會丟訊息,所以只該寫一次。
-
★ fake 不能取代整合測試。它不會告訴你訊息超限、序列化失敗、topic 不存在。
-
★ 不要讓呼叫端拼字串 topic。用型別化的 Topic,拼錯就編譯不過。
-
★ 閘道是橫切關注點唯一該放的地方(信封、序列化、重試、metrics、Outbox)。散在每個發送點一定會漏掉 correlation-id。
-
閘道不要塞業務邏輯。「金額大於一萬要多發一則」屬於業務層,不屬於閘道。
-
閘道的介面就是契約。方法簽章一改,所有使用它的地方都要改——所以介面要小。
🔗 相關工具
- 訊息對應器 Messaging Mapper(同家族,負責領域物件與訊息互轉)
- 服務啟動器 Service Activator(同家族,接入端的對稱角色)
- 企業整合模式
- 訊息對應器 —— 領域物件 ↔ 訊息的對應
- 通道配接器 —— 在應用程式外面
- 服務啟動器 —— 同時支援訊息與同步呼叫
- 訊息端點 —— 消費迴圈的正確性
- 信封包裝器 —— 閘道負責組裝信封
- 閘道、外觀模式 —— 一般化的封裝想法
- 工具-針對介面編程、工具-Go介面小而隱式 —— 介面定義在消費方