🎯 什麼情境該想到我
當你「有一堆細粒度物件,跨網路呼叫次數太多導致很慢,想用一個粗粒度介面包起來」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:為一組細粒度物件提供一個粗粒度的遠端介面,減少跨程序/跨網路的呼叫次數。
做法要點:
- 遠端呼叫成本高(序列化、網路延遲),細粒度介面在遠端會很慢。
- 建立一個外觀物件,把多個細瑣操作聚合成少數幾個粗粒度方法。
- 外觀本身不含領域邏輯,只做聚合與轉發;領域邏輯留在原本的細粒度物件。
- 一次遠端呼叫就完成一組相關操作,往返大幅減少。
★★ 它的理由只有一個:遠端呼叫很貴,所以介面要粗
★ 細粒度的遠端介面(把本地物件的 API 直接暴露出去):
client.GetOrder(id) → 1 次往返
order.GetCustomer() → 1 次往返
order.GetLines() → 1 次往返
line.GetProduct() → ★★ N 次往返
→ ★ 一個「顯示訂單頁」變成 20 次往返 × 每次 50 ms = ★★ 1 秒
★★ 遠端外觀:一次呼叫回傳整頁需要的東西
client.GetOrderDetail(id) → 1 次往返
★★ 這正是 GraphQL 與 BFF(Backend for Frontend)存在的理由——
它們是遠端外觀在 2026 年的樣貌。
★★ 今天的三種遠端外觀
| 做法 | 說明 | ★ 代價 |
|---|---|---|
| ① ★ BFF(每個客戶端一個外觀) | Web 一個、iOS 一個 —— ★ 各自回傳剛好需要的欄位 | ★ N 個 BFF 要維護 |
| ② ★★ GraphQL | ★ 客戶端自己決定要什麼欄位 | ★★ N+1 問題(要 DataLoader)、查詢複雜度攻擊、快取困難 |
③ ★ 複合端點(GET /orders/{id}/detail) | 一個為特定畫面設計的端點 | ★ 端點數量成長;★ 但最簡單、最好快取 |
★★ ③ 最被低估:它不需要新技術棧,Cache-Control 與 CDN 直接可用,
而且**「這個端點服務哪個畫面」是明確的**——★ 一個畫面改了,只改那個端點。
★ 判準:客戶端種類多且需求差異大 → ①②;★ 只有一兩種客戶端 → ③。
★★ 三條紀律(違反任一條就失去它的價值)
① ★★ 遠端外觀不可以有業務邏輯
★ 它只做「呼叫多個服務/查詢 → 組裝成回應」
→ ★ 業務規則屬於 [[服務層]];★★ 放進外觀 = 每個客戶端的外觀各有一份規則
② ★★ 回傳 [[資料傳輸物件]],不是領域物件
→ ★ 領域物件序列化出去 = 內部模型變成公開契約
③ ★★ 粗粒度不等於「一個萬用端點」
❌ POST /api { "action": "...", "params": {...} }
→ ★ 失去 HTTP 的一切(快取、狀態碼、冪等語義、可觀測性)
→ ★★ 粗粒度指的是「一次拿齊一個畫面需要的資料」,不是「一個端點做所有事」
★★ Go 的實作:並行取資料是重點
func (f *OrderFacade) Detail(ctx context.Context, id OrderID) (OrderDetail, error) {
// ★★ 三個獨立的查詢 → 並行,不要循序
g, ctx := errgroup.WithContext(ctx) // ★ 任一個失敗就取消其他
var (
order *Order
customer *Customer
shipping *Shipment
)
g.Go(func() (err error) { order, err = f.orders.Get(ctx, id); return })
g.Go(func() (err error) { customer, err = f.customers.Get(ctx, custID); return })
g.Go(func() (err error) {
shipping, err = f.shipments.Get(ctx, id)
if errors.Is(err, ErrNotFound) { return nil } // ★★ 可選的部分:找不到不算失敗
return err
})
if err := g.Wait(); err != nil { return OrderDetail{}, err }
return assemble(order, customer, shipping), nil // ★ 純函式組裝,好測
}★★ 兩個關鍵設計:
① errgroup.WithContext → 一個失敗就取消其他,不浪費資源。
② ★★ 區分「必要」與「可選」的下游:
shipping 找不到只是「還沒出貨」,不該讓整個頁面失敗。
★ 這是遠端外觀最重要的一個設計決定:
哪些下游是必要的、哪些失敗時可以降級回傳部分資料(見 工具-服務容錯設計)。
★★ 它的固有風險:可用性相乘
★ 一個外觀依賴 5 個下游,每個 99.9%
→ ★★ 全部必要:0.999⁵ = 99.5% (一年多約 40 小時不可用)
→ ★ 其中 3 個可選:實際可用性接近 99.8%
→ ★★ 所以「哪些可選」直接決定了這個端點的 SLO
★ 配套必做的三件事:每個下游各自的超時、斷路器、以及部分失敗的回應格式
(回 200 + "shipping": null 還是回 206?★ 要明確定義並寫進 API 文件)。
★ 什麼時候不需要遠端外觀
- ★★ 只有一個客戶端,而且它就是你自己的前端 → ★ 直接讓那個 handler 組資料,
不需要一個叫「Facade」的額外層(見 服務層)。 - ★ 客戶端在同一個行程內(模組化單體)→ 遠端呼叫的成本不存在。
- ★ 資料本來就在一個服務裡 → 一個 SQL JOIN 勝過任何外觀。
★ 兄弟辨析
| 差別 | |
|---|---|
| 服務層 | ★ 本地的操作邊界(交易、授權);遠端外觀是為遠端呼叫減少往返 |
| 資料傳輸物件 | ★ 遠端外觀回傳的型別 |
| 外觀模式 | ★ 行程內的簡化入口(沒有「減少往返」的動機) |
| 訊息閘道 | 往外發訊息的封裝 |
| 散發-聚集 | ★ 訊息世界的「問多方再彙整」 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
若呼叫在同一程序內(無遠端邊界),不需要這層。
-
別把領域邏輯塞進外觀,否則會變成貧血又難維護。
-
通常與資料傳輸物件搭配,一次把資料整包傳回。
-
★★ 遠端外觀不可以有業務邏輯。 放進去 = 每個客戶端的外觀各有一份規則。
-
★★ 「粗粒度」不等於「一個萬用端點」。
POST /api {action, params}失去 HTTP 的快取、狀態碼、冪等語義與可觀測性。 -
★★ 必須區分「必要」與「可選」的下游。
shipping找不到只是還沒出貨,不該讓整頁失敗。這是這個模式最重要的設計決定。 -
★★ 可用性相乘:5 個全必要的 99.9% 下游 = 99.5%(一年約 40 小時不可用)。「哪些可選」直接決定 SLO。
-
★ 部分失敗的回應格式要明確定義並寫進 API 文件(200 + null 還是 206)。
-
★ 用
errgroup.WithContext並行取資料,一個失敗就取消其他。 -
★ 回傳 DTO 不是領域物件,否則內部模型變成公開契約。
-
★ 只有一個客戶端就不需要一個叫「Facade」的額外層,直接讓 handler 組資料。
-
GraphQL 的代價是 N+1(需要 DataLoader)、查詢複雜度攻擊、快取困難。 「複合端點」常常更划算。