
团队 code review 里有一类问题特别烦人——不是 bug,是规范。
比如我们团队有条规矩:Handler 层不准直接调 Repository 层,必须走 Service 层。道理都懂,但每次 review 都有人犯。reviewer 指出来,开发者改了;下次 review 又出现。你不好意思次次说,说多了像念经。
有天我想:这种重复性的规范检查,能不能让 linter 自动做?写个自定义 linter,CI 里跑一遍,违规的直接拦住。
听起来挺美。做起来才知道,写出 linter 只是故事的开头。本文从一个函数长度检查起步,走过 go/ast 的能力边界和 go/types 的升级,最终撞上了比技术更难的问题:怎么让团队真正用起来。

一、先从最简单的开始
动手之前先试试水。我选了一个最简单的需求:检查函数长度是否超过 80 行。
Go 标准库的 go/analysis 框架让这件事简单到超出预期——它做的事就是遍历所有函数声明,数花括号之间的行数,超了就报告。核心逻辑不到 20 行:
// [实测 Go 1.26.2]
var Analyzer = &analysis.Analyzer{
Name: "funclength",
Doc: "检查函数体是否超过指定行数",
Run: run,
}
func run(pass *analysis.Pass) (interface{}, error) {
for _, f := range pass.Files {
ast.Inspect(f, func(n ast.Node) bool {
fn, ok := n.(*ast.FuncDecl)
if !ok || fn.Body == nil {
return true
}
start := pass.Fset.Position(fn.Body.Lbrace).Line // 函数体开始行号
end := pass.Fset.Position(fn.Body.Rbrace).Line // 函数体结束行号
length := end - start
if length > maxLines {
pass.Reportf(fn.Name.Pos(),
"函数 %s 有 %d 行,超过上限 %d 行",
fn.Name.Name, length, maxLines)
}
return true
})
}
return nil, nil
}
加上 singlechecker.Main(Analyzer) 一行入口,就是一个完整的命令行工具。跑了一下测试——ShortFunc(5 行)不触发,TooLongFunc(83 行)正确拦截。20 行代码就能做一个 linter,架构分层检查能有多难?
函数长度检查只是试水。真正想自动化的是另一类问题——
二、撞墙:go/ast 的能力边界
检测被忽略的 error 返回值。
代码里经常有 os.Remove(path) 这种写法,函数返回了 error 但被直接丢弃。我用 go/ast 写了个检查器,逻辑很直接:扫描所有函数调用,如果返回值没被接收就报警。
跑出来 12 条警告。测试文件里只有 4 处真正忽略 error 的写法。逐条核对,8 条是假阳性。
问题在哪?go/ast 只看语法结构,不看类型。它看到 strings.Contains(s, "foo") 的返回值没人接,就报警——但这个函数返回的是 bool,不是 error。它看到 doNothing() 也报警,但这个函数根本没有返回值。它看到 _ = f(把 *os.File 赋给下划线)也报警,可被丢弃的根本不是 error。
简单说:go/ast 只知道"这里有个函数调用,返回值没人用",不知道"返回值是什么类型"。
我切到 go/types。同一份测试文件,go/types 方案报了 6 条警告,其中 4 条真阳性、2 条是 fmt.Println。fmt.Println 确实返回 (int, error),技术上讲忽略它的 error 确实不对,但工程上大家默认不处理——生产级 linter 通常需要维护一份白名单来排除这类情况,白名单本身就是维护成本的一部分。
实测对比:
| 方案 | 检出数 | 真阳性 | 假阳性 | 精确率 |
|---|---|---|---|---|
| 纯 go/ast | 12 | 4 | 8 | 33.3% |
| go/ast + go/types | 6 | 4 | 2 | 66.7% |
[实测 Go 1.26.2] 测试文件包含 10 种场景:4 处真正忽略 error + 4 个假阳性诱饵(非 error 返回值)+ 2 个真阴性。完整代码见 GitHub。

精确率翻倍,假阳性从 8 个降到 2 个。go/types 让 linter 从"看到什么都喊"变成"只喊该喊的"。
架构分层检查呢?
三、用 go/types 做架构分层检查
回到最初的需求:“Handler 层不准直接调 Repository 层。“和 error 检查一样,纯 go/ast 方案只能靠字符串匹配 import alias 和变量名,漏检严重。
测试项目里我设计了三种违规场景:
- 直接实例化
repo.UserRepo{}(用了 import aliasrepo) - 通过 struct 字段
h.repoObj.FindByID()调用 - 通过接口字段
h.repoIntf.FindByID()调用
go/ast 方案只检出了 1 个——场景 2,靠的是字段名里包含 “repo” 的字符串匹配。场景 1 漏了,因为 alias 改了名字 AST 遍历时拿到的是复合表达式。场景 3 完全看不到。AST 不知道接口变量 repoIntf 的类型来自 repository 包。
go/types 方案检出了全部 3 个。它通过 types.Info 获取每个调用的接收者类型,追踪到实际的包路径。不管你用什么 alias、什么中间变量、什么接口,包路径骗不了类型系统。
| 方案 | 检出数 | 漏检 | 关键盲区 |
|---|---|---|---|
| 纯 go/ast | 1/3 | 2 处 | alias 间接调用、接口变量调用 |
| go/ast + go/types | 3/3 | 0 | — |
[实测 Go 1.26.2] 测试项目包含 handler/service/repository 三层,3 种违规 + 1 种合规调用,0 漏检 0 误报。测试集较小,大规模项目的数据需要更多验证。完整代码见 GitHub。
核心代码的关键一步:拿到函数调用的接收者类型后,检查它的包路径是否属于 repository 层。
// 类型方案的核心判断(简化,完整版还需处理 info.Uses 和 info.Defs)
func isRepositoryCall(info *types.Info, sel *ast.SelectorExpr) bool {
// 通过类型系统获取调用目标的包路径
selection, ok := info.Selections[sel]
if ok {
recv := selection.Recv()
pkg := extractPackagePath(recv)
return strings.HasSuffix(pkg, "/repository")
}
return false
}
这段只展示了 info.Selections 一层。完整版还需要处理包级引用(info.Uses)和直接实例化(info.Defs),否则只用 Selections 会漏掉 repo.UserRepo{} 这类直接构造。但核心思路不变:不依赖字符串匹配,靠类型系统追踪包路径。
写到这里,代码层面的问题解决了。
但故事才到一半。
四、让团队用起来,比写代码难

linter 写好了,下一步是集成到 CI。
golangci-lint v2(2024 年发布,注意与 v1 的 plugin 机制完全不同)提供了 module 插件机制,理论上只要在 .golangci.yml 里加几行配置就能集成。但"理论上"三个字在工程里从来不可靠。
版本对齐是第一个拦路虎——go.mod 里 golang.org/x/tools 版本必须和 golangci-lint 完全一致,否则编译直接报 module version mismatch。每次 golangci-lint 升级,你的 linter 可能需要同步升级依赖。更烦的是本地跑通了 CI 却挂了,Alpine 镜像缺 CGO 依赖。集成失败时的错误信息全是 Go module 层面的,没有 golangci-lint 上下文,定位问题本身就需要对 Go module 有深入理解。
我最后选了一个更务实的方案:不走 golangci-lint 插件,把 linter 编译为独立二进制,在 CI 的 Makefile 里单独调用。牺牲了 golangci-lint 的聚合报告和编辑器集成,但避开了版本对齐地狱。
集成只是第一关。更大的挑战是:团队真的会用吗?

上线头几天,linter 拦截了几个分层违规的 MR。团队很开心:“终于不用每次 review 都指出这个问题了。“得到正反馈后,规则从 1 条扩展到 5 条——加了函数长度、命名规范、日志格式。
到了第三周左右,CI 失败率飙升。不是代码真有问题,是规则太严。“我这个工具函数就是要 100 行,拆开反而更难读。““这个命名是历史遗留,你让我改所有调用方?“有人开始在配置里给自己的包加 exclude。误报率飙升到团队无法忍受的程度。
规则是给团队定的,但例外也是团队要的。 每个人都认同"代码要规范”,但每个人都觉得自己的情况是"合理的例外”。
一个多月后调整完,形成了稳态:
- 砍规则:从 5 条砍到 2 条(只保留架构分层 + 错误处理),其余降级为 warning
- 加白名单:允许
//nolint:archlayer注释跳过,但必须写注释说明原因 - 让团队参与:规则的 PR 发到团队群讨论,不是一个人说了算
自定义 linter 的落地曲线不是线性上升,是先涨后跌再稳的 S 型。

什么时候该写自定义 linter
我后来的经验是:不是所有规范都值得自动化。
值得写 linter 的,是那些能用代码精确表达的规则——“Handler 不准调 Repository"就是典型。违规是二元的,要么违反要么不违反,没有模糊地带。而且同一个问题 review 里反复出现,reviewer 说了三次以上还在犯。
不值得写的,是那些涉及主观判断的规则。“这个函数太复杂了”——复杂度因人而异,80 行对工具函数可能是合理的,对 handler 方法就太长了。这种灰色地带最多的规范,用 code review 讨论反而比 linter 自动拦截更有效。
写 linter 的成本不只是写代码。是版本维护、误报调优、团队沟通、规则演进的长期投入。对一个十人团队来说,两个月的开发换来每次 review 少花五分钟——回本需要半年,但省下的不是时间,是 reviewer 的耐心。
开头那个问题——“Handler 不准直接调 Repository”——最终确实用 linter 解决了。CI 里跑着,误报率控制在可接受范围内,reviewer 不再需要念经。但从第一行代码到团队平稳运行,中间经历了技术选型的撞墙、集成的踩坑、团队推广的反复。回头看,代码本身反而是最顺利的那部分。

附录:实验代码和原始数据
本文 3 组实验的完整代码已开源:
GitHub:zhiyulab-evidence/go-custom-linter
error-check-compare/:go/ast vs go/types 检测忽略 error 的精度对比(含 10 种测试场景)architecture-linter/:架构分层 linter(handler→repository 违规检测),含 3 种违规 + 1 种合规场景func-length-linter/:基于 go/analysis 框架的最简 linter 示例
每个子目录有独立 go.mod,go build 后可直接运行。