🔍 登录 注册

golangci-lint 工程化 让静态检查在 CI 里闭环

打 打工人 2026-10-23 👁 1 ⏱ 5min

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 个规则然后一周内被关掉有效得多。

读完了?给作者一点支持

本文采用 CC BY 4.0 协议转载 · © 打工人

评论(0)

登录 后参与评论。

  • 还没有评论,来抢沙发。

🔗 相关文章