33.6 BFF 与 API 组合

一句话

BFF 不是“网关的别名”,而是按客户端裁剪的后端:它把 客户端数 × 服务数 的集成点收敛成“每个客户端一个入口”,代价是多一层必须有人维护、必须独立部署的代码——它属于前端团队,不属于平台团队。

三个东西,三种职责

组件 职责 归属 不该做什么
API 网关 认证、限流、路由、TLS、日志(47.2 API 网关) 平台团队 业务聚合、字段裁剪
BFF 面向一种客户端的聚合、裁剪、协议转换 前端团队 业务规则、数据所有权
服务 领域逻辑、数据所有权 后端团队 为某个客户端定制字段

🔑 判据:一个接口如果只为一个客户端服务,它就该在 BFF;如果对所有客户端都成立,它才该进服务。

N×M 收敛:为什么 BFF 值得多一层

6 个服务、4 个客户端(Web / iOS / Android / 小程序)。没有 BFF 时,每个客户端都要自己调 6 个服务、自己拼装、自己处理 6 套错误码。

package main

import "fmt"

const (
	services  = 6     // 商品/库存/价格/评价/推荐/用户
	clients   = 4     // Web / iOS / Android / 小程序
	objKB     = 8.0   // 每个服务返回的对象大小(KB)
	mobileTrim = 0.30 // 移动端真正需要的字段占比
	rttMs      = 200.0 // 移动网络 RTT
)

func main() {
	fmt.Println("=== 直连 N 个服务 vs 一个 BFF(移动端视角)===")
	fmt.Printf("%-34s %-14s %-14s %s\n", "指标", "直连", "BFF", "变化")

	// ① 首屏请求数
	fmt.Printf("%-34s %-14d %-14d %s\n", "首屏请求数", services, 1,
		fmt.Sprintf("-%.0f%%", (1-1.0/float64(services))*100))

	// ② 下行字节
	directKB := float64(services) * objKB
	bffKB := directKB * mobileTrim
	fmt.Printf("%-34s %-14s %-14s %s\n", "下行字节/次(移动端)",
		fmt.Sprintf("%.1f KB", directKB), fmt.Sprintf("%.1f KB", bffKB),
		fmt.Sprintf("-%.0f%%", (1-mobileTrim)*100))

	// ③ 串行首屏延迟(客户端一个个调)
	fmt.Printf("%-34s %-14s %-14s %s\n", "串行首屏延迟(RTT 200ms)",
		fmt.Sprintf("%.0f ms", float64(services)*rttMs), fmt.Sprintf("%.0f ms", rttMs+30),
		fmt.Sprintf("-%.0f%%", (1-(rttMs+30)/(float64(services)*rttMs))*100))

	// ④ 客户端侧集成点
	fmt.Printf("%-34s %-14d %-14d %s\n", "客户端侧集成点", clients*services, clients,
		fmt.Sprintf("-%.0f%%", (1-float64(clients)/float64(clients*services))*100))

	// ⑤ 服务改字段时要发版的端数
	fmt.Printf("%-34s %-14d %-14d %s\n", "服务改字段要发版的端数", clients, 0, "-100%")

	fmt.Printf("\nBFF 侧要维护的适配点 = %d 个 BFF × %d 个服务 = %d 个(集中在服务端,可复用、可灰度)\n",
		clients, services, clients*services)
	fmt.Printf("直连时这些适配逻辑分散在 %d 个客户端里,且要跟随客户端发版(iOS 审核 1–3 天)\n", clients)
}

本机实测(macOS,Apple M1,Go 1.27):

=== 直连 N 个服务 vs 一个 BFF(移动端视角)===
指标                                 直连             BFF            变化
首屏请求数                              6              1              -83%
下行字节/次(移动端)                        48.0 KB        14.4 KB        -70%
串行首屏延迟(RTT 200ms)                  1200 ms        230 ms         -81%
客户端侧集成点                            24             4              -83%
服务改字段要发版的端数                        4              0              -100%

BFF 侧要维护的适配点 = 4 个 BFF × 6 个服务 = 24 个(集中在服务端,可复用、可灰度)
直连时这些适配逻辑分散在 4 个客户端里,且要跟随客户端发版(iOS 审核 1–3 天)

读数:

指标 直连 → BFF 说明
首屏请求数 6 → 1 移动端最容易感知的指标:每个请求一次无线唤醒 请求数减少对延迟的影响往往大于服务端耗时
下行字节 48KB → 14.4KB 只传客户端真正用到的字段 移动流量与解析时间都省
串行首屏 1200ms → 230ms 直连时客户端逐个调(保守假设),BFF 内部并行 这是 BFF 最大的可见收益
服务改字段影响 4 端 → 0 端 改 BFF 即可,客户端不用发版 iOS/Android 审核期不再阻塞后端
适配点 24 集中到服务端 可以统一测试、灰度、回滚 代价是这 24 个点要有人维护

🔑 BFF 的收益不在“少写代码”,而在把变更的爆炸半径从客户端侧挪到服务端:客户端发版慢(审核 1–3 天)、不可强制升级,而后端可以随时回滚。代价是新增一层要部署、要监控、要有人负责的代码。

一个客户端一个 BFF,还是共用?

