你的移动团队正在做一个商品页。通用 API 返回几十个手机从不显示的字段,评价、库存和愿望清单还各需要再调用一次。这些端点的每次改动,都要等一个同时服务网站和三家合作伙伴的 API 团队。
Backend for Frontend(BFF)为每种客户端体验提供一个自己的小型服务端 API,由构建该客户端的团队负责。本文介绍这个模式的由来、BFF 应该做什么、它与 API 网关和 GraphQL 的比较、它如何让 OAuth 令牌远离浏览器、常见陷阱,以及如何画图。
模式的由来
给这个模式命名的是 SoundCloud。Phil Calçado 的2015 年文章描述了基于公开 API 构建的应用:个人主页会调用 /tracks/1234.json、/tracks/1234/related.json、/users/86762.json 和 /users/me.json,再自己合并结果。客户端团队拥有自己的后端之后,就把合并工作搬到了那里,应用只需请求 GET /user-profile/123.json。Web 技术负责人 Nick Fisher 创造了这个术语。Calçado 总结说:“BFF 是应用的一部分。”
Sam Newman 在 2015 年 11 月发表的文章 Backends For Frontends 把它推广开来:每种用户体验一个后端,由构建该 UI 的团队维护。
话痨客户端与一刀切的 API
- 话痨客户端。 Newman 的例子是愿望清单:先获取清单,再获取其中各项的名称、价格和库存。这是两次相互依赖的往返;在移动网络上假设每次 150 ms,渲染开始前就要过去 300 ms。有了 BFF,手机只需一次往返,相互依赖的调用在数据中心内部执行,那里一跳最多只要几毫秒。
- 按最大屏幕设计的载荷。 手机会下载从不显示的字段。
- 共享的瓶颈。 每个客户端团队都要为同一个可部署单元排队提交改动,而它常常归另一个团队所有。
BFF 的职责
| 职责 | 放在 BFF 中 | 不放在 BFF 中 |
|---|---|---|
| 聚合 | 每个屏幕一个端点,并行调用各服务 | 每个客户端都需要的 join;这些属于服务 |
| 响应整形 | 选取、重命名和格式化字段 | 定价等业务规则 |
| 认证与会话 | 会话 Cookie、令牌的存储和刷新 | 最终授权;由服务决定 |
| 缓存 | 短时效的聚合响应 | 仅以 URL 作为键的缓存中的个性化数据 |
| 功能开关 | 该客户端看到哪个变体 | 会改变领域行为的开关 |
BFF、API 网关与 GraphQL 的比较
| API 网关 | BFF | GraphQL 服务器 | |
|---|---|---|---|
| 服务对象 | 所有客户端 | 一种客户端体验 | 通过一个 schema 服务任意客户端 |
| 通常归属 | 平台团队 | 客户端团队 | 图团队,或通过联邦由各领域团队负责 |
| 职责 | TLS、路由、令牌检查、限流 | 聚合、整形、会话和令牌 | 让客户端选择字段;resolver 调用服务 |
| 屏幕变化时是否需要改动 | 否 | 是,在同一次发布中 | 通常只需改查询 |
| 典型故障 | 业务逻辑渗入 | 重复代码、扇出缓慢 | 昂贵的查询、N+1 的 resolver 调用、HTTP 缓存更难 |
常见的做法是在边缘放一个网关,后面再放 BFF,就像 API 网关架构图那样。一个共享的图可以取代按客户端划分的 BFF,Microsoft 的模式指南指出,如果 resolver 针对各前端定制,独立的 BFF 可能没有增加任何价值。代价是团队边界:每个客户端团队都要就同一个 schema 的改动进行协商。
归属与团队边界
BFF 属于客户端团队:Web 应用和它的 BFF 在同一个 pull request 中修改,并在同一次发布中上线。Newman 指出,移动团队甚至可以把逻辑挪进自己的 BFF,从而无需等待应用商店审核就能发布。
该有几个 BFF?Newman 引用了 Stewart Gleadow 的说法:一种体验,一个 BFF。REA Group 为 iOS 和 Android 分别运行 BFF;SoundCloud 的 iOS 和 Android 听众应用共用一个,其工程师告诉 Newman,他们可能会重新考虑。Pete Hodgson 的做法是沿着团队边界划分。你的工程组织架构图应该为每个 BFF 恰好标出一个负责团队。
合作伙伴 BFF 保持稳定、带版本的契约,并在服务变化时把它映射到各服务上,这样内部的改动就不会破坏合作伙伴的集成。把合作伙伴直接路由到带版本的服务 API 也行得通。
作为浏览器应用令牌处理器的 BFF
自己运行 OAuth 的单页应用会把令牌放在浏览器里,任何被注入的脚本都能窃取它们。JWT 指南列出了浏览器应用可以把令牌存放在哪里以及每种方式的风险。RFC 10017,即面向浏览器应用的 OAuth 2.0,在 2026 年 8 月成为 Best Current Practice(BCP 212)。它按安全性从高到低排列了三种架构:
| 模式 | 令牌所在位置 | 被注入的 JavaScript 能做什么 | RFC 10017 的结论 |
|---|---|---|---|
| BFF | 只在服务器上 | 页面打开期间通过 BFF 发送请求 | 对业务和敏感应用强烈推荐 |
| 令牌中介后端 | 访问令牌在浏览器,刷新令牌在服务器 | 窃取访问令牌,并向后端索取新的 | 仅在完整 BFF 不可行时使用 |
| 基于浏览器的 OAuth 客户端 | 所有令牌都在浏览器 | 窃取每个令牌并运行新的流程 | 不推荐用于业务或敏感应用 |
这里的 BFF 是机密(confidential)OAuth 客户端。它运行带 PKCE 的 authorization code 流程,用自己的凭据交换授权码,把令牌与用户的会话绑定存储,并在转发的每个调用上附带访问令牌。浏览器只持有一个 Cookie;OAuth 2.0 流程图逐步讲解了 code 流程。
RFC 的主要规则:
- Cookie: 必须设置
Secure和HttpOnly;推荐SameSite=Strict、Path=/、不设Domain,并使用__Host-Http-之类的名称前缀。Chrome 和 Firefox 会强制执行该前缀;Safari 在撰写本文时(2026 年 11 月)只应用其中的__Host-部分。 - CSRF: 必须有防御措施。在每个 API 调用上加一个固定的自定义请求头,会对跨源请求强制触发 CORS 预检。
- 代理: 只转发到允许列表中的主机。
- 会话状态: 使用服务端存储(RFC 仅建议在小规模时采用),或把加密后的令牌放进 Cookie,大小限制在 RFC 6265 要求浏览器支持的每个 Cookie 4,096 字节之内。让会话的有效期与刷新令牌一致。
Set-Cookie: __Host-Http-sid=3f9c...; Secure; HttpOnly; SameSite=Strict; Path=/
// Same-origin fetch sends the cookie by default.
await fetch("/bff/orders", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF": "1" },
body: JSON.stringify(order),
});
BFF 并不能阻止跨站脚本攻击:页面打开期间,被注入的代码仍然可以调用 BFF,所以要保留 Content Security Policy。原生应用改为遵循 RFC 8252,在系统浏览器中运行带 PKCE 的 code 流程,因此移动端 BFF 通常只需验证一个 bearer 令牌。
陷阱
- 重复的逻辑。 Newman 宁可接受一些重复,也不愿用共享代码把各个 BFF 耦合在一起。当同一段逻辑出现第三次时就把它提取出来:技术代码做成库,业务逻辑做成领域服务。SoundCloud 在大约五个 BFF 各自拼装用户资料之后,构建了 UserProfileService。
- 变成单体的 BFF。 警示信号:它拥有领域数据或业务规则、其他 BFF 调用它,或者多个团队向它提交代码。折扣由 Web BFF 计算而移动端 BFF 不计算,这样的 bug 会被客户发现。
- 扇出延迟。 如果一个 BFF 并行调用五个服务,至少有一个服务的响应慢于其自身 p99 的概率是 1 − 0.99^5 ≈ 4.9%:BFF 的 p95 大致相当于各服务的 p99。Dean 和 Barroso 的 The Tail at Scale 介绍了应对办法。
- 多一跳。 每个 BFF 都会增加延迟、一套部署和一个值班轮换,而按 IP 地址限流的服务现在只能看到 BFF 的地址。
超时、部分响应与断路器
为每个屏幕设定一个截止时间并拆分它:BFF 中有 800 ms 时,每个调用 300 ms 的超时可以为一次重试留出空间。并行运行相互独立的调用,并把截止时间往下传。
然后决定哪些数据是必需的。Newman 的愿望清单需要清单本身,但没有库存也可以渲染。返回你已有的数据,并标明缺失的部分:
{
"items": [{ "id": "sku-123", "name": "Trail shoe", "price": "$89.00" }],
"stock": null,
"errors": [{ "section": "stock", "reason": "timeout" }]
}
给每个下游服务各配一个断路器和连接池,这样一个缓慢的服务会快速失败,而不是占住所有 worker;阈值和降级方案见断路器模式。只对幂等的读取重试,并且只在一层重试;聚合响应的缓存时间不要超过其中最新鲜部分所允许的时长。
BFF 架构图
图表内容
- Shared services (domain teams)
- Identity provider
- Session store
- Web app
- Web BFF
- Mobile app
- Mobile BFF
- Partner system
- Partner BFF
- Catalog service
- Orders service
- Profile service
- session cookie only
- bearer access token
- client credentials token
- parallel calls + token
- parallel, fewer fields
- stable v1 mapping
- tokens by session ID
- 1. log in (redirect)
- 2. code + PKCE → tokens
这张图的读法如下:
- 每种客户端体验一行, 按负责团队着色。每个客户端只调用自己的 BFF。
- 每个 BFF 向共享服务扇出; 它的箭头止于框架,因为它会调用屏幕所需的任意服务。
- 虚线边只用于登录。 浏览器被重定向到身份提供商,然后 Web BFF 用授权码换取令牌,这一步与 OpenID Connect 登录相同。
- 令牌留在服务器上, 以会话 ID 为键保存在会话存储中。加密 Cookie 也可行,超出小规模部署后 RFC 10017 更倾向于它。
- Markdown 块列出了 Cookie 设置和一个按屏幕划分的预算示例。
把 BFF 图画清楚
- 给每个客户端一行, 连同它的 BFF,并标注负责团队。
- 标明每条边上传递的内容:会话 Cookie、bearer 令牌或客户端凭据。
- 画出令牌所在的位置,是在存储中还是在加密 Cookie 中。
- 把网关单独画出来。 网关和 BFF 共用一个方框会掩盖归属关系。
- 留意枢纽。 一个连到所有客户端的 BFF 方框,就是通用后端。
- 省略端点列表。 每个 BFF 画一条带标签的边指向服务边界就够了;服务一侧由另一张BFF 背后的微服务图来呈现。
在 Archboard 中构建
- 用 Google 或 GitHub 登录并创建一个图表。
- 从 C4 模型 包中,为 Web 应用和移动应用各添加一个 容器,为合作伙伴添加一个 外部系统,每行一个。
- 在每个客户端旁边为其 BFF 添加一个 容器,填写技术和描述。
- 从 基础 包添加一个名为 Shared services (domain teams) 的 框架,里面放三个 容器 节点:catalog、orders 和 profile。
- 在 Web 行上方,添加一个来自 安全 包的 身份提供商,以及一个来自 数据 包的 Redis 节点作为会话存储。
- 把每个客户端连到它的 BFF,把每个 BFF 连到框架,给边加上标签,把两条登录边设为虚线,并按团队给每一行着色。
- 添加一个基础包的 Markdown 块,写入 Cookie 设置和预算,并添加基础包的 文本 标签标注各团队。
如果你的服务已经画在 Mermaid 流程图里,Mermaid 转换器能给你一份可编辑的初稿。这个示例共用 13 个图表节点,在 Free 套餐的限制之内。
常见问题
什么是 Backend for Frontend(BFF)模式? 为某一种客户端体验(如 Web 应用、移动应用或合作伙伴集成)构建、并由该客户端团队负责的服务端 API。它聚合后端调用并整形响应。
BFF 和 API 网关有什么区别? 网关服务所有客户端,负责路由、令牌检查和限流。BFF 服务一个客户端,并随其屏幕变化。很多系统会在多个 BFF 前面放一个网关。
GraphQL 会取代 BFF 模式吗? 可以,因为客户端自己选择所需的字段。但仍然需要有人负责 schema 和查询限制,而为单个客户端构建的 GraphQL 服务器本身就是一个 BFF。
BFF 的认证是如何工作的? BFF 是机密 OAuth 客户端:它运行带 PKCE 的 authorization code 流程,把令牌保存在服务器上,只给浏览器一个 HttpOnly 会话 Cookie。
我应该有几个 BFF? 每种用户体验一个,并与团队保持一致。体验相同、负责团队也相同的两个应用可以共用一个。