Backend for Frontend 模式:打造你的 UI 真正想要的 API

Backend for Frontend 模式:打造你的 UI 真正想要的 API

每個前端開發者都遇過這種事:API 回傳的內容技術上正確,但對你正在做的東西一點都不對。首頁需要使用者的姓名、頭像,還有最近三筆訂單。你分四次呼叫 API,在客戶端把資料拼湊起來,然後祈禱網路夠快,讓使用者不會察覺到那串載入過程。一個端點回傳 47 個欄位,而你只需要 6 個,偏偏你需要的那個欄位,名字叫做 usr_acct_disp_nm 這種東西。

這是架構問題。後端是為了通用用途,或完全是另一個客戶端而設計的。Backend for Frontend(BFF) 模式就是為了解決這個問題而存在。

什麼是 BFF?

這個詞誕生於 SoundCloud,並由 Sam Newman 在微服務的研究中加以整理。核心概念很簡單。你為每個客戶端打造一個專屬的伺服器端層。一個想要服務所有客戶端表面(網頁、行動裝置、電視、第三方整合)的通用 API,結果是哪一個都服務不好。

BFF 是一個負責以下工作的伺服器:

「每個客戶端表面一個 BFF」這個原則沒有妥協空間。行動 App 和網頁 App 的資料需求、效能預算、互動模式都不一樣。兩者共用一個 BFF,等於又回到打造一個誰都不為其最佳化的通用 API。

BFF 對上一般後端

一般後端關心的是 商業邏輯與資料完整性。它套用領域規則、擁有資料庫,並以 領域 能理解的語言公開資源。它不在乎你的首頁需要的 User 形態,跟你的個人檔案頁面稍微不同。

BFF 關心的是 呈現邏輯。它把下游後端提供的東西,轉換成特定前端需要的東西。實務上代表:

這個區別很重要。後端工程師用實體和不變量的角度思考。前端工程師的框架是元件,以及一個畫面需要的資料。BFF 能流利地講第二種語言。

架構:沒有 BFF vs. 有 BFF

Architecture diagram: without BFF vs. with BFF

來看看實際上的差異。沒有 BFF 時,一個儀表板頁面可能會這樣做:

// 前端透過三個獨立呼叫組裝頁面
const [user, orders, notifications] = await Promise.all([
  fetch("/api/users/me"),
  fetch("/api/orders?userId=me&limit=3"),
  fetch("/api/notifications?userId=me&unread=true"),
]);
const unreadCount = notifications.filter((n) => !n.read).length;
const displayName = `${user.firstName} ${user.lastName}`;

有 BFF 時,前端一次呼叫就拿到確切需要的東西:

// BFF 提供專為這個頁面設計的端點
const { user, recentOrders, unreadCount } = await fetch("/bff/dashboard");

彙整與轉換的邏輯都在 BFF 裡。它不會散落在前端各處,也不會在多個畫面重複。

BFF 在微服務與單體(Monolith)情境下

微服務:經典的使用情境

BFF 模式誕生於微服務的世界,這其來有自。當你的後端拆成十幾個服務(使用者、訂單、庫存、通知、帳單),前端就得認識所有這些服務。光是一個小小的 UI 變更,可能就得協調三個後端團隊,才能把回應的形態稍微改一下。

BFF 透過充當前端唯一的契約來解決這件事。BFF 跟它需要的下游服務溝通。如果使用者服務改了 API,BFF 會吸收這個變更。前端完全不用在乎。

這是套用到前端/後端邊界的 防腐層(anti-corruption layer) 模式。它把前端與後端演進的頻繁變動隔離開來。

單體:另一種風味

BFF 用在單體上一樣有用,只是動機不同。你不是在編排多個服務。你面對的是一個沒有為你特定 UI 需求設計的通用 API。

在這種情境下,BFF 通常是單體前方一層很薄的 轉接層(adapter layer)。它可以跟後端在同一個 codebase(作為一個模組、一組 Next.js route handler,或 monorepo 裡一個獨立的 Express 應用程式),也可以獨立成一個服務。

單體情境的關鍵規則是:讓 BFF 保持輕薄。一個開始複製單體商業邏輯的 BFF,就是反模式。它應該負責翻譯與彙整。商業規則屬於後端。

誰來寫 BFF?

這裡正是團隊最常搞錯的地方。

BFF 應該由前端團隊擁有。 這就是整個模式的重點。

如果後端工程師擁有 BFF,它會慢慢漂回一個通用 API。審查週期變慢。前端團隊最後透過 pull request 和 JIRA ticket 來協商回應形態。這些形態要等後端團隊核准才出得去。你只是把一個問題,換成同一個問題的比較慢版本。

當前端工程師擁有 BFF,他們就擁有契約。他們需要時就重塑資料。他們不用提出申請就能新增端點。他們以前端的速度出貨。

這確實要求前端工程師寫一些伺服器端程式碼。但不多。BFF 的程式碼大多是膠水工作。呼叫這個服務、轉換那個回應、回傳一個乾淨的形態。它需要你熟悉 HTTP、認證流程和非同步 I/O。能寫複雜 React 應用程式的工程師,多半都能輕鬆勝任。

擁有權的界線就是 BFF 本身。BFF 呼叫下游服務,但絕不擁有商業邏輯。資料庫寫入、領域規則、不變量都留在後端。BFF 牢牢待在呈現層。這個區別必須主動維護。

語言選擇

是的,這很重要,尤其當前端工程師是主要擁有者時。

搭配 Node.js runtime 的 TypeScript 是我的預設建議。 理由很務實。

