适应度函数不是又一套 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 康威定律与团队拓扑 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。