编程 一个 UI 一个 BFF:聚合多个微服务,以及下游挂掉时怎么降级

2026-10-11 00:04:34

一个 UI 一个 BFF:聚合多个微服务,以及下游挂掉时怎么降级

来源:Sam Newman,Pattern: Backends For Frontends(2015-11-18)。相关材料:Phil Calçado 提出 BFF 模式、Lukasz Plotnicki 谈 SoundCloud 的实践、RxJava、Finagle Futures。

背景:从厚客户端到 Web,再到移动端

Web 兴起之后,交付 UI 的主流方式从厚客户端应用转向 Web 交付,新功能发版成本大幅下降——客户端安装的成本在多数情况下被直接消掉了,SaaS 也因此成长起来。

这个更简单的世界没维持多久,移动端紧接着来了。问题变成:同一批服务端功能要同时通过桌面 Web UI 和一个或多个移动 UI 暴露出去。而系统最初是围绕桌面 Web 设计的,往往已经和桌面 Web UI 紧耦合,很难容纳新的 UI 形态。

通用 API 后端:先合一个出来,然后开始堵

支持多种 UI 的第一步,通常是提供一个单一的服务端 API,再按需求逐步加功能。

如果不同 UI 想做的调用相同或高度相似,这种通用 API 容易成功。但移动体验和桌面 Web 差异很大:

  • 设备能力不同。 屏幕小,能展示的数据少;开大量服务端连接耗电、费流量。
  • 交互性质不同。 拿线下零售商举例:桌面端可以做浏览商品、线上下单、到店预留;移动端可能想做扫码比价,或者在店里收到基于位置的优惠。

结果是移动端会发出不同的调用、更少的调用,展示不同(通常更少)的数据。通用 API 后端就得不断加功能来支持移动界面。

第二个问题是瓶颈。这个 API 后端按定义要服务多个面向用户的应用程序,所有变更都压在同一个可部署工件上,新交付的上线节奏会被拖慢。

第三个问题是职责漂移。通用 API 后端容易承担多重职责,于是常常见到一个专门团队被拉出来维护这份代码。情况反而更糟:前端团队改点东西要跟另一个团队对接,而这个团队既要平衡多个客户端团队的优先级,又要和多个下游团队协作去接新 API。走到这一步,架构里多了一个不聚焦任何业务域的「智能中间件」,这跟多数人对合理 SOA 的期待是相反的。

引入 Backend For Frontend

在 REA 和 SoundCloud 都出现过的做法是:不要通用 API 后端,改成每个用户体验配一个后端。Phil Calçado(前 SoundCloud)把它命名为 Backend For Frontend,也就是 BFF。

概念上,把面向用户的应用程序看成两个组件:边界外的客户端应用,和边界内的服务端组件(BFF)。

BFF 与特定用户体验紧耦合,通常由 UI 团队自己维护。好处有两层:按 UI 的需求定义和调整 API 更容易;客户端和服务端组件的发版更容易对齐。

一个 UI 用一个服务端 BFF。BFF 只聚焦这一个 UI,也只服务这一个 UI,因此更小、更专注。

两种做法对照:

维度通用 API 后端每个 UI 一个 BFF
服务对象多个 UI,有时还包括外部方单一 UI,或同一类 UI
响应形态倾向通用、面向资源按屏幕裁剪字段与数据量
变更节奏多客户端需求挤在同一个部署单元与 UI 同团队、同节奏
维护团队常见专门团队,成为协调瓶颈UI 团队自己维护
下游调用客户端各自拼装,调用次数多BFF 聚合、并行、可降级
跨服务重复较少,但耦合更重可能较多,按情况处理

到底要几个 BFF

同一(或相似)用户体验要覆盖不同平台时,有两种做法:

  1. 每一种客户端类型严格配一个 BFF。 这是 REA 用的模型,也是我更倾向的。
  2. 每一类用户界面配一个 BFF。 SoundCloud 用这个:Android 和 iOS 的 listener 原生应用共用一个 BFF。

