写 Go linter 不难,难的是让团队用起来

用 go/analysis 框架写一个自定义 linter 只要 20 行代码,但从第一行代码到团队平稳运行花了两个月。三组实测数据展示 go/ast 的能力边界,以及比技术更难的落地困境。

封面

团队 code review 里有一类问题特别烦人——不是 bug,是规范。

比如我们团队有条规矩:Handler 层不准直接调 Repository 层,必须走 Service 层。道理都懂,但每次 review 都有人犯。reviewer 指出来,开发者改了;下次 review 又出现。你不好意思次次说,说多了像念经。

有天我想:这种重复性的规范检查,能不能让 linter 自动做?写个自定义 linter,CI 里跑一遍,违规的直接拦住。

听起来挺美。做起来才知道,写出 linter 只是故事的开头。本文从一个函数长度检查起步,走过 go/ast 的能力边界和 go/types 的升级,最终撞上了比技术更难的问题:怎么让团队真正用起来。

角色盯着终端的 linter 警告

一、先从最简单的开始

动手之前先试试水。我选了一个最简单的需求:检查函数长度是否超过 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.Printlnfmt.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

go/ast vs go/types 精度对比

精确率翻倍,假阳性从 8 个降到 2 个。go/types 让 linter 从"看到什么都喊"变成"只喊该喊的"。

架构分层检查呢?

三、用 go/types 做架构分层检查

回到最初的需求:“Handler 层不准直接调 Repository 层。“和 error 检查一样,纯 go/ast 方案只能靠字符串匹配 import alias 和变量名,漏检严重。

测试项目里我设计了三种违规场景:

  1. 直接实例化 repo.UserRepo{}(用了 import alias repo
  2. 通过 struct 字段 h.repoObj.FindByID() 调用
  3. 通过接口字段 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{} 这类直接构造。但核心思路不变:不依赖字符串匹配,靠类型系统追踪包路径。

写到这里,代码层面的问题解决了。

但故事才到一半。

四、让团队用起来,比写代码难

集成 golangci-lint 的积木拼图

linter 写好了,下一步是集成到 CI。

golangci-lint v2(2024 年发布,注意与 v1 的 plugin 机制完全不同)提供了 module 插件机制,理论上只要在 .golangci.yml 里加几行配置就能集成。但"理论上"三个字在工程里从来不可靠。

版本对齐是第一个拦路虎——go.modgolang.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 的三阶段漫画

上线头几天,linter 拦截了几个分层违规的 MR。团队很开心:“终于不用每次 review 都指出这个问题了。“得到正反馈后,规则从 1 条扩展到 5 条——加了函数长度、命名规范、日志格式。

到了第三周左右,CI 失败率飙升。不是代码真有问题,是规则太严。“我这个工具函数就是要 100 行,拆开反而更难读。““这个命名是历史遗留,你让我改所有调用方?“有人开始在配置里给自己的包加 exclude。误报率飙升到团队无法忍受的程度。

规则是给团队定的,但例外也是团队要的。 每个人都认同"代码要规范”,但每个人都觉得自己的情况是"合理的例外”。

一个多月后调整完,形成了稳态:

  1. 砍规则:从 5 条砍到 2 条(只保留架构分层 + 错误处理),其余降级为 warning
  2. 加白名单:允许 //nolint:archlayer 注释跳过,但必须写注释说明原因
  3. 让团队参与:规则的 PR 发到团队群讨论,不是一个人说了算

自定义 linter 的落地曲线不是线性上升,是先涨后跌再稳的 S 型。

决策树:什么时候该写自定义 linter

什么时候该写自定义 linter

我后来的经验是:不是所有规范都值得自动化。

值得写 linter 的,是那些能用代码精确表达的规则——“Handler 不准调 Repository"就是典型。违规是二元的,要么违反要么不违反,没有模糊地带。而且同一个问题 review 里反复出现,reviewer 说了三次以上还在犯。

不值得写的,是那些涉及主观判断的规则。“这个函数太复杂了”——复杂度因人而异,80 行对工具函数可能是合理的,对 handler 方法就太长了。这种灰色地带最多的规范,用 code review 讨论反而比 linter 自动拦截更有效。

写 linter 的成本不只是写代码。是版本维护、误报调优、团队沟通、规则演进的长期投入。对一个十人团队来说,两个月的开发换来每次 review 少花五分钟——回本需要半年,但省下的不是时间,是 reviewer 的耐心。

开头那个问题——“Handler 不准直接调 Repository”——最终确实用 linter 解决了。CI 里跑着,误报率控制在可接受范围内,reviewer 不再需要念经。但从第一行代码到团队平稳运行,中间经历了技术选型的撞墙、集成的踩坑、团队推广的反复。回头看,代码本身反而是最顺利的那部分。

自定义 linter 生存指南


附录:实验代码和原始数据

本文 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.modgo build 后可直接运行。


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →