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 关心的是 呈现逻辑。它把下游后端提供的东西,转换成特定前端需要的东西。实务上代表:

这个区别很重要。后端工程师用实体和不变量(invariant)的角度思考。前端工程师的框架是组件,以及一个画面需要的数据。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.