gofmt、goimports、go vet 属于无争议的基础项:格式统一由机器保证,vet 作为官方静态分析工具,能在编译前发现 printf 参数不匹配、锁被拷贝等典型错误。真正的分歧集中在静态分析覆盖不到的区域:被 _ 丢弃的 error、未关闭的 HTTP 响应体、以 err == io.EOF 判断错误、持续增长的函数。项目规模扩大后,这些缺口会转化为 code review 中反复出现且难以收敛的争议:一方主张应当检查错误,另一方声称该行为是有意为之,评审无法达成一致。

社区的事实标准是 golangci-lint:单个二进制聚合 100+ 个 linter,并行执行,以一份 .golangci.yml 统一配置。它的核心价值不在于多发现几个 bug,而在于以配置文件固化团队对"什么是合格代码"的共识,减少评审中的人为争论——"这里是否应当检查错误"不再取决于评审者的个人立场,而以配置为准。

基本原则:规则可以严格,nolint 必须克制。当 lint 配置过严、误报过多时,开发者倾向于用 //nolint 压制告警;数月之后,豁免指令的清理成本会超过告警本身。以下按底线、配置、重点 linter、nolint 纪律、日常工作流的顺序展开。

底线三件套

gofmt/gofumpt、goimports、go vet 应无条件启用,不设例外。 格式化属于机器职责:v2 起 golangci-lint 将格式化器纳入统一入口,golangci-lint fmt 一条命令即可运行 gofmt 与 gofumpt(建议设置 extra-rules: true,比 gofmt 更严格);import 的增删与分组交给 goimports,手工对齐 import 应当避免。govet 负责检查 printf 格式串、锁拷贝、struct tag 校验等人工审查最容易遗漏的问题。

提交前应执行格式化与全量 lint,可自动修复的问题交给 --fix。 日常只需三条命令:

golangci-lint fmt ./...         # 格式化:gofmt + gofumpt
golangci-lint run ./...         # 全量静态检查
golangci-lint run --fix ./...   # 自动修复可修复项

一份 .golangci.yml,三档渐进

每个 Go 项目必须有 .golangci.yml,规则是否生效以该文件为准。 v2 要求文件头声明 version: "2",核心板块共五块:run(并发、超时、目录排除)、linters.enable/disable(启用与停用)、linters.settings(各 linter 的阈值)、formatters(gofmt/gofumpt)、issues(输出限制与豁免规则)。该文件应提交至仓库并纳入评审——linter 的取舍是团队的决定,不应由个别工程师的本地配置代替。运行 golangci-lint linters 可查看当前版本的全部可选 linter。

应按默认、推荐、严格三档渐进,新项目直接采用推荐档。 划档标准只有一条:误报率乘以修复成本。

  • 默认档(底线):govet、errcheck、staticcheck、unused,加上 formatters;golangci-lint v2 的默认启用集还包含 ineffassign,应一并启用。均为官方或准官方工具,误报极少,任何项目从第一天起就应保持全绿。
  • 推荐档(业务项目标配):errorlint、nilerr、forcetypeassert、noctx、bodyclose、rowserrcheck、gocritic、revive、misspell、nolintlint 等。偶有误报,但拦截的均为真实问题。
  • 严格档(按需,新代码优先):复杂度与风格类——gocyclo(阈值 13)、funlen(120 行 / 80 条语句)、nestif、dupl(阈值 100 token)、goconst、wsl_v5、paralleltest。这类规则主观性较强,存量仓库不应立即启用,以避免对现有代码造成过大冲击。

一份精简可用的推荐档配置:

version: "2"

run:
  timeout: 5m              # v2 默认不限时(0),CI 中必须显式设置

linters:
  enable:
    # —— 默认档 ——
    - errcheck      # 漏检的 error 返回值
    - govet         # 官方静态检查器
    - staticcheck   # 过时 API、常见错误、可简化写法
    - unused        # 未使用的变量、函数、类型、字段
    # —— 推荐档 ——
    - errorlint         # 强制 errors.Is/As 与 %w 包装
    - nilerr            # err 非空却返回 nil,静默失败的高发场景
    - forcetypeassert   # 无 comma-ok 的类型断言
    - noctx             # 不带 context 的 HTTP 请求
    - bodyclose         # HTTP 响应体未关闭,导致连接泄漏
    - rowserrcheck      # 遍历后未检查 sql.Rows.Err()
    - misspell          # 注释/标识符拼写错误
    - nolintlint        # 约束 nolint 的使用,见下文
  settings:
    errcheck:
      check-type-assertions: true   # 类型断言漏检同样视为错误

formatters:
  
     
  
    
       

停用 linter 必须移入 linters.disable 并注明原因。 直接删除会使后续维护者无法区分这是经过评估的决定还是临时处理;写明理由后,停用即成为可评审的决定。linter 之间规则冲突时同理:停用价值较低的一方,并说明保留另一方的理由。

建议优先启用的十二个 linter

linter 总数超过一百个,而日常拦截价值高度集中。正确性与资源类应优先于纯风格类——前者拦截 bug,后者只约束分歧。以下 12 个覆盖正确性、资源泄漏与高频风格问题,建议直接纳入推荐档:

linter拦截内容典型案例
errcheck漏检的 errordata, _ := os.ReadFile(...)
govet官方内置检查器printf 格式串与参数不匹配、锁被拷贝
staticcheck常见错误与过时 API调用已废弃函数、多余死代码
errorlint错误判断方式err == io.EOF 应为 errors.Is
nilerr静默失败if err != nil { return nil }
forcetypeassert裸类型断言 panicv := x.(T) 应为 v, ok := x.(T)
gocritic冗余低效写法range 大结构体按值拷贝
revive命名与导出规范导出符号缺注释、命名不合约定
misspell拼写错误注释里的 sucess、seperate
noctx无 context 的请求http.Get(...) 应改用带 ctx 的请求
bodyclose连接泄漏resp.Body 未 Close
rowserrcheck漏掉迭代错误遍历 rows 后不检查 rows.Err()