对第二种的主要顾虑是:一个 BFF 承载的客户端类型越多,越容易被多种关注点撑大。关键点在于,即便共用一个 BFF,也是同一类用户界面在共享——SoundCloud 的 iOS/Android listener 应用共用一个 BFF,但其他原生应用(比如新的 Creator 应用 Pulse)用的是另一个 BFF。

如果 Android 和 iOS 由同一个团队维护、BFF 也归这个团队,第二种做法更容易接受;如果这些应用分属不同团队维护,我更倾向严格模型。也就是说,组织架构是决定哪种模型更合理的主要驱动之一——Conway 定律又赢了一次。值得注意的是,和我聊过的 SoundCloud 工程师提到,如果今天重新做这个决定,Android 和 iOS listener 共用一个 BFF 这件事他们会重新考虑。

Stewart Gleadow(他把这条归功于 Phil Calçado 和 Mustafa Sezgin)有一条准则很实用:「一个体验,一个 BFF」。iOS 和 Android 体验非常相似,共用一个 BFF 就比较好论证;如果两者差异很大,分开更合理。

Pete Hodgson 的观察是,BFF 与团队边界对齐时效果最好,所以团队结构应该驱动 BFF 数量:只有一个移动团队,就一个 BFF;iOS 和 Android 分成两个团队,就两个 BFF。我的顾虑是团队结构往往比系统设计更易变。如果移动端只有一个 BFF,后来团队拆成 iOS 和 Android 两个方向,是不是也得跟着拆 BFF?如果 BFF 本来就分开,拆团队会更容易,因为已经独立的资产可以直接换归属。BFF 与团队结构的相互作用值得认真对待。

减少 BFF 数量的常见动因,是想复用服务端功能、避免太多重复。这件事有别的处理方式。

聚合多个下游服务(微服务)

后端服务数量不多时,BFF 也是可用的模式。对使用大量服务的组织,BFF 几乎必需——交付一个用户功能需要聚合的下游调用会急剧增多。这时候,对 BFF 的一次调用通常会变成对多个微服务的下游调用。

举个例子。电商应用要拉取用户心愿单里的条目,同时展示库存和价格:

  • Wishlist 服务存列表信息,以及每个条目的 ID;
  • Catalog 服务存每个条目的名称和价格;
  • Inventory 服务存库存。

BFF 暴露一个「获取完整心愿单」的方法,内部至少是 3 次调用。

效率上,能并行的就并行。Wishlist 的调用必须先完成,之后到 Catalog 和 Inventory 的调用理想情况下同时发出,整体耗时才会短。并行与串行的混合在复杂场景里很快会变得难管理,这正是响应式编程风格能派上用场的地方——RxJava 或 Finagle 的 futures 让多个调用的组合更容易写。

聚合顺序大致是这样:

items = wishlist.get(userID)        // 串行:没有它,后面的 key 都拿不到

parallel {
    names = catalog.get(itemIDs)     // 名称与价格
    stock = inventory.get(itemIDs)   // 可能失败
}

view.items = items
view.names = names

if stock.ok {
    view.stock = stock.levels
} else {
    // Inventory 挂了就降级:不展示库存指示,其余照常返回
}

失败模式需要想清楚。上面这个例子可以要求所有下游调用都成功才返回 payload,但这合理吗?Wishlist 挂了确实无能为力;如果只是 Inventory 挂了,更好的做法是降级——比如去掉库存指示,其余照常返回。这些首先得由 BFF 自己处理;同时要保证调用 BFF 的客户端能理解部分响应并正确渲染。客户端和服务端得对「缺哪些字段是正常的」有共识,否则降级只是把 500 换成了渲染错乱。

复用与重复代码

每个 UI 一个 BFF 的一个担忧,是 BFF 之间会有大量重复:可能做同样类型的聚合,可能有相同或相似的调用下游服务的代码。有些人的反应是把它们合回去,做成一个通用的聚合型 Edge API 服务。这种模型一次次被证明会导致代码高度膨胀、多重关注点挤在一起。

