《互联网产品的服务端架构·产品、流量与基础能力》13.1 账号、资料与 BFF 的模块划分

账号、资料和 BFF 三个模块共用一个 models 包时,边界只剩目录名。文章用一次越权读取说明数据所有权、依赖方向和对外契约如何划分,并给出可校验重复、缺失依赖与循环依赖的模块图实现,让拓扑序在每次构建中都可重复,也让三个模块能各自独立发布。

一次“只加一个字段”的越权读取

「回声」是一个带个人主页的社区产品。服务端早期只有三个业务包:

  • internal/account:账号、外部身份绑定、登录会话。
  • internal/profile:昵称、头像、简介、可见性和注销导出。
  • internal/bff:按 iOS、Android、Web、小程序组装页面数据。

某天产品要在个人主页显示“注册天数”。BFF 的负责人发现 account 库里已经有 created_at,于是在 BFF 的查询里直接 join 了账号表。代码评审只看了 SQL,觉得“只读一个时间字段,不会出事”,合并上线。

两周后,一个被对方拉黑的用户打开个人主页,仍然看到了对方的手机号尾号。问题不在这次 join 本身,而在于 BFF 从此绕过了 profile 的可见性过滤:手机号本来只允许本人和客服读取,BFF 却把它当成“公开资料”拼进了响应。更麻烦的是,修复时没人说得清还有多少条查询走了同一条捷径。

这次事故暴露的不是某个字段忘了脱敏,而是三个模块之间从来没有约定“谁拥有什么数据、谁只能通过什么方式读取”。目录分开了,所有权没有分开。

模块边界是数据所有权,不是目录名

把三个模块放在三个目录里,只能说明代码放在哪,不能说明谁能改哪张表、谁能读哪些字段。真正可执行的边界至少要回答四个问题:这个模块拥有什么数据,它依赖谁,它对外暴露什么,它绝对不能做什么。

模块拥有的数据允许依赖对外暴露明确禁止
account账号 ID、外部身份、凭据、会话与设备无业务上游认证结果、会话撤销、身份绑定读取资料可见性、拼装页面
profile昵称、头像、简介、可见性、注销与导出account 的账号 ID按访问者裁剪后的资料、删除与导出任务直接签发会话、按端拼装响应
bff各端页面契约、字段白名单、端能力account、profile 的公开接口面向具体端的聚合响应直接读写账号库或资料库
audit管理操作与敏感读取记录各模块的审计事件审计查询反向调用业务写路径

这张表最重要的不是“拥有”一列,而是“明确禁止”一列。只写允许什么,边界会在下一次“就加一个字段”时被绕过;把禁止项写出来,评审才有拒绝的依据。

“BFF 是否可以直接读账号库”这个问题,答案取决于读的是什么。如果读的是账号模块已经承诺的公开投影,比如账号 ID 和创建时间,那应该由 account 提供一个稳定的只读接口,BFF 调用接口而不是表。如果读的是凭据、会话、身份详情,那不只是耦合问题,而是越权问题:BFF 没有能力判断这些字段该不该出现在当前端的响应里。

profile 依赖 account 的账号 ID,但不依赖账号库的表结构。账号 ID 是契约,表结构是实现。依赖契约时,账号模块可以换存储、拆库、加索引;依赖表结构时,任何一次迁移都要先问 BFF 能不能一起发版。

bff 的职责是“同一份事实,按端裁剪和组装”。它拥有响应契约,不拥有业务事实。把创建时间缓存进 BFF 看起来省了一次调用,实际上制造了第二份事实:账号注销后 BFF 还在返回旧数据,资料可见性变更后 BFF 的缓存不知道。BFF 可以做短 TTL 的响应缓存,但不能把业务数据复制成自己的主数据。

还有一条经常被忽略:模块之间的依赖必须无环。profile 需要账号 ID,bff 需要资料,account 不应反过来调用 bff 发通知。一旦出现环,启动顺序、发布顺序和故障传播都会变成猜谜。环不一定在代码 import 里出现,也可能藏在“账号模块调用资料接口补昵称”这种运行时调用里。

失败场景:共享 models 包让边界名存实亡

