《后端架构设计·架构基础与边界》8.3 架构适应度函数:把边界写成测试

架构约束失效往往不是没人知道,而是没人执行。本篇把 arch-lab 的包边界写成 tools/fitfunc:用 go/parser 扫描依赖、类型命名、契约测试存在性和文件预算,违规时退出码非零;再给出干净树与故意改坏的树两次真实运行输出,并说明它与 deplint、依赖测试的分工。

适应度函数不是又一套 lint

站内 架构适应度函数 已经讲过适应度函数的分类,以及 Java 的 ArchUnit、Python 的 import-linter 如何接入 CI。这里不重复那些工具,只回答 arch-lab 的具体问题:一个只用标准库的 Go 项目,怎样把“领域包不能依赖适配器”“适配器必须有契约测试”这类边界变成一条命令。

判断标准只有三个:可执行、可定位、可失败。可执行意味着不依赖人解读文档;可定位意味着输出能直接指到包和规则;可失败意味着违规时退出码非零,CI 不需要解析自然语言。做不到这三点的“架构规范”,本质上还是注释。

四条规则,对应四个边界

规则边界违规意味着什么
domain-no-adapters领域与端口不依赖适配器依赖方向被反转,领域开始感知数据库
domain-no-suffix领域类型不用 DTO/Impl/Manager 后缀技术分层词汇渗进领域模型,边界开始模糊
adapter-contract-test每个适配器包有契约测试端口语义只有一份实现,换实现时无人证明等价
domain-file-budget领域单文件不超过 300 行聚合或状态机正在膨胀,职责可能没有切开

规则不是越多越好。每加一条,都要能回答“谁负责修、什么情况下可以豁免、豁免什么时候到期”。没有 owner 的规则会在第一次赶工时被注释掉,然后再也没恢复。

用标准库解析,而不是 grep

grep -r "internal/adapters" internal/order 看起来更简单,但它会把注释、字符串和文档示例里的 import 算成违规,也会漏掉 order -> ports -> adapters 这种间接路径。go list -deps 能看传递闭包,却要求模块能编译;一个刚改坏 import 的树,恰恰是最需要检查的时候。

所以 fitfunc 用 go/parser 做静态扫描:它只认真正的 import 声明和类型声明,不误判注释;它不要求类型检查通过,坏树也能扫;它还能识别测试文件里的函数名,用来检查契约测试是否存在。代价是它看不到构建标签和生成代码,这部分留给 go list 和编译器,边界要写清楚。

代码

load 负责目录遍历与 AST 解析,Check 是纯函数,main 只做参数与退出码。

// file: tools/fitfunc/main.go
// tier: L1
// verify: go test ./tools/fitfunc/ && go run ./tools/fitfunc -root . -module archlab
package main

import (
	"flag"
	"fmt"
	"go/ast"
	"go/parser"
	"go/token"
	"io/fs"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
)

// pkg 是静态扫描得到的包信息。这里刻意不用 go list:
// 适应度函数要能扫一个还没编译通过的坏树,也要能在 CI 之外独立运行。
type pkg struct {
	Path      string
	Imports   []string
	Types     []string
	TestFuncs []string
	FileLines map[string]int
}

type Rule struct {
	Name     string
	Kind     string // forbidden_import | forbidden_type | required_test | file_budget
	Packages string // 作用在包导入路径上的正则
	Pattern  string // 作用在 import、类型名或测试函数名上的正则
	Budget   int    // 单文件行数上限
}

type Violation struct {
	Rule, Pkg, Detail string
}

func defaultRules() []Rule {
	return []Rule{
		{"domain-no-adapters", "forbidden_import", `^archlab/internal/(order|ports)$`, `^archlab/internal/adapters/`, 0},
		{"domain-no-suffix", "forbidden_type", `^archlab/internal/order$`, `(DTO|Impl|Manager)$`, 0},
		{"adapter-contract-test", "required_test", `^archlab/internal/adapters/`, `^Test.*Contract$`, 0},
		{"domain-file-budget", "file_budget", `^archlab/internal/(order|ports)$`, ``, 300},
	}
}

