外观
Node.js BFF:前端团队的全栈边界
当产品从单体页面变成多端、多服务时,前端开始承担一类新的工作:把后端能力组装成页面真正需要的数据形状。
直接在浏览器里串联多个接口,会让客户端逻辑变复杂,还会暴露内部服务细节、增加请求数、难以统一鉴权。BFF(Backend For Frontend)就是在客户端与后端服务之间加一层轻量的服务端聚合层。
BFF 解决什么问题
典型场景:
- 首页需要用户信息、订单摘要、公告三个服务的接口,客户端串行请求要等三次往返。
- 不同端(Web、小程序、App)需要的字段和结构不同,后端不愿为每个端定制接口。
- Token 不应暴露在浏览器,需要服务端统一换取和刷新。
- 页面需要服务端渲染或流式输出,但后端服务是面向业务的 RPC 接口。
BFF 的定位是面向体验的聚合层,不是业务中台。它应该做:
- 接口聚合与裁剪。
- 鉴权、会话和灰度路由。
- 缓存、降级、限流。
- SSR 的渲染入口。
它不应该做:
- 核心业务规则。
- 数据持久化。
- 与具体服务重复的领域逻辑。
一个最小可用的 BFF
用 Fastify 写一个首页聚合接口:
ts
// server/routes/home.ts
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
size: z.coerce.number().int().min(1).max(50).default(10)
})
async function readJson<T>(response: Response): Promise<T> {
if (!response.ok) {
throw new Error(`Upstream ${response.status}`)
}
return response.json() as Promise<T>
}
export async function homeRoutes(app: FastifyInstance) {
app.get('/api/home', async (request, reply) => {
const { page, size } = QuerySchema.parse(request.query)
const signal = AbortSignal.timeout(2000)
const headers = {
'x-request-id': request.id
}
const [profileResult, ordersResult, noticesResult] = await Promise.allSettled([
fetch(`${process.env.USER_SERVICE}/profile`, { headers, signal }).then(readJson),
fetch(`${process.env.ORDER_SERVICE}/orders?page=${page}&size=${size}`, {
headers,
signal
}).then(readJson),
fetch(`${process.env.NOTICE_SERVICE}/notices`, { headers, signal }).then(readJson)
])
const errors = [profileResult, ordersResult, noticesResult]
.filter((result) => result.status === 'rejected')
.map((result) => (result as PromiseRejectedResult).reason)
if (errors.length > 0) {
request.log.warn({ errors }, 'partial home data failed')
}
return reply.send({
profile: profileResult.status === 'fulfilled' ? profileResult.value : null,
orders: ordersResult.status === 'fulfilled' ? ordersResult.value : { items: [] },
notices: noticesResult.status === 'fulfilled' ? noticesResult.value : []
})
})
}这里用了几个关键策略:
Promise.allSettled允许部分失败。首页公告服务挂了,不应该让整个页面报错。AbortSignal.timeout(2000)给上游设超时。没有超时的 BFF 会拖垮整个页面。- 透传
request-id,便于链路追踪。
鉴权应该收口在 BFF
浏览器只保存一个 HttpOnly 会话 Cookie,真正的访问令牌留在服务端:
ts
app.get('/api/profile', async (request, reply) => {
const session = await getSession(request.cookies.session)
if (!session) {
return reply.code(401).send({ message: '未登录' })
}
const accessToken = await refreshIfNeeded(session)
const response = await fetch(`${process.env.USER_SERVICE}/profile`, {
headers: { authorization: `Bearer ${accessToken}` }
})
return reply.send(await readJson(response))
})这样做的好处是:
- 令牌不进入 JavaScript 可访问的存储,降低 XSS 窃取风险。
- 刷新令牌、服务间签名、灰度流量都可以在服务端统一处理。
- 客户端不需要知道内部服务的地址和协议。
代价是 BFF 成了鉴权入口,必须认真处理 CSRF、Cookie 安全和会话过期。
缓存与降级
BFF 是缓存的理想位置,但缓存必须按用户或权限隔离:
ts
async function getHomeData(userId: string) {
const key = `home:${userId}:v3`
const cached = await redis.get(key)
if (cached) return JSON.parse(cached)
const data = await loadHomeData(userId)
await redis.set(key, JSON.stringify(data), { EX: 60 })
return data
}适合缓存的内容:
- 变化不频繁的配置、公告、公共列表。
- 用户维度的概览数据,设置较短 TTL。
- 下游服务不稳定时的兜底数据。
不适合缓存:
- 实时性要求高的订单状态、库存、余额。
- 带权限差异但缓存键没区分用户的数据。
降级策略要提前设计:下游超时返回旧缓存、返回空结构还是直接报错?不同模块应该有不同的兜底方案。
与 SSR 的关系
BFF 常与 SSR 合并部署,但职责要分清:
- SSR 负责首屏 HTML 渲染和数据注水(hydration)。
- BFF 负责数据聚合、鉴权和缓存。
- 两者可以同进程,也可以拆开。
同进程部署简单,但 SSR 渲染压力会影响 API 响应;拆开后运维复杂度上升,需要网关和服务发现。早期项目建议同进程,等流量和团队规模上来再拆。
常见坑
- BFF 变成业务后端。业务规则、数据写入逐渐堆进来,最终没人能维护。
- 客户端逻辑平移。只是把多个
fetch从浏览器搬到 Node,没有聚合、裁剪和错误策略,价值有限。 - 没有超时和重试边界。下游变慢时,BFF 连接池被占满,故障扩散。
- 重试非幂等请求。下单、支付类接口重试可能造成重复操作。
- 缓存键缺少用户维度。导致用户看到别人的数据,这是严重的安全事故。
- 缺少可观测性。Node 服务需要请求日志、错误率、P99 延迟和下游依赖监控,否则排查困难。
小结
BFF 的价值不是"让前端写后端",而是让前端团队对页面体验负责到数据层。它的边界应该清晰:聚合、鉴权、缓存、降级。超过这个范围的需求,应该回到业务服务,而不是继续往 BFF 里堆。