比 BFF 直接读库更常见的退化,是三个模块共用一个 internal/models 包。最初只是为了复用 User 结构体,后来变成所有查询的集散地:

  • models.User 同时带账号字段、资料字段、会话字段和 BFF 展示字段。
  • 资料模块改一个字段名,账号模块和 BFF 一起重编译,发布被迫同步。
  • 可见性判断被复制到 BFF 的模板 helper 里,两份规则逐渐不一致。
  • 账号库想做一次字段拆分,发现 BFF 的 SQL 直接引用了旧列名。

后果不是“编译慢”,而是三件更严重的事。第一,任何模块都无法独立发布:一个资料字段的改名会阻塞账号模块的热修复。第二,权限判断失去唯一入口:资料模块改了拉黑规则,BFF 的旧 helper 还在按好友关系放行。第三,故障定位失去边界:线上返回了不该返回的字段,无法判断是资料裁剪错了,还是 BFF 自己查了库。

共享 models 包最危险的地方,是它让越界变得“类型安全”。编译器不会阻止 BFF 读取 User.Phone,因为字段就在那里。目录边界只能靠人自觉,而人总会在截止日期前选择更短的那条路。

修复这类退化,通常要做三步:先把 models 里的字段按所有者拆回各自模块;再把跨模块读取改成显式接口,接口只返回调用方真正需要的投影;最后在启动时校验模块图的依赖声明,让“新增一条反向依赖”在测试阶段就失败,而不是等线上事故。

用 Validate 与 Order 固定模块图

模块图可以先写成数据,再让代码校验。下面的实现把模块名、拥有数据和依赖声明放在一起,提供两个能力:Validate 检查重复、缺失依赖和环,Order 返回确定的拓扑序。

// file: internal/baseline/module.go
// tier: L1
// verify: go test ./internal/baseline/

package baseline

import (
	"fmt"
	"sort"
	"strings"
)

// Module 是服务端的一个业务模块。Owns 是它拥有的数据表或资源,
// DependsOn 是它调用或读取的上游模块名。
type Module struct {
	Name      string
	Owns      []string
	DependsOn []string
}

// Validate 检查模块图的基本不变量:名字唯一、依赖存在、没有环。
func Validate(modules []Module) error {
	index, err := indexModules(modules)
	if err != nil {
		return err
	}
	for _, m := range modules {
		for _, dep := range m.DependsOn {
			if _, ok := index[dep]; !ok {
				return fmt.Errorf("baseline: module %q depends on unknown module %q", m.Name, dep)
			}
		}
	}
	if cycle := findCycle(modules, index); len(cycle) > 0 {
		return fmt.Errorf("baseline: dependency cycle: %s", strings.Join(cycle, " -> "))
	}
	return nil
}

// Order 返回确定的拓扑序。层级按最长依赖链计算,同层按名字升序,
// 因此同一张模块图无论输入顺序如何都得到同一个结果。
func Order(modules []Module) ([]string, error) {
	if err := Validate(modules); err != nil {
		return nil, err
	}
	index, err := indexModules(modules)
	if err != nil {
		return nil, err
	}

	level := make(map[string]int, len(modules))
	var depth func(name string) int
	depth = func(name string) int {
		if got, ok := level[name]; ok {
			return got
		}
		best := 0
		for _, dep := range index[name].DependsOn {
			if d := depth(dep) + 1; d > best {
				best = d
			}
		}
		level[name] = best
		return best
	}

	names := make([]string, 0, len(modules))
	for _, m := range modules {
		names = append(names, m.Name)
		depth(m.Name)
	}
	sort.Slice(names, func(i, j int) bool {
		if level[names[i]] != level[names[j]] {
			return level[names[i]] < level[names[j]]
		}
		return names[i] < names[j]
	})
	return names, nil
}

// indexModules 建立名字到模块的索引。空名字和重复名字都在这里被拒绝,
// 避免后续拓扑计算拿到含义不明的节点。
func indexModules(modules []Module) (map[string]Module, error) {
	index := make(map[string]Module, len(modules))
	for _, m := range modules {
		if strings.TrimSpace(m.Name) == "" {
			return nil, fmt.Errorf("baseline: module name is required")
		}
		if _, dup := index[m.Name]; dup {
			return nil, fmt.Errorf("baseline: duplicate module %q", m.Name)
		}
		index[m.Name] = m
	}
	return index, nil
}