其中错误处理三项(errcheck、errorlint、nilerr)与本系列错误链规范一一对应,背景可参阅 错误处理规范;revive 的命名检查,则相当于将 命名与代码风格 中的约定交由机器执行。

其余 linter 按领域选用,不必一次全部启用:性能域有 perfsprint(用 strconv.Itoa 替代 fmt.Sprintf("%d", n))、unconvert、goconst;测试域有 thelper(测试辅助函数补 t.Helper())、testifylint、usetesting(用 t.Setenv/t.TempDir 替代 os 包);日志域有 sloglint、loggercheck;现代化有 modernize、intrange(range N 替代 C 风格循环)、usestdlibvars(http.MethodGet 替代 "GET");正确性域还有 durationcheck(time.Duration 相乘这类单位错误)、reassign(为其他包的哨兵变量重新赋值,例如 io.EOF = nil)。

nolint:精确到行,写明原因

先修复根因,再考虑豁免;确认属于误报或有意为之,才允许 nolint。 绝大多数告警的正确处理方式是修改代码,而非添加豁免指令。nolint 仅用于"linter 判断有误"或"规则在此处确实不适用"的场景;每一条豁免都是留给后续维护者的记录,必须可审计。

nolint 必须精确到 linter 名,禁止裸 //nolint。 裸指令压制该行的全部检查:开发者以为只豁免了一个误报,实际屏蔽了所有规则,且日后无法审计此处究竟跳过了哪些检查。指令应写在告警所在行,或紧邻的上一行:

// ❌ 全量压制、无原因:后续维护者无法得知当初的理由
//nolint
_ = logger.Sync()

// ✅ 精确到 linter,写明原因
//nolint:errcheck // fire-and-forget 日志,错误无从处理
_ = logger.Sync()

// ✅ 复杂度豁免:拆分反而掩盖流程
//nolint:gocyclo // 编排函数,协调 8 个子系统
func orchestrate() error {

//nolint:errcheck // 之后的说明文字面向后续读者,应写清依据。同时豁免多个 linter 时用逗号分隔://nolint:errcheck,gosec // 测试辅助函数中的 fire-and-forget。

应通过 nolintlint 将上述纪律固化为机器检查。 配置 require-explanation 与 require-specific 后,它会专门报告裸 //nolint 与缺少原因的豁免——nolint 自身也被纳入 lint,纪律才可持续执行。

bodyclose、sqlclosecheck、rowserrcheck、gosec 无强理由不应豁免。 它们报告的是真实的连接泄漏与安全漏洞,豁免会引入潜在缺陷。豁免与修复的边界如下:

  • 几乎总是修复:errcheck(至少记录错误)、govet(报告内容多为真实缺陷)、staticcheck、bodyclose、sqlclosecheck
  • 可豁免(必须写明原因):funlen(表驱动测试,长度与用例数成正比)、gocyclo(编排函数,拆分会掩盖流程)、dupl(刻意的平行结构比强行抽象更易读)、exhaustive(default 分支有意覆盖剩余取值)、goconst(测试断言中抽取常量反而降低可读性)

接入日常与 CI

每次显著改动后应在本地执行 golangci-lint run --fix ./...,CI 中全量执行,两道检查缺一不可。输出格式为 path/file.go:42:10: message (linter-name),行尾括号内即来源 linter 名——应先查阅文档确认其检查内容,再决定修复或豁免,顺序不可颠倒。

存量代码使用 issues.new-from-rev 增量接入:老仓库第一天全量运行通常产生数百条告警,短期内逐一清理并不现实。设置 new-from-rev: HEAD~1 后只检查新增/变更代码,新代码零容忍,存量逐步清理。其他常见问题:run.timeout 必须设置(v2 默认 0,即不限时);v1 配置升级使用 golangci-lint migrate;大仓库执行慢时调整 run.concurrency,或用 linters.exclusions.paths 排除生成代码目录。

CI 侧接入(github actions 中使用 golangci-lint-action、失败阈值、缓存等)属于流水线话题,本文不展开,详见本系列 CI/CD 规范。

速查清单

  • gofmt/gofumpt、goimports、go vet 无条件启用,不设例外
  • 每个项目一份 .golangci.yml,提交仓库并纳入评审;v2 文件头写 version: "2"
  • 三档渐进:默认(vet/errcheck/staticcheck/unused/ineffassign)→ 推荐 → 严格;新项目直接采用推荐档
  • 停用 linter 移入 disable 并注明原因;规则冲突时停用价值较低的一方
  • 阈值应显式设置:gocyclo 13、funlen 120 行/80 语句、dupl 100 token、goconst 3 字符 4 次
  • 高价值 12 个:errcheck、govet、staticcheck、errorlint、nilerr、forcetypeassert、gocritic、revive、misspell、noctx、bodyclose、rowserrcheck
  • 先修复根因再豁免;nolint 禁止裸用,必须精确到 linter 名并写明原因
  • nolintlint 启用 require-explanation + require-specific
  • bodyclose、sqlclosecheck、rowserrcheck、gosec 无强理由不豁免
  • 提交前执行 fmt 与 run --fix,可自动修复项不应积压
  • 存量代码用 issues.new-from-rev 增量接入,新代码零容忍
  • CI 全量执行,run.timeout 必须设置(v2 默认 0,不限时)