Skip to content

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 响应;拆开后运维复杂度上升,需要网关和服务发现。早期项目建议同进程,等流量和团队规模上来再拆。

常见坑 ​

  1. BFF 变成业务后端。业务规则、数据写入逐渐堆进来,最终没人能维护。
  2. 客户端逻辑平移。只是把多个 fetch 从浏览器搬到 Node,没有聚合、裁剪和错误策略,价值有限。
  3. 没有超时和重试边界。下游变慢时,BFF 连接池被占满,故障扩散。
  4. 重试非幂等请求。下单、支付类接口重试可能造成重复操作。
  5. 缓存键缺少用户维度。导致用户看到别人的数据,这是严重的安全事故。
  6. 缺少可观测性。Node 服务需要请求日志、错误率、P99 延迟和下游依赖监控,否则排查困难。

小结 ​

BFF 的价值不是"让前端写后端",而是让前端团队对页面体验负责到数据层。它的边界应该清晰:聚合、鉴权、缓存、降级。超过这个范围的需求,应该回到业务服务,而不是继续往 BFF 里堆。

基于 VitePress 构建 · 持续更新中