// findCycle 用三色 DFS 找一条环。栈中仍在灰态的节点就是环上的节点,
// 返回时首尾同名,方便错误信息直接读出环路。
func findCycle(modules []Module, index map[string]Module) []string {
	const (
		white = 0
		gray  = 1
		black = 2
	)
	color := make(map[string]int, len(modules))
	stack := make([]string, 0, len(modules))

	var dfs func(name string) []string
	dfs = func(name string) []string {
		color[name] = gray
		stack = append(stack, name)
		deps := append([]string(nil), index[name].DependsOn...)
		sort.Strings(deps)
		for _, dep := range deps {
			switch color[dep] {
			case gray:
				start := 0
				for i, n := range stack {
					if n == dep {
						start = i
						break
					}
				}
				cycle := append([]string(nil), stack[start:]...)
				return append(cycle, dep)
			case white:
				if c := dfs(dep); len(c) > 0 {
					return c
				}
			}
		}
		stack = stack[:len(stack)-1]
		color[name] = black
		return nil
	}

	for _, m := range modules {
		if color[m.Name] == white {
			if c := dfs(m.Name); len(c) > 0 {
				return c
			}
		}
	}
	return nil
}

Validate 不尝试“修复”图,只负责拒绝。重复模块名会让依赖指向两个节点,缺失依赖会让启动顺序无法确定,环会让拓扑序不存在;这三类问题都必须在启动前失败,而不是在运行时靠重试绕开。

Order 先用最长依赖链计算层级,再按“层级升序、同层名字升序”排序。这样 account 和 audit 这类无依赖模块总在同一层,名字小的先出现;profile 在 account 之后,bff 在 profile 之后。同一张图无论模块切片怎么打乱,输出都一致,测试和部署脚本才能稳定比较。

findCycle 的三色标记还有一个实际好处:错误信息会给出完整环路,例如 account -> bff -> profile -> account,而不是只说“存在循环依赖”。值班的人可以直接按环路找到那几条反向调用,不需要自己把图再画一遍。

测试:重复、缺依赖与环

测试要覆盖的不只是“正常图能通过”,更要覆盖每一种让边界失效的输入。

// file: internal/baseline/module_test.go
// tier: L1
// verify: go test ./internal/baseline/

package baseline

import (
	"reflect"
	"strings"
	"testing"
)

func TestValidateAcceptsAcyclicGraph(t *testing.T) {
	modules := []Module{
		{Name: "account"},
		{Name: "profile", DependsOn: []string{"account"}},
		{Name: "bff", DependsOn: []string{"account", "profile"}},
	}
	if err := Validate(modules); err != nil {
		t.Fatalf("Validate() error = %v", err)
	}
}

func TestValidateRejectsDuplicateModule(t *testing.T) {
	err := Validate([]Module{{Name: "account"}, {Name: "account"}})
	if err == nil || !strings.Contains(err.Error(), "duplicate module") {
		t.Fatalf("Validate() error = %v, want duplicate module", err)
	}
}

func TestValidateRejectsMissingDependency(t *testing.T) {
	err := Validate([]Module{{Name: "bff", DependsOn: []string{"profile"}}})
	if err == nil || !strings.Contains(err.Error(), "unknown module") {
		t.Fatalf("Validate() error = %v, want unknown module", err)
	}
}

func TestValidateReportsNodesOnCycle(t *testing.T) {
	err := Validate([]Module{
		{Name: "bff", DependsOn: []string{"profile"}},
		{Name: "profile", DependsOn: []string{"account"}},
		{Name: "account", DependsOn: []string{"bff"}},
	})
	if err == nil {
		t.Fatal("Validate() error = nil, want cycle")
	}
	for _, name := range []string{"account", "bff", "profile"} {
		if !strings.Contains(err.Error(), name) {
			t.Fatalf("cycle error %q does not mention %q", err, name)
		}
	}
	if strings.Count(err.Error(), "->") != 3 {
		t.Fatalf("cycle error %q does not close the loop", err)
	}
}

func TestOrderSortsSameLayerByName(t *testing.T) {
	modules := []Module{
		{Name: "bff", DependsOn: []string{"profile", "account"}},
		{Name: "profile", DependsOn: []string{"account"}},
		{Name: "account"},
		{Name: "audit"},
	}
	got, err := Order(modules)
	if err != nil {
		t.Fatalf("Order() error = %v", err)
	}
	want := []string{"account", "audit", "profile", "bff"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("Order() = %v, want %v", got, want)
	}
}

