
每个前端开发者都遇过这种事:API 回传的内容技术上正确,但对你正在做的东西一点都不对。首页需要用户的姓名、头像,还有最近三笔订单。你分四次调用 API,在客户端把数据拼凑起来,然后祈祷网络够快,让用户不会察觉到那串加载过程。一个端点回传 47 个字段,而你只需要 6 个,偏偏你需要的那个字段,名字叫做 usr_acct_disp_nm 这种东西。
这是架构问题。后端是为了通用用途,或完全是另一个客户端而设计的。Backend for Frontend(BFF) 模式就是为了解决这个问题而存在。
这个词诞生于 SoundCloud,并由 Sam Newman 在微服务的研究中加以整理。核心概念很简单。你为每个客户端打造一个专属的服务器端层。一个想要服务所有客户端表面(网页、移动设备、电视、第三方集成)的通用 API,结果是一个都服务不好。
BFF 是一个负责以下工作的服务器:
「每个客户端表面一个 BFF」这个原则没有妥协空间。移动 App 和网页 App 的数据需求、性能预算、交互模式都不一样。两者共用一个 BFF,等于又回到打造一个谁都不为其最优化的通用 API。
一般后端关心的是 业务逻辑与数据完整性。它套用领域规则、拥有数据库,并以 领域 能理解的语言公开资源。它不在乎你的首页需要的 User 形态,跟你的个人档案页面稍微不同。
BFF 关心的是 呈现逻辑。它把下游后端提供的东西,转换成特定前端需要的东西。实务上代表:
这个区别很重要。后端工程师用实体和不变量(invariant)的角度思考。前端工程师的框架是组件,以及一个画面需要的数据。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 模式诞生于微服务的世界,这其来有自。当你的后端拆成十几个服务(用户、订单、库存、通知、账单),前端就得认识所有这些服务。光是一个小小的 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,它会慢慢漂回一个通用 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 最有力的论点之一,而且长期被低估。
单页应用程序有个老问题: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,而是在盖第二个后端。那是完全不同的问题。
如果你用 Next.js,你已经有轻量 BFF 所需的基础设施。那就是 Route Handlers(App Router)或 API Routes(Pages Router)。你的前端和 BFF 住在同一个项目,一起部署成单一单位。
这是中小型应用程序正确的默认选择。代价是你不能让 BFF 独立于前端来扩展。对多数团队来说,这个限制无关紧要。从这里开始。
对较大的应用程序,常见的做法是让一个专用的 BFF 服务,在 monorepo 里跟前端并肩而立。两者共用 TypeScript 类型,但独立部署。前端通过 HTTP 打 BFF。BFF 再 fan-out 到后端服务。
这种设定在运营上比较复杂,但给你独立的扩展和更干净的分隔。当 BFF 的复杂度足以支撑这个运营负担时,它就值得。这代表有多种汇总模式、缓存层、连接池。
在 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 模式是一个 团队结构工具,只是打扮成架构模式。
它主张:打造 UI 的团队,应该拥有喂养它的 API 契约。语言选择(TypeScript)来自于所有权。安全模型(token 放在服务器端)来自于 BFF 是一个可信赖的后端层。部署来自于前端和 BFF 一起演进。
当你有协调问题时用它。也就是当前端工程师在等后端团队重塑响应,或当你在服务多个需求差异很大的客户端表面时。不要用它来解决你还没有的问题。
当你真的需要它时,没有什么比 BFF 更能给前端团队对自己的代码、以及它消费的数据之间那份契约的完整控制。
© Melvin Laplanche - All rights reserved.