首页 / 文章

将Next.js路由处理程序转化为有意图设计的BFF层

了解前端后端模式旨在解决什么问题,为何它在Next.js应用中再度流行,以及如何避免将路由处理程序变成臃肿的“上帝对象”。

阅读约 6 分钟1435 词

某个移动开发团队的一名开发者曾询问,为何结账页面需要发起六次独立的API调用才能渲染完成,其中三次调用的返回数据竟是来自同一底层服务的重复字段。真实的原因是没人能明确界定前端实际需要什么格式的数据,因此每个团队都按照自己的理解来实现功能,最终由客户端负责将这些数据整合起来。这其实并非技术问题,而是组织架构层面的问题,而“前端后端模式”正是为了解决这类问题而设计的。或许正因如此,这个在十年前还只是Netflix和SoundCloud工程博客中几乎被遗忘的理念,最近又在多篇文章中被提及。

BFF 并非什么新概念。在“BFF”成为标准术语之前,已有许多工程师以不同的名称在开发类似功能。新的是,它如今又在 Next.js 应用这一特定场景中被重新引入,而这些应用逐渐演变成了 API 聚合层,往往并非出于刻意设计。鉴于这一趋势,我们不妨有意识地采用这种模式,而非偶然间陷入其中。

问题的本质

这种场景会以略有不同的形式不断重复出现。你有几项后端服务,有些是内部开发的,还有些则是后来添加的第三方API。此外还有多个客户端:一个网页应用、一个移动应用,或许还有一个内部管理工具。这些客户端的需求各不相同:移动客户端由于经常在不可靠的网络环境中运行,因此需要轻量级的数据传输;网页控制台则希望获得完全反范式的响应,所有数据都已预先合并,以避免一系列连续的请求;而管理工具则需要原始的、未经处理的数据,因为使用它的人通常是在排查问题。

如果跳过BFF层,只会面临两种糟糕的结果。要么核心服务会膨胀成臃肿的通用接口,试图同时满足所有可能的客户端需求,最终变成一个不断添加“另一个可选查询参数”的死胡同;要么每个客户端都不得不在相同的后端服务之上重新实现自己的聚合逻辑。这样一来,三个不同的代码库中就会出现三种略有差异且不准确的“活跃用户”定义。

BFF究竟是什么

前端的后端是一种轻量级的服务器层,通常每种客户端类型或客户群对应一个,位于用户界面与真正的后端服务之间。它的唯一职责就是为所服务的特定客户端格式化数据。面向网页的BFF和面向移动端的BFF虽然可能调用相同的底层服务,但返回的数据格式却截然不同,因为它们各自要解决不同的展示问题。

在 Next.js 的讨论中,这个观点屡被提及,因为只要以正确的方式使用 App Router,它实际上就能免费为你提供 BFF 层。路由处理程序和服务器组件本就在服务器端运行,位于客户端与后端之间,并且具备发起多个并行请求的能力,还能够在数据到达浏览器之前对合并后的结果进行重新处理。许多采用了 App Router 的团队其实已经在执行类似 BFF 的功能,只是没有正式给它命名或统一应用这种模式。

// app/api/checkout-summary/route.ts
// A web-specific BFF endpoint that aggregates three backend calls
// into the one shape the checkout screen actually needs
export async function GET(req: Request) {
  const userId = getUserIdFromSession(req);  const [cart, pricing, shippingOptions] = await Promise.all([
    fetch(`${CART_SERVICE}/users/${userId}/cart`).then(r => r.json()),
    fetch(`${PRICING_SERVICE}/quote?userId=${userId}`).then(r => r.json()),
    fetch(`${SHIPPING_SERVICE}/options?userId=${userId}`).then(r => r.json()),
  ]);  // Reshape into exactly what the checkout component renders,
  // nothing more. This is the whole point.
  return Response.json({
    items: cart.items.map((i: CartItem) => ({ id: i.id, name: i.name, qty: i.qty })),
    total: pricing.total,
    currency: pricing.currency,
    shipping: shippingOptions.filter((o: ShippingOption) => o.available),
  });
}

现在想象一下,移动应用直接调用那三个服务,并在 Swift 或 Kotlin 中自行处理数据聚合,独立实现判断哪些配送选项可用的逻辑。问题恰恰就出在这种做法上——两个团队、两种语言,对同一业务规则有着不同的理解,迟早这些理解会出现分歧,而直到有支持工单被提交到某人手中之前,没人能发现这些问题。

问题不再仅仅是技术层面,还涉及管理层面

以正确方式采用 BFF 的难点与编写代码关系不大,而在于谁来负责它。仅为网页团队构建的 BFF 只有在由有权快速对其进行修改的人来管理时才能发挥作用,理想情况下应是网页团队本身,而非受两周发布周期限制的共享平台团队。如果你的 BFF 层最终由另一个后端团队负责,而该团队将其视为另一项需要经过与其他服务相同审批流程的微服务,那么你并未解决最初的瓶颈问题,只是将其位置向客户端挪近了一步而已。

这也正是该模式能如此自然地融入 Next.js 代码库的原因:编写路由处理函数的开发者通常就是那些编写调用这些函数的组件的开发者。无需提交工单,无需团队间交接,也无需协调独立的部署流程。如果需要更改结账汇总响应的格式,只需在用于显示该内容的组件的同一次拉取请求中进行修改即可。

陷阱:将每个路由处理函数变成“上帝对象”

在另一个极端,还有一种很少被提及的故障模式。一旦团队发现路由处理程序可以随意聚合各种数据,他们就会很难抗拒创建一个巨大的端点,在单次调用中返回应用程序的完整初始状态的冲动,因为较少的往返请求显然更有优势。有时候确实如此。但这同时也意味着一个路由处理程序最终要同时支持六个互不相关的功能,而对发货模块的微调现在会迫使你将其与积分组件放在同一个文件中,因为这两者都被塞进了类似 getInitialAppState 的函数里。

当 BFF 端点对应一个界面或一个范围明确的功能,而非“应用加载时可能需要的所有内容”时,这一模式依然适用。如果你难以根据某个具体的用户界面来为端点命名,那通常说明该端点的功能范围已经过宽了。

还有一个相关的警示信号值得注意:BFF端点从重新处理数据逐渐转变为重新实现业务逻辑。如果某个路由处理器自行决定客户是否符合免费配送条件,而非仅仅向定价服务查询后再转达结果,那么它就悄然变成了系统中已有规则的另一个决策来源。BFF的职责应是转换和整合数据,而非做出决策。一旦它开始自行决策,就会再次出现之前讨论过的那种偏离问题,只不过这种问题更靠近客户端,因此在代码审查时更难被发现。

我的看法

BFF并不一定非得以带有图表和维基页面的正式架构规范形式来呈现,不过如果这符合你们组织的运作方式,这样做也并无不妥。对于那些已经在使用Next.js的团队而言,采用BFF模式并非是为了引入新事物,而是为了给现有的功能命名:承认这些路由处理程序实际上起到了BFF的作用,不再假装它们只是普通的API路由,并根据客户端的实际需求而非后端服务边界来组织这些路由。这种模式本身从来都不是难点。真正困难且让大多数团队迟迟不愿面对的是,要承认聚合逻辑需要一个合适的归属地,然后将其交给真正负责该功能的前端团队,而不是等到结账页面需要发起六次独立调用才能渲染出来时才有人停下来追问原因。