func TestOrderIsRepeatableAcrossInputOrder(t *testing.T) {
	forward := []Module{
		{Name: "zulu"},
		{Name: "alpha", DependsOn: []string{"zulu"}},
		{Name: "bravo", DependsOn: []string{"zulu"}},
	}
	backward := []Module{forward[2], forward[1], forward[0]}
	first, err := Order(forward)
	if err != nil {
		t.Fatalf("Order(forward) error = %v", err)
	}
	second, err := Order(backward)
	if err != nil {
		t.Fatalf("Order(backward) error = %v", err)
	}
	want := []string{"zulu", "alpha", "bravo"}
	if !reflect.DeepEqual(first, want) || !reflect.DeepEqual(second, want) {
		t.Fatalf("orders = %v and %v, want %v", first, second, want)
	}
}

func TestOrderEmptyGraph(t *testing.T) {
	got, err := Order(nil)
	if err != nil {
		t.Fatalf("Order(nil) error = %v", err)
	}
	if len(got) != 0 {
		t.Fatalf("Order(nil) = %v, want empty", got)
	}
}

TestValidateReportsNodesOnCycle 不只检查“返回了错误”,还逐个确认环路里的三个名字都出现在错误信息中,并且 -> 出现三次。少了任何一个节点,值班人员就无法从日志还原环。

TestOrderIsRepeatableAcrossInputOrder 把同一组模块正序和逆序各跑一次,要求结果完全相同。拓扑序如果依赖 map 遍历顺序,今天和明天可能不同,部署脚本的 diff 会变成噪声,灰度批次也会莫名变化。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/baseline/ -count=1 -v -run 'TestValidate|TestOrder'

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestValidateAcceptsAcyclicGraph
--- PASS: TestValidateAcceptsAcyclicGraph (0.00s)
=== RUN   TestValidateRejectsDuplicateModule
--- PASS: TestValidateRejectsDuplicateModule (0.00s)
=== RUN   TestValidateRejectsMissingDependency
--- PASS: TestValidateRejectsMissingDependency (0.00s)
=== RUN   TestValidateReportsNodesOnCycle
--- PASS: TestValidateReportsNodesOnCycle (0.00s)
=== RUN   TestOrderSortsSameLayerByName
--- PASS: TestOrderSortsSameLayerByName (0.00s)
=== RUN   TestOrderIsRepeatableAcrossInputOrder
--- PASS: TestOrderIsRepeatableAcrossInputOrder (0.00s)
=== RUN   TestOrderEmptyGraph
--- PASS: TestOrderEmptyGraph (0.00s)
PASS
ok  	webapp/internal/baseline	0.226s

0.226s 是一次本机运行的耗时,会随机器和负载变化,不能当成性能结论。-run 'TestValidate|TestOrder' 只匹配模块图相关的测试,不会执行同包的端到端和基线测试。

小结

账号、资料和 BFF 的边界,不是三个目录,而是三组可执行的所有权声明:账号拥有身份与会话,资料拥有可见性与个人信息,BFF 拥有按端组装的响应契约。BFF 可以读账号模块公开的投影,但不能直接读账号库;资料模块可以依赖账号 ID,但不能依赖账号表结构。

模块图要无环、依赖要存在、名字要唯一,拓扑序要稳定。把这些规则写成 Validate 和 Order,边界才会在每次构建和每次新增依赖时被检查,而不是等一次越权读取把问题送到用户面前。

下一节把视角从模块图拉到用户旅程:单测全绿不代表注册、登录、改资料、踢旧设备这条链真的能走通。

阅读导航:上一节:目录与学习路径 · 下一节:13.2 端到端用例与测试 。

常见错误与边界

  • 把目录当边界。代码分包但共用一张表或一个 models 包,边界只存在于目录名里。
  • BFF 直接 join 账号库。今天是只读一个时间字段,明天就会有人加手机号。
  • 用“只读”为越权开脱。读凭据、会话和敏感资料不是耦合问题,是权限问题。
  • 依赖表结构而不是契约。账号库一迁移,所有调用方被迫同步发布。
  • 允许模块图出现环。环会让启动顺序、发布顺序和故障传播都无法独立判断。
  • 拓扑序依赖 map 遍历。同一张图每次输出不同,部署 diff 和灰度批次都失去意义。
  • 只校验“能启动”,不校验依赖声明。新增反向调用不会失败,直到线上出现循环调用。
  • 把 Owns 当装饰。拥有关系如果不进入评审和测试,就只是注释。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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