我对跨服务的重复代码比较放松。在单个进程边界内,我通常会尽量把重复重构成合适的抽象;面对跨服务的重复,我的反应不一样。主要原因是,我更担心抽取共享代码导致服务之间紧耦合——这比重复本身更麻烦。当然,确实有值得抽的情况,三次法则在这里同样适用:重复到第三处再考虑抽象,比看到两处一样就急着合更稳妥。

Pete Hodgson 指出过一点:没有 BFF 的时候,「公共」逻辑往往被烤进各个客户端里。因为客户端技术栈差异很大,识别出这种重复本身就很难。组织在服务端组件上通常用统一技术栈,多个 BFF 之间的重复反而更容易被发现和抽离。

真要抽共享代码时,有两个明显的选项。第一个通常最便宜、但更麻烦:抽一个共享库。问题在于共享库是耦合的主要来源,尤其是用来生成调用下游服务的客户端库时。第二个选项是把共用能力往下沉——让下游服务提供更粗粒度的接口,把聚合逻辑留在服务提供方那一侧。两条路都意味着某种耦合,选之前先确认这份重复的规模与稳定性是否值这个价。

桌面 Web 与其他设备

BFF 不是移动端专属。桌面 Web 同样可以有 BFF——SoundCloud 的桌面 Web 也有自己的 BFF。判断依据是 UI 的形态和需求,而不是设备类型。

同一套逻辑也适用于外部方。第三方开发者、合作伙伴的集成需求通常和自家 UI 不同:调用集合不同、需要的字段不同、限流与配额诉求也不同。给他们单独一个 BFF,比让他们挤进自家 UI 的 BFF 更干净,也避免为外部方的改动牵动第一方 UI。

团队自治

BFF 的另一个价值是自治。UI 团队拥有 BFF,就能自己决定 API 形状、自己发版,不必为每次改动去排另一个团队的期。改动从「跨团队协调」变成「团队内部的事」,这是 BFF 最实际的收益之一。

代价是 BFF 数量增加带来的运维和基础设施开销:更多部署单元、更多监控对象、更多需要保持一致的东西。这部分不该被忽略,但协调成本省下来的通常更多。

边界层的通用关注点

BFF 通常位于系统边界,于是天然要处理一批横切关注点:认证、授权、限流、日志、监控、TLS 终止等。

这些如果在每个 BFF 里各写一遍,既浪费又容易不一致。可选做法有两种:一是在所有 BFF 前面放一层边界服务,统一处理这些事,Nginx、Apache 之类的反向代理或网关层常用来承担这个角色;二是抽共享库,但要接受它带来的耦合。让 BFF 只关心自己那一个 UI 的聚合逻辑,边界职责集中到一处,通常更清晰。

什么时候用

BFF 适合这些情况:

  • 有多个形态差异大的 UI,各自需要不同的调用集合和数据形态;
  • 后端是多个服务,客户端直接调会退化成 N 次调用拼装;
  • UI 团队有能力自己维护服务端组件,也愿意承担 BFF 的运维。

反过来,只有一个 UI 时,BFF 只是多一层。UI 团队没法自己维护服务端时,BFF 会变成新的协调瓶颈。而如果用一个 BFF 去服务所有客户端,那只是把通用 API 后端的问题换了个名字。

结论

BFF 解决的是「一个通用后端服务所有 UI」带来的耦合与瓶颈。它把面向用户的应用程序拆成客户端和它专属的服务端组件,让 API 形状跟着 UI 走,让发版节奏跟着团队走,并在微服务环境下承担聚合、并行与失败降级。

它不是免费的:BFF 之间会有重复代码,数量越多运维成本越高,团队结构和边界职责也要一并想清楚。核心判断标准其实是组织性的——谁来维护这个 BFF,它服务的是不是一个清晰的体验。

复制全文 生成海报 Web架构 BFF 微服务 API聚合 架构设计

推荐文章

程序员茄子在线接单