《后端架构设计·演进与治理》4.2 BFF:按端聚合与契约裁剪

从 arch-lab 同时服务 Web、移动端和合作方的订单详情页出发,说明 BFF 如何按端定义聚合计划、用白名单裁剪敏感字段,并把可选依赖失败降级为带标记的部分响应;给出并发聚合的 Go 实现,实测字段裁剪、必需与可选依赖的分界以及 section 并行度。

同一个订单详情,三个端要的形状不一样

arch-lab 的订单详情页最初由三个端各自直连 Order、Inventory、Payment:Web 一次要拿全部字段并展示支付时间,移动端只画一张卡片,合作方对账只关心订单状态和支付状态。问题不是“多调了几次”,而是三件事同时发生:移动端被迫下载 internalNote 和客户手机号;合作方在支付服务超时时拿不到订单,尽管它只需要订单状态;字段一旦新增,三个端要分别改解析代码。

BFF(Backend for Frontend)不是“在前端和后端之间再加一层”,而是把“某个端需要哪些数据、缺失时怎么显示”变成一个显式的、可测试的契约。它可以和边缘网关部署在同一个进程,也可以独立部署;本节把它实现为 gateway 包里的聚合器,复用上一节的认证结果和租户上下文。更完整的 BFF 形态可以对照 BFF 进阶 。

聚合计划:端决定形状,不决定业务规则

聚合计划是一张表:每个端需要哪些 section、哪些是必需、每个 section 保留哪些字段。表由端团队和后端团队共同评审,新增字段必须显式加入白名单,而不是默认透传。这样做的代价是每次字段变更都要改计划,收益是泄露面可控、响应体可预测。

端orderinventorypayment失败策略
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 网关失败模式:超时预算、连接池与级联故障 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查