當你的組織已經有 Go 的專業,而且預期 BFF 會由 full-stack 或後端工程師維護時,Go 是合理的替代方案。Go 很擅長處理並行 fan-out,這在 BFF 為了組出單一回應而同時發出多個下游請求時很重要。

語言選擇應該跟著擁有權模式走。如果前端工程師擁有 BFF,就應該用他們已經會的語言。任何其他選擇都會製造摩擦,而摩擦最終會把擁有權推回後端團隊。

安全性:BFF 免費送你什麼

安全性是支持 BFF 最有力的論點之一,而且長期被低估。

Token 存放的問題

單頁應用程式有個老問題:access token 要放在哪裡?localStorage 很方便,但會被頁面上任何 JavaScript 碰到。在 npm 套件供應鏈攻擊的時代,這是真實的風險。放記憶體比較安全,但一重新整理就沒了。HTTP-only cookie 有用,但需要伺服器端處理。

BFF 徹底解決了這個問題。瀏覽器永遠不會持有原始的 access token。

BFF 負責執行 token 交換。它從你的身分提供者收到授權碼,換成 access token 和 refresh token,把 token 存在伺服器端,然後交給瀏覽器一個 HTTP-only 的 session cookie。之後前端每次請求都會帶著這個 cookie。BFF 找到 session,把真正的 token 掛到下游請求上,再轉送出去。token 永遠不會進入 JavaScript 的領域。

// BFF 處理 OAuth 回呼——瀏覽器看不到存取令牌
app.get("/auth/callback", async (req, res) => {
  const { code } = req.query;
  const tokens = await identityProvider.exchangeCode(code);
 
  // 令牌儲存在伺服器端
  await sessionStore.save(req.sessionId, tokens);
 
  res.setCookie("session", req.sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: "strict",
  });
 
  res.redirect("/dashboard");
});

什麼不該放進 BFF

BFF 絕對不該變成領域規則堆積的地方。讓它專注在它被設計來做的事:讀取彙整、回應塑形、認證委派。

具體來說:

如果你的 BFF 有自己的資料庫,而且開始擁有商業規則,那你已經不是在蓋 BFF,而是在蓋第二個後端。那是完全不同的問題。

部署

與前端放在一起

如果你用 Next.js,你已經有輕量 BFF 所需的基礎設施。那就是 Route Handlers(App Router)或 API Routes(Pages Router)。你的前端和 BFF 住在同一個專案,一起部署成單一單位。

這是中小型應用程式正確的預設選擇。代價是你不能讓 BFF 獨立於前端來擴展。對多數團隊來說,這個限制無關緊要。從這裡開始。

monorepo 中的獨立服務

對較大的應用程式,常見的做法是讓一個專用的 BFF 服務,在 monorepo 裡跟前端並肩而立。兩者共用 TypeScript 型別,但獨立部署。前端透過 HTTP 打 BFF。BFF 再 fan-out 到後端服務。

這種設定在營運上比較複雜,但給你獨立的擴展和更乾淨的分離。當 BFF 的複雜度足以支撐這個營運負擔時,它就值得。這代表有多種彙整模式、快取層、連線池。

Kubernetes sidecar

在 Kubernetes 環境中,BFF 可以部署成與前端伺服器同一個 Pod 的 sidecar 容器。前端透過 localhost 跟 BFF 溝通,沒有外部網路跳點,把延遲保持在接近零。兩者永遠一起部署,也消除了版本錯位的問題。

該避免什麼

不要讓多個前端應用程式共用一個 BFF。一旦你這麼做,就是在蓋 API gateway。你把想解決的那個一般化問題,在更高一層重新引入了。

真實案例

SoundCloud 是這個模式正式被命名的地方。他們的工程團隊正是遇到了上面描述的協調問題。行動和網頁客戶端需要不同的資料形態,而一個共用的通用 API,意味著前端和後端團隊之間無止盡的協商。拆成每個客戶端各自的 BFF,出貨速度就此恢復。

Netflix 為它支援的每個裝置都跑一層 BFF。數量很多。電視、手機、平板、遊戲主機、網頁瀏覽器的效能預算和渲染限制都差很多。單一 API 無法把這些都服務好。Netflix 的 BFF 層為每個表面處理翻譯。

Spotify 用類似的模式。網頁播放器、桌面客戶端、行動 App 各有不同的資料需求和效能目標。他們的 BFF 層處理到內部服務的 fan-out,並回傳為每個客戶端量身打造的回應。

這三個案例中,BFF 都是一個結構性的決定,反映了他們的工程團隊怎麼組織,以及他們需要多快出貨。

什麼時候不要用 BFF

BFF 模式多了一個網路跳點、一個要營運的服務、一個要管理的部署。這個負擔必須自己證明值得。

如果符合以下情況,就跳過 BFF:

最貴的 BFF,是你在有理由之前就蓋好的那一個。那些脚手架增加了認知負擔,而如果沒有真正的協調問題要吸收,它就變成多餘要維護的程式碼。

結論

BFF 模式是一個 團隊結構工具,只是打扮成架構模式。

它主張:打造 UI 的團隊,應該擁有餵養它的 API 契約。語言選擇(TypeScript)來自於擁有權。安全模型(token 放在伺服器端)來自於 BFF 是一個可信賴的後端層。部署來自於前端和 BFF 一起演進。

當你有協調問題時用它。也就是當前端工程師在等後端團隊重塑回應,或當你在服務多個需求差異很大的客戶端表面時。不要用它來解決你還沒有的問題。

當你真的需要它時,沒有什麼比 BFF 更能給前端團隊對自己的程式碼、以及它消費的資料之間那份契約的完整控制。

© Melvin Laplanche - All rights reserved.