func load(root, module string) ([]pkg, error) {
	root, err := filepath.Abs(root)
	if err != nil {
		return nil, err
	}
	fset := token.NewFileSet()
	byDir := map[string]*pkg{}
	err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
		if walkErr != nil {
			return walkErr
		}
		if d.IsDir() {
			switch d.Name() {
			case ".git", "vendor", "node_modules", ".local":
				return filepath.SkipDir
			case "testdata":
				if path != root {
					return filepath.SkipDir
				}
			}
			return nil
		}
		if !strings.HasSuffix(path, ".go") {
			return nil
		}
		dir := filepath.Dir(path)
		p := byDir[dir]
		if p == nil {
			rel, relErr := filepath.Rel(root, dir)
			if relErr != nil {
				return relErr
			}
			p = &pkg{Path: module, FileLines: map[string]int{}}
			if rel != "." {
				p.Path = module + "/" + filepath.ToSlash(rel)
			}
			byDir[dir] = p
		}
		f, parseErr := parser.ParseFile(fset, path, nil, 0)
		if parseErr != nil {
			return parseErr
		}
		for _, spec := range f.Imports {
			if imp, unqErr := strconv.Unquote(spec.Path.Value); unqErr == nil {
				p.Imports = append(p.Imports, imp)
			}
		}
		for _, decl := range f.Decls {
			switch d := decl.(type) {
			case *ast.GenDecl:
				for _, spec := range d.Specs {
					if ts, ok := spec.(*ast.TypeSpec); ok {
						p.Types = append(p.Types, ts.Name.Name)
					}
				}
			case *ast.FuncDecl:
				if strings.HasSuffix(path, "_test.go") && d.Recv == nil {
					p.TestFuncs = append(p.TestFuncs, d.Name.Name)
				}
			}
		}
		data, readErr := os.ReadFile(path)
		if readErr != nil {
			return readErr
		}
		p.FileLines[filepath.Base(path)] = strings.Count(string(data), "\n") + 1
		return nil
	})
	if err != nil {
		return nil, err
	}
	out := make([]pkg, 0, len(byDir))
	for _, p := range byDir {
		out = append(out, *p)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
	return out, nil
}

// Check 是纯函数:输入包列表与规则,输出违规。文件系统只出现在 load 里。
func Check(pkgs []pkg, rules []Rule) []Violation {
	var out []Violation
	for _, r := range rules {
		pkgRe, err := regexp.Compile(r.Packages)
		if err != nil {
			out = append(out, Violation{r.Name, "", "bad package regexp: " + err.Error()})
			continue
		}
		patRe, err := regexp.Compile(r.Pattern)
		if err != nil {
			out = append(out, Violation{r.Name, "", "bad pattern regexp: " + err.Error()})
			continue
		}
		for _, p := range pkgs {
			if !pkgRe.MatchString(p.Path) {
				continue
			}
			switch r.Kind {
			case "forbidden_import":
				for _, imp := range p.Imports {
					if patRe.MatchString(imp) {
						out = append(out, Violation{r.Name, p.Path, "imports " + imp})
					}
				}
			case "forbidden_type":
				for _, typ := range p.Types {
					if patRe.MatchString(typ) {
						out = append(out, Violation{r.Name, p.Path, "type " + typ})
					}
				}
			case "required_test":
				found := false
				for _, fn := range p.TestFuncs {
					found = found || patRe.MatchString(fn)
				}
				if !found {
					out = append(out, Violation{r.Name, p.Path, "missing test matching " + r.Pattern})
				}
			case "file_budget":
				for file, lines := range p.FileLines {
					if lines > r.Budget {
						out = append(out, Violation{r.Name, p.Path, fmt.Sprintf("%s has %d lines > %d", file, lines, r.Budget)})
					}
				}
			default:
				out = append(out, Violation{r.Name, p.Path, "unknown rule kind " + r.Kind})
			}
		}
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Rule != out[j].Rule {
			return out[i].Rule < out[j].Rule
		}
		if out[i].Pkg != out[j].Pkg {
			return out[i].Pkg < out[j].Pkg
		}
		return out[i].Detail < out[j].Detail
	})
	return out
}

