golangci-lint 工程化 让静态检查在 CI 里闭环
go vet 加 gofmt 是起点,但拦不住 nil map 写入、错误被忽略、context 未传递这类真实引发事故的问题。golangci-lint 的价值在于聚合几十种分析器,但更关键的是如何配置它,让它在 CI 里长期存在而不被人绕过。多数团队失败在两步:一次开太多规则导致噪音淹没一切,以及全量检查让老项目永远无法通过。
一、基线配置
# .golangci.yml
run:
timeout: 5m
tests: true
linters:
disable-all: true
enable:
- errcheck
- govet
- staticcheck
- unused
- gosimple
- ineffassign
- typecheck
- contextcheck
- errorlint
- nilerr
- bodyclose
- sqlclosecheck
- exhaustive
- goconst
- gocritic
- revive
- misspell
- prealloc
issues:
max-issues-per-linter: 0
max-same-issues: 0
exclude-rules:
- path: _test\.go
linters: [errcheck, gosec]
- path: "mocks/"
linters: [all]
linters-settings:
govet:
enable-all: true
revive:
rules:
- name: exported
disabled: true
errcheck:
check-type-assertions: true
gocritic:
enabled-tags: [diagnostic, performance]
规则要分档。第一档是能抓真 bug 的:govet、staticcheck、errcheck、nilerr、bodyclose、sqlclosecheck、contextcheck、errorlint、ineffassign。第二档是风格类,比如 revive、gocritic 的 opinionated 检查,等第一档稳定跑通一年再逐步打开。stylecheck 与 gofumpt 这类纯格式问题交给 gofmt 与提交前 hook,别混进 CI 主检查。
二、老项目落地:只检查增量
一次性修完全仓历史问题是最大阻力,正确做法是存量豁免、增量必填。CI 里只针对本次改动报错:
- name: golangci-lint (changed only)
uses: golangci/golangci-lint-action@v6
with:
version: v1.61
only-issues-files: "$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- '*.go')"
如果平台不支持按文件过滤,可以用 --new-from-rev 只报相对基线提交的新问题:
golangci-lint run --new-from-rev=origin/main
这条参数是古法落地的关键,它使得"存量不动、新增必清"成为可能,通常第一天就能通过,阻力最小。
另一条路是一次性生成基线并逐期削减:
golangci-lint run --output-json baseline.json
# .golangci.yml 中通过 issues.exclude-rules 精确定向豁免,并挂一个数量上限指标
关键是基线只能变小不能变大,把"当前问题数"当作指标看板追踪。
三、几条真正防事故的规则
值得单独强调四个常被忽略的检查器。
errcheck:忽略返回值最常见的形态是 defer f.Close()(可接受)与 json.Unmarshal(不可接受)。用白名单精确放行,而不是全局关掉:
// 明确表达"我知道这里忽略错误"
_ = f.Close()
bodyclose:HTTP 响应体没关会泄漏连接,比想象中常见。
resp, err := client.Do(req)
if err != nil { return err }
defer resp.Body.Close() // 漏掉这里,bodyclose 会报
contextcheck / 参数传递:捕获"新建 context 断开调用链""context 存在 struct 里"两类问题。后者是 Go 明确禁止的写法。
exhaustive:枚举 switch 漏分支时报错。加状态机新状态时能立刻找到全仓需要改的位置——配合状态机改造特别有用。
//exhaustive:enforce
func label(s Status) string {
switch s {
case StatusDraft: return "草稿"
case StatusPendingReview: return "待审核"
case StatusPublished: return "已发布"
// 少了 rejected,exhaustive 直接报错
}
}
四、本地与 CI 必须同源
本地和 CI 行为不一致会让检查失去信任。三个要求:版本固定(不要 latest)、配置文件唯一、命令一致。
LINT_VERSION := v1.61.0
lint:
docker run --rm -v $(PWD):/src -w /src golangci/golangci-lint:$(LINT_VERSION) \
golangci-lint run --timeout 5m
lint-fix:
docker run --rm -v $(PWD):/src -w /src golangci/golangci-lint:$(LINT_VERSION) \
golangci-lint run --fix
用 Docker 固定运行环境能消掉"本机 go 版本不同导致结果不同"的问题。pre-commit 只跑改动文件的快速检查:
golangci-lint run $(git diff --name-only --diff-filter=ACM HEAD -- '*.go' | xargs -r dirname | sort -u)
全量检查放提交前 hook 会让人直接绕过 hook,这是最常见的失效原因。
五、禁用注释的三条纪律
//nolint 是必需品,但滥用等于没有检查。三条规矩:必须带理由,禁止裸写;必须指定规则名,禁用 nolint:all(会把后来新开的规则一起屏蔽掉);数量进看板,逐季度削减。
res, _ := db.Exec(q) //nolint:errcheck // 只需要 affected rows,失败由下一步兜底
CI 里加一条对 nolint:all 的检查,直接拒绝:
if grep -rn "nolint:all\|//nolint$" --include="*.go" .; then
echo "禁止无规则的 nolint"; exit 1
fi
六、把 lint 指标化
让检查持续有效的手段是把它变成趋势指标,而不是每次单独看有没有红:
golangci-lint run --out-format=json | jq '[.Issues[]] | length'
按 linter 分组统计与按提交追踪,配合看板可以看到"errcheck 报错从 120 降到 8"。有下降曲线,团队才愿意继续投入;只有红绿灯,人会想办法让灯变绿而不是让代码变好。
同理值得加进 CI 的还有 go vet -all(比 golangci 的 govet 覆盖更全的参数)、以及 staticcheck 的更严格 profile(--checks="all,-ST*")——但只在主检查稳定后再叠加。
一个反直觉但有效的建议:把 --fix 也放进 CI 自动提交(仅限格式化类),能省掉大量无意义评审往返。
小结
让 golangci-lint 长期有效的关键是治理方式而非规则数量:分档启用(先 bug 类后风格类)、老项目用 --new-from-rev 只卡增量、本地与 CI 版本与配置同源、nolint 必须带理由并限量、问题总数做成下降指标。先跑通一条不会误报的红线,再逐步加严,比第一天开 40 个规则然后一周内被关掉有效得多。
评论(0)
登录 后参与评论。