同一个订单详情,三个端要的形状不一样
arch-lab 的订单详情页最初由三个端各自直连 Order、Inventory、Payment:Web 一次要拿全部字段并展示支付时间,移动端只画一张卡片,合作方对账只关心订单状态和支付状态。问题不是“多调了几次”,而是三件事同时发生:移动端被迫下载 internalNote 和客户手机号;合作方在支付服务超时时拿不到订单,尽管它只需要订单状态;字段一旦新增,三个端要分别改解析代码。
BFF(Backend for Frontend)不是“在前端和后端之间再加一层”,而是把“某个端需要哪些数据、缺失时怎么显示”变成一个显式的、可测试的契约。它可以和边缘网关部署在同一个进程,也可以独立部署;本节把它实现为 gateway 包里的聚合器,复用上一节的认证结果和租户上下文。更完整的 BFF 形态可以对照 BFF 进阶
。
聚合计划:端决定形状,不决定业务规则
聚合计划是一张表:每个端需要哪些 section、哪些是必需、每个 section 保留哪些字段。表由端团队和后端团队共同评审,新增字段必须显式加入白名单,而不是默认透传。这样做的代价是每次字段变更都要改计划,收益是泄露面可控、响应体可预测。
| 端 | order | inventory | payment | 失败策略 |
|---|---|---|---|---|
| Web | 必需,全部展示字段 | 可选 | 可选 | 任一可选失败 → 降级 |
| 移动端 | 必需,卡片字段 | 可选 | 可选 | 任一可选失败 → 降级 |
| 合作方 | 必需,状态字段 | 不请求 | 必需 | 支付失败 → 整体失败 |
“按端聚合”不等于“按端复制业务逻辑”。计划里只能出现取数、裁剪、组合,不能出现折扣计算、状态机推进或库存扣减。一旦 BFF 开始写业务规则,它就会变成第二个单体:每个端的规则不同,最终没人能说清订单状态到底由谁决定。BFF 的职责边界应当是“把已经算好的事实摆成客户端要的形状”,而不是“替领域层做决定”。
字段裁剪:白名单是安全边界
字段裁剪经常被当成“省流量”,但在多端场景里它首先是安全边界。customerPhone、internalNote、providerSecret 这些字段不是“客户端不需要”,而是“某些端不应该看到”。用 deny-list 裁剪,新增字段默认泄露;用 allow-list,新增字段默认不可见,必须显式放行。两者在正常迭代中的成本差不多,在事故中的后果完全不同。
trimFields 返回一个新 map,不修改上游数据。这一点很重要:如果 Fetcher 返回的是缓存里的共享 map,原地删除字段会让另一个端的响应也缺字段,而且这种 bug 很难复现。复制一份的成本是每个 section 一次小分配,相比网络和 JSON 序列化可以忽略。
裁剪还会掩盖 schema 漂移:上游改了字段名,allow-list 找不到旧字段,响应里就静默少一个字段。BFF 需要配合契约测试,至少对每个端的必需字段做断言,不能只依赖“页面没报错”。字段命名和兼容性规则可以对照 API 契约设计 。
部分失败:降级必须有标记
聚合天然是部分失败:三个依赖不可能同时健康。关键不是“能不能返回部分数据”,而是“客户端能不能区分缺失和空值”。如果库存服务超时,响应里 inventory: null,而 HTTP 仍是 200 且没有任何标记,客户端会把 null 当成“没有库存”,而不是“暂时不知道”。
| 失败类型 | HTTP | 响应体 | 客户端行为 |
|---|---|---|---|
| 可选 section 失败 | 200 | 对应 section 为 null,degraded=true,列出 missing | 显示占位和“稍后重试” |
| 必需 section 失败 | 502 | 不返回部分 body | 整页错误,允许重试 |
| 全部成功 | 200 | 完整 body,degraded=false | 正常渲染 |
本节的实现把 order 设为所有端的必需 section,把 inventory 和 payment 按端设为可选或必需。必需失败直接返回错误,避免“看起来成功、实际缺关键数据”的半成品;可选失败则保留其他 section,并在 Missing 里按计划顺序列出。Durations 记录每个 section 的耗时,方便定位是哪个依赖拖慢了整页,但它不是超时预算本身;预算传递在下一节展开。
可运行实现
下面的聚合器并发调用各 section,按计划顺序组装结果;Fetcher 是接口,真实实现可以是 HTTP 客户端、gRPC 客户端或本地缓存。测试用一个带并发计数的假 Fetcher,验证并行度、裁剪和降级语义。
// file: internal/platform/gateway/bff.go
// tier: L1
// verify: go test ./internal/platform/gateway/ -count=1
package gateway
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
type ClientKind string
const (
ClientWeb ClientKind = "web"
ClientMobile ClientKind = "mobile"
ClientPartner ClientKind = "partner"
)
// BFFSection is one upstream call. A nil Fields slice means all fields; a
// non-nil slice is an allow-list.
type BFFSection struct {
Name string
Required bool
Fields []string
}
type BFFPlan struct {
Client ClientKind
Sections []BFFSection
}
type Fetcher interface {
Fetch(ctx context.Context, orderID, section string) (map[string]any, error)
}
// BFFResponse carries the body plus the degradation metadata clients need to
// render a placeholder instead of failing the whole screen.
type BFFResponse struct {
Client ClientKind
OrderID string
Data map[string]any
Degraded bool
Missing []string
Durations map[string]time.Duration
}
type BFFAggregator struct {
Fetcher Fetcher
Plans map[ClientKind]BFFPlan
}
type bffResult struct {
index int
name string
data map[string]any
err error
dur time.Duration
}
func (a *BFFAggregator) Aggregate(ctx context.Context, client ClientKind, orderID string) (BFFResponse, error) {
plan, ok := a.Plans[client]
if !ok {
return BFFResponse{}, fmt.Errorf("bff: no plan for client %q", client)
}
if a.Fetcher == nil {
return BFFResponse{}, errors.New("bff: nil fetcher")
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ch := make(chan bffResult, len(plan.Sections))
var wg sync.WaitGroup
for i, section := range plan.Sections {
wg.Add(1)
go func(i int, section BFFSection) {
defer wg.Done()
start := time.Now()
data, err := a.Fetcher.Fetch(ctx, orderID, section.Name)
ch <- bffResult{index: i, name: section.Name, data: data, err: err, dur: time.Since(start)}
}(i, section)
}
go func() { wg.Wait(); close(ch) }()
results := make([]bffResult, len(plan.Sections))
for r := range ch {
results[r.index] = r
}
resp := BFFResponse{
Client: client, OrderID: orderID,
Data: make(map[string]any, len(plan.Sections)),
Durations: make(map[string]time.Duration, len(plan.Sections)),
}
for i, section := range plan.Sections {
r := results[i]
resp.Durations[section.Name] = r.dur
if r.err != nil {
if section.Required {
cancel()
return BFFResponse{}, fmt.Errorf("bff: required section %s: %w", section.Name, r.err)
}
resp.Degraded = true
resp.Missing = append(resp.Missing, section.Name)
resp.Data[section.Name] = nil
continue
}
resp.Data[section.Name] = trimFields(r.data, section.Fields)
}
return resp, nil
}
// trimFields copies only allowed keys; it never mutates a shared cache entry.
func trimFields(data map[string]any, allow []string) map[string]any {
if data == nil {
return nil
}
out := make(map[string]any, len(data))
if allow == nil {
for k, v := range data {
out[k] = v
}
return out
}
for _, field := range allow {
if v, ok := data[field]; ok {
out[field] = v
}
}
return out
}
// DefaultBFFPlans is the starting contract for the three clients.
func DefaultBFFPlans() map[ClientKind]BFFPlan {
return map[ClientKind]BFFPlan{
ClientWeb: {Client: ClientWeb, Sections: []BFFSection{
{Name: "order", Required: true, Fields: []string{"orderID", "status", "total", "currency", "customerID", "customerPhone", "internalNote"}},
{Name: "inventory", Required: false, Fields: []string{"sku", "available", "warehouse"}},
{Name: "payment", Required: false, Fields: []string{"status", "paidAt"}},
}},
ClientMobile: {Client: ClientMobile, Sections: []BFFSection{
{Name: "order", Required: true, Fields: []string{"orderID", "status", "total", "currency"}},
{Name: "inventory", Required: false, Fields: []string{"sku", "available"}},
{Name: "payment", Required: false, Fields: []string{"status"}},
}},
ClientPartner: {Client: ClientPartner, Sections: []BFFSection{
{Name: "order", Required: true, Fields: []string{"orderID", "status"}},
{Name: "payment", Required: true, Fields: []string{"status", "paidAt"}},
}},
}
}
// file: internal/platform/gateway/bff_test.go
// tier: L1
// verify: go test ./internal/platform/gateway/ -count=1
package gateway
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
)
type bffFakeFetcher struct {
mu sync.Mutex
data map[string]map[string]any
errs map[string]error
delay time.Duration
inFlight int
peak int
calls int
}
func (f *bffFakeFetcher) Fetch(ctx context.Context, orderID, section string) (map[string]any, error) {
f.mu.Lock()
f.inFlight++
f.calls++
if f.inFlight > f.peak {
f.peak = f.inFlight
}
f.mu.Unlock()
if f.delay > 0 {
time.Sleep(f.delay)
}
f.mu.Lock()
f.inFlight--
f.mu.Unlock()
if err := f.errs[section]; err != nil {
return nil, err
}
d, ok := f.data[section]
if !ok {
return nil, fmt.Errorf("no fixture for %s", section)
}
return d, nil
}
func bffFixture() *bffFakeFetcher {
return &bffFakeFetcher{
data: map[string]map[string]any{
"order": {"orderID": "o-42", "status": "PAID", "total": 19900, "currency": "CNY",
"customerID": "c-7", "customerPhone": "13800000000", "internalNote": "vip", "internalCost": 12000},
"inventory": {"sku": "sku-9", "available": 3, "warehouse": "WH-1", "internalCost": 8000},
"payment": {"status": "CAPTURED", "paidAt": "2026-10-28T09:00:00+08:00", "providerSecret": "never"},
},
errs: map[string]error{},
}
}
func bffRun(t *testing.T, f *bffFakeFetcher, client ClientKind) BFFResponse {
resp, err := (&BFFAggregator{Fetcher: f, Plans: DefaultBFFPlans()}).Aggregate(context.Background(), client, "o-42")
if err != nil {
t.Fatalf("Aggregate: %v", err)
}
return resp
}
func bffSection(t *testing.T, resp BFFResponse, name string) map[string]any {
m, ok := resp.Data[name].(map[string]any)
if !ok {
t.Fatalf("section %q is %T, want map", name, resp.Data[name])
}
return m
}
func TestBFFProjectionPerClient(t *testing.T) {
web := bffRun(t, bffFixture(), ClientWeb)
if web.Degraded || len(web.Missing) != 0 {
t.Fatalf("unexpected degradation: %v", web.Missing)
}
order := bffSection(t, web, "order")
if order["customerPhone"] != "13800000000" {
t.Fatalf("web order must keep customerPhone, got %v", order["customerPhone"])
}
if _, ok := order["internalCost"]; ok {
t.Fatal("web order leaked an unmapped field")
}
if _, ok := bffSection(t, web, "inventory")["internalCost"]; ok {
t.Fatal("inventory allow-list leaked internalCost")
}
if len(web.Durations) != 3 {
t.Fatalf("durations=%d want 3", len(web.Durations))
}
mobile := bffSection(t, bffRun(t, bffFixture(), ClientMobile), "order")
for _, field := range []string{"customerPhone", "internalNote", "customerID"} {
if _, ok := mobile[field]; ok {
t.Fatalf("mobile order must not contain %q", field)
}
}
if len(mobile) != 4 {
t.Fatalf("mobile order fields=%d want 4", len(mobile))
}
}
func TestBFFPartialFailureDegrades(t *testing.T) {
f := bffFixture()
f.errs["inventory"] = errors.New("inventory timeout")
resp := bffRun(t, f, ClientWeb)
if !resp.Degraded || len(resp.Missing) != 1 || resp.Missing[0] != "inventory" {
t.Fatalf("degraded=%v missing=%v want true [inventory]", resp.Degraded, resp.Missing)
}
if resp.Data["inventory"] != nil {
t.Fatalf("missing section should be nil, got %v", resp.Data["inventory"])
}
if bffSection(t, resp, "order")["status"] != "PAID" {
t.Fatal("order should survive an optional failure")
}
}
func TestBFFRequiredFailureReturnsError(t *testing.T) {
f := bffFixture()
f.errs["order"] = errors.New("order store unavailable")
resp, err := (&BFFAggregator{Fetcher: f, Plans: DefaultBFFPlans()}).Aggregate(context.Background(), ClientPartner, "o-42")
if err == nil || resp.Data != nil {
t.Fatalf("required failure must return no body, got err=%v data=%v", err, resp.Data)
}
}
func TestBFFFetchRunsInParallel(t *testing.T) {
f := bffFixture()
f.delay = 30 * time.Millisecond
resp := bffRun(t, f, ClientWeb)
f.mu.Lock()
peak, calls := f.peak, f.calls
f.mu.Unlock()
t.Logf("peak=%d calls=%d sections=%d", peak, calls, len(resp.Data))
if peak < 2 || calls != 3 || len(resp.Data) != 3 {
t.Fatalf("peak=%d calls=%d sections=%d want >=2/3/3", peak, calls, len(resp.Data))
}
}
本机实测
环境同上:macOS darwin/arm64、Go 1.26.0,gofmt -l . 与 go vet ./... 均无输出。运行:
$ go test ./internal/platform/gateway/ -run 'TestBFF' -count=1 -v
=== RUN TestBFFProjectionPerClient
--- PASS: TestBFFProjectionPerClient (0.00s)
=== RUN TestBFFPartialFailureDegrades
--- PASS: TestBFFPartialFailureDegrades (0.00s)
=== RUN TestBFFRequiredFailureReturnsError
--- PASS: TestBFFRequiredFailureReturnsError (0.00s)
=== RUN TestBFFFetchRunsInParallel
bff_test.go:137: peak=3 calls=3 sections=3
--- PASS: TestBFFFetchRunsInParallel (0.03s)
PASS
ok archlab/internal/platform/gateway 0.259s
输出里的 peak=3 calls=3 sections=3 说明三个 section 确实同时在路上,而不是串行相加;测试只断言峰值至少为 2,避免在单核调度下偶发失败。0.03s 来自测试里人为设置的 30ms 延迟,不是上游真实耗时。四个测试分别验证:Web 保留手机号但裁掉未映射字段、移动端丢弃敏感字段、可选 section 失败时降级、必需 section 失败时整体报错。跨进程超时、重试和熔断没有在这个实验里,它们属于下一节的失败模式。
常见错误与边界
- 把 BFF 当业务层。折扣、状态机、库存扣减一旦写进聚合器,各端规则会分叉,领域边界随之消失。
- 用 deny-list 裁剪字段。新增敏感字段默认透传,事故后才补名单,泄露已经发生。
- 部分失败仍返回 200 且没有
degraded标记。客户端无法区分“没有数据”和“暂时拿不到数据”。 - 串行聚合。三个 100ms 依赖会变成 300ms;并发之后要配合超时与取消,否则慢依赖会拖住整个请求。
- 本节没有实现缓存、请求合并和响应压缩;这些优化会改变一致性窗口,必须在引入时单独评审。
小结
BFF 的核心不是“多一层聚合”,而是把每个端的数据形状写成显式计划:哪些 section、哪些字段、哪个必需、失败时怎么显示。白名单裁剪让新增字段默认不可见,部分失败标记让客户端能区分缺失与空值,并发取数让延迟接近最慢依赖而不是总和。
计划必须保持薄。它只做取数、裁剪和组装,不碰业务规则;一旦开始计算折扣或推进状态机,BFF 就会变成第二个单体,而且比原来的单体更难治理。下一节讨论当这些依赖变慢或连接池耗尽时,网关如何用超时预算和取消把故障限制在单个请求内。
阅读导航:上一节:4.1 边缘网关:路由、认证与租户限流 · 下一节:4.3 网关失败模式:超时预算、连接池与级联故障 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。