形态 适用 问题
每端一个 BFF(Web / iOS / Android / 小程序 各一个) 端之间差异大(字段、交互、鉴权方式不同) 4 份代码,公共逻辑要抽库,否则复制粘贴
按端类型合并(移动端一个、Web 一个) 移动端之间差异小 要在一个 BFF 里做版本分支,分支会越积越多
一个通用 BFF 服务所有端 客户端少、差异小 很快退化成“第二个网关 + 一堆 if 判断客户端类型”

判断标准只有一条:改动是否会因为“端不同”而不同。会 → 分开;不会 → 合并。

BFF 里能放什么、不能放什么

能放 例子 不能放 后果
字段裁剪 移动端只要 12 个字段 业务规则 规则散落在 4 个 BFF,口径不一致
多服务聚合 一次返回商品+价格+评价摘要 数据所有权 BFF 开始写库,变成第二个后端
协议转换 REST ↔ gRPC ↔ GraphQL 事务 跨服务事务写在 BFF,无法保证
端侧适配 图片尺寸、分页大小、时区 权限的最终判定 BFF 判定被绕过(应服务端校验)
缓存与降级 首页兜底数据 长期状态 BFF 变成有状态服务,无法水平扩

⚠️ 最常见的腐化路径:BFF 里加了一个“小判断” → 加了第二个 → 抽成了 service 层 → 最后 BFF 里长出了一套业务逻辑。防线是“BFF 的代码不允许出现领域名词的写操作”,评审时只看一条:这个 BFF 有没有调用任何写接口之外的业务规则。

BFF 与查询聚合的分工

问题 归谁
一次查询要拼 6 个服务的数据(N+1、分页、排序) 查询聚合的设计在 30.7 跨服务查询与数据聚合
跨服务分页/全局排序的正确性 读模型 / CQRS(32.1 CQRS),不要指望 BFF 拼对
字段裁剪、协议转换、端侧默认值 BFF
鉴权与限流 网关 + 服务(BFF 只透传身份)

🔑 一句话分工:BFF 负责“形状”,不负责“正确性”。把 6 个服务的列表拼成一个数组是形状问题;保证这个数组按全局时间排序、分页不重不漏,是数据问题——后者需要读模型,BFF 做不了。

反模式

反模式 后果
BFF 变成业务逻辑层 4 个 BFF 各实现一遍规则,口径漂移,且无法被领域测试覆盖
BFF 直接连数据库 数据所有权被绕过,服务无法独立演进(30.3)
每个功能一个 BFF(BFF 爆炸) 部署与监控对象翻倍,团队记不住哪个 BFF 服务哪个页面
BFF 里做全局排序/分页 结果错误但看起来“能用”(30.7 的命中率表)
平台团队统一维护所有 BFF 前端需求排队,BFF 变成第二个中台
BFF 无缓存、无降级 任一依赖抖动,整个客户端白屏
客户端绕过 BFF 直连服务 收敛失效,字段裁剪与版本兼容又回到客户端

自检

Q1 一个团队有 4 个客户端、6 个服务,直连时“服务改一个字段要发 4 个客户端”。改用 BFF 后这个问题消失了吗?

▸ 答案:没有消失,只是搬到了 BFF。服务改字段后,4 个 BFF 里负责该字段的适配代码仍要改,只是改的是服务端代码——发版从“4 个客户端 + 审核”变成“1 次服务端发布”。收益是变更速度(分钟级 vs 天级)与可回滚性,不是“零改动”。真正的零改动需要服务端做向后兼容(只加字段)与客户端做 tolerant reader(30.6 服务版本与兼容)。

Q2 为什么说 BFF 属于前端团队,而不是平台团队?

▸ 答案:因为 BFF 的形状由客户端体验决定:字段要哪些、列表怎么排、图片什么尺寸、空态怎么返回。这些决策每天都在变,且需要与设计/产品紧密迭代。平台团队维护会导致“前端需求排队 + 平台不理解端侧细节”两个问题同时出现。平台团队的职责是提供 BFF 的运行底座(部署、网关、监控、模板),而不是写 BFF 的业务代码。

Q3 BFF 能不能做“全局排序 + 分页”?

▸ 答案:不能保证正确。BFF 只能拿到各服务返回的那一页数据,按 30.7 的演示,跨服务“各取第 N 页再合并”在第 2 页就会丢数据(命中率 20%),第 3 页起完全错位。要正确,BFF 必须“每个服务取前 p×size 条”(深翻页成本线性上升)或者改用读模型。BFF 适合拼形状,不适合拼正确性;需要精确分页时,把聚合下沉到读模型(32.1 CQRS)。

Q4 一个 BFF 里出现了“下单”这样的写操作,问题在哪?

▸ 答案:三个问题。① 事务边界:BFF 没有数据所有权,跨服务写只能靠分布式事务或最终一致,BFF 里做会绕开领域层的聚合边界(29.3);② 规则重复:4 个 BFF 各自实现“下单”,促销/限购/风控规则会漂移;③ 无法测试:领域规则应该被领域测试覆盖,放在 BFF 里只能靠端到端测试。正确做法是 BFF 只转发“下单”请求到订单服务,由订单服务做业务校验。

关联