func main() {
	root := flag.String("root", ".", "module root to scan")
	module := flag.String("module", "archlab", "module import path")
	flag.Parse()

	pkgs, err := load(*root, *module)
	if err != nil {
		fmt.Fprintln(os.Stderr, "fitfunc:", err)
		os.Exit(2)
	}
	rules := defaultRules()
	violations := Check(pkgs, rules)
	if len(violations) != 0 {
		fmt.Printf("fitfunc: %d violation(s) in %d package(s)\n", len(violations), len(pkgs))
		for _, v := range violations {
			fmt.Printf(" - [%s] %s: %s\n", v.Rule, v.Pkg, v.Detail)
		}
		os.Exit(1)
	}
	fmt.Printf("fitfunc: ok (%d rules, %d packages)\n", len(rules), len(pkgs))
}

几个实现细节值得说明。包路径由 -module 加相对目录拼接,不依赖 go.mod 解析;遇到 testdata 目录会跳过,除非它本身就是扫描根,这样坏例子可以被单独扫描而不会污染主树。违规结果按规则名、包、详情排序,CI 日志因此稳定可 diff。

测试

规则引擎不碰文件系统,测试直接构造包结构,跑得快,也不需要临时目录。

// file: tools/fitfunc/main_test.go
// tier: L1
// verify: go test ./tools/fitfunc/ -v
package main

import "testing"

func testRules() []Rule {
	return []Rule{
		{"no-adapters", "forbidden_import", `^archlab/internal/order$`, `^archlab/internal/adapters/`, 0},
		{"no-dto", "forbidden_type", `^archlab/internal/order$`, `(DTO|Impl)$`, 0},
		{"contract", "required_test", `^archlab/internal/adapters/`, `^Test.*Contract$`, 0},
		{"budget", "file_budget", `^archlab/internal/order$`, ``, 100},
	}
}

func TestCheckForbiddenImport(t *testing.T) {
	pkgs := []pkg{{Path: "archlab/internal/order", Imports: []string{"archlab/internal/adapters/memory"}}}
	got := Check(pkgs, testRules())
	if len(got) != 1 || got[0].Rule != "no-adapters" {
		t.Fatalf("got %+v", got)
	}
}

func TestCheckForbiddenType(t *testing.T) {
	pkgs := []pkg{{Path: "archlab/internal/order", Types: []string{"OrderDTO", "Order"}}}
	got := Check(pkgs, testRules())
	if len(got) != 1 || got[0].Detail != "type OrderDTO" {
		t.Fatalf("got %+v", got)
	}
}

func TestCheckRequiredContractTest(t *testing.T) {
	missing := []pkg{{Path: "archlab/internal/adapters/memory", TestFuncs: []string{"TestSave"}}}
	if got := Check(missing, testRules()); len(got) != 1 || got[0].Rule != "contract" {
		t.Fatalf("missing: %+v", got)
	}
	present := []pkg{{Path: "archlab/internal/adapters/memory", TestFuncs: []string{"TestOrderRepoContract"}}}
	if got := Check(present, testRules()); len(got) != 0 {
		t.Fatalf("present: %+v", got)
	}
}

func TestCheckFileBudget(t *testing.T) {
	pkgs := []pkg{{Path: "archlab/internal/order", FileLines: map[string]int{"order.go": 101}}}
	got := Check(pkgs, testRules())
	if len(got) != 1 || got[0].Rule != "budget" {
		t.Fatalf("got %+v", got)
	}
}

四个测试对应四类规则,其中契约测试用例同时验证“缺失时失败”和“存在时通过”。这比只测失败路径更重要:一条永远失败的规则和一条永远通过的规则,在 CI 里看起来都只是噪声。

真实运行结果

在 arch-lab 模块根目录(internal/order、internal/ports、internal/adapters/memory 和 tools/fitfunc 四个包)运行:

$ go run ./tools/fitfunc -root . -module archlab
fitfunc: ok (4 rules, 4 packages)

把 internal/order/order.go 改成一个导入适配器、并声明 OrderDTO 的坏包后,再跑一次:

$ go run ./tools/fitfunc -root ./testdata/bad -module archlab
fitfunc: 2 violation(s) in 1 package(s)
 - [domain-no-adapters] archlab/internal/order: imports archlab/internal/adapters/memory
 - [domain-no-suffix] archlab/internal/order: type OrderDTO
exit status 1

两条违规分别来自 import 和类型声明,退出码为 1。CI 只需要判断退出码,不需要 grep 输出;开发者从规则名就能找到对应的修复动作。注意这里没有编译坏树,go/parser 只解析语法,所以 import 指向一个不存在的包也能报出来。

与 deplint、契约测试的分工

1.3 的 deplint 检查包前缀层面的层间方向,4.2 的依赖测试检查 go list -deps 的传递闭包,本篇的 fitfunc 检查可配置规则、测试存在性和文件预算。三者回答的问题不同:deplint 回答“这行 import 是否越层”,依赖测试回答“编译图里有没有适配器”,fitfunc 回答“团队约定的边界是否仍然成立”。

不要用一个工具替代另一个。fitfunc 能检查命名和测试存在性,但它不做类型检查,也不理解泛型;deplint 快而准,但规则写死在代码里;依赖测试最接近编译器,却要求模块可构建。把它们放在同一条 CI 流水线里,失败时各自给出不同粒度的定位。

接入 CI 与豁免

流水线里只需要一行:

$ go run ./tools/fitfunc -root . -module archlab

它应该和单元测试并列,而不是放在发布前的最后一步。越早失败,修复成本越低。规则本身也要有测试,否则一条正则写错,整条规则会静默通过。

豁免必须显式:要么在规则表里加一条带到期日的临时规则,要么在代码里写 //fitfunc:ignore <rule> <reason> 并由 fitfunc 解析。不要用“把规则注释掉”当豁免——那等于删除边界,而且没人会记得恢复。文件行数预算是静态预算;真正的性能预算应该放进基准测试,例如用 testing.AllocsPerRun 给热路径设上限,两者不要混为一谈。

常见错误与边界

第一,用 grep 实现依赖规则。注释、字符串和示例代码都会造成误报,间接依赖又会漏报;用 AST 或 go list,不要用文本匹配。

第二,规则没有测试。一条写错的正则可能永远通过,CI 会给人“边界已被守护”的错觉;每条规则至少要有正例和反例。

第三,把适应度函数做成一次性脚本。它必须进 CI、有 owner、有豁免流程,否则第一次赶工就会绕过。

第四,忽略扫描器的边界。fitfunc 不做类型检查、不展开构建标签、不分析生成代码;这些盲区要用 go build、go vet 和依赖测试补上,而不是假设它什么都能查。

小结

适应度函数的价值不在于规则数量,而在于它让边界在每次提交时都被执行一次。arch-lab 的 fitfunc 用标准库解析包结构,把依赖方向、命名、契约测试和文件预算变成四条可测试的规则;干净树输出 ok,坏树输出具体违规并以非零码退出。到这里,卷一的“边界”从文档、目录和接口,落成了三条可运行的命令:deplint、依赖测试和 fitfunc。下一章转向组织:边界不仅由代码决定,也由团队的沟通结构决定。

阅读导航:上一节:8.2 分支抽象与双写/双读 · 下一节:9.1 康威定律与团队拓扑 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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