编辑器配置:让格式化和检查自动发生

VSCode 最小可用配置(formatOnSave、gopls、goimports)、golangci-lint 与 CI 对齐、.editorconfig、pre-commit 钩子选型,以及格式化与 lint 打架这类真实翻车。

上个月帮同事在他的新机器上装环境,我在 settings.json 上花了四十分钟。最后发现格式化不生效的原因,是他从某篇教程抄来的一段 go.formatTool 和我让他加的 [go] 语言级覆盖互相打架。回头翻我自己仓库里真正活下来的配置,只剩二十几行。这篇记一下我从"配了一堆规则、半年后没人维护"收敛到"按一次保存就把该做的做完"的过程,以及中间踩过的几个坑。

一、起点:格式全对、CI 全红的那种提交

最早我是手动派:写完了跑 gofmt -w .,再跑 golangci-lint run ./...,然后 git add。实际结果是——改了三行代码,第五次提交才想起来跑 lint。CI 红一次,改一次,再红一次。

转折是我翻了一次自己的提交记录:一个 PR 里有一半的 commit message 是"fix lint""fix fmt"。这些动作的共同点是规则明确、判断成本为零,那它就根本不该由人来做。所以我给自己定的目标很小:

按一次 Ctrl+S,格式化和导入整理这两件事必须已经发生;重型检查(跨包分析、重复代码、安全问题)交给提交钩子和 CI,不放在按键路径上。

二、我的 settings.json 去掉了注释就这么点

{
  "editor.formatOnSave": true,
  "editor.rulers": [120],
  "files.eol": "\n",
  "files.insertFinalNewline": true,
  "files.trimTrailingWhitespace": true,
  "[go]": {
    "editor.defaultFormatter": "golang.go",
    "editor.codeActionsOnSave": {
      "source.organizeImports": "explicit"
    }
  },
  "gopls": {
    "analyses": { "unusedparams": true, "shadow": true },
    "staticcheck": true
  },
  "[markdown]": { "files.trimTrailingWhitespace": false }
}

source.organizeImports 我给的是 "explicit" 而不是 "always",这个选择值得解释。VSCode 的自动保存是按"你停止打字"触发的,如果你同时开了 files.autoSave: "afterDelay" 和 "always",导入整理会在你打字停顿的间隙反复执行。后果是:你把一段用 redis 的代码临时注释掉去试别的写法,保存一次,导入就被删了;等你取消注释,代码直接编译不过,你还以为是自己的问题。"explicit" 只在真正的 Ctrl+S 时做这件事,把"删导入"从被动变成主动。

配置作用少写它会怎样
editor.formatOnSave保存即格式化靠人记得跑 gofmt,然后忘
[go].editor.defaultFormatter指定 gopls 为唯一的 formatter多个 formatter 抢同一个文件,结果在保存前后反复跳
files.eol: "\n"统一 LFWindows 上保存成 CRLF,git diff 显示"整文件重写"
files.insertFinalNewline文件末尾补换行diff 末尾出现 \ No newline at end of file,review 全是噪音
files.trimTrailingWhitespace去掉行尾空格whitespace 类的 lint 规则报警,但本地看不见
[markdown].trimTrailingWhitespace: falseMarkdown 行尾两个空格是硬换行草稿的换行被吃掉,排版莫名其妙错乱

三、golangci-lint:本地检查和 CI 检查必须是同一条命令

目标只有一句:CI 上会红的规则,我在本地必须能提前看到。 做不到这一点,配置写多少条都是自我安慰。

我的做法是三条:

  1. 配置只有一份 .golangci.yml,放在仓库根目录。编辑器插件、提交钩子、CI 都读它,谁都不许有自己的例外。
  2. 版本号写进 Makefile 和 CI,不用 latest。本地跑 v1、CI 跑 v2 这种事,会让"本地过了"变成一句空话。
  3. 编辑器里可以用轻量旗子做即时反馈,但全量检查必须用和 CI 完全一样的命令。
GOLANGCI_VERSION := v2.1.6

.PHONY: tools fmt lint
tools:
	go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION)

fmt:
	gofmt -l -w .

lint:
	golangci-lint run ./...

配置文件本身在 v2 里换了形状,网上大量 v1 的写法要改写:

version: "2"
linters:
  default: standard
  enable:
    - errcheck
    - govet
    - ineffassign
    - staticcheck
    - unused
    - bodyclose
    - errorlint
  exclusions:
    rules:
      - path: _test\.go
        linters: [errcheck, bodyclose]

我特意留了两个"看起来可以省"的:errorlint 会揪出 err == sql.ErrNoRows 这种该用 errors.Is 的写法(越是开始包装错误的项目,这里越容易埋雷);bodyclose 抓 HTTP 响应体没关闭(攒久了就是连接池打满)。这两条都属于"本地跑一次、线上少一次告警"的类型。

关于 gopls 内置的 staticcheck 分析:它反馈更快,但不要指望它等于 golangci-lint,两者的规则集和版本都不一样。我的定位是"编辑器管手快、CI 管把关"——编辑器不报不代表能过,编辑器报了也不一定是 CI 会管的事。

四、.editorconfig:管住 lint 管不到的地方

.golangci.yml 只管 Go。仓库里还有 YAML、Makefile、SQL、Markdown,这些文件的缩进和行尾不出事则已,出事都不好查。我现在的根配置只有十几行:

root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2

[*.go]
indent_style = tab

[Makefile]
indent_style = tab

[*.md]
trim_trailing_whitespace = false

[Makefile] 那条不是洁癖:Makefile 的配方行必须是 tab,编辑器好心按 indent_size 转成空格,make 立刻报 missing separator,而它指的行号跟你改的地方能差出十万八千里。这是我最想给所有"自动格式化"配置加的边界——别格式化你不懂的语言。

五、提交钩子:pre-commit 还是 lefthook

两个都用过,取舍标准最后收敛成一句话:团队里"必须有 Python 才能提交代码"这件事能不能接受。

方案优点代价我会在什么时候选它
pre-commit生态最全,常见语言的官方 hook 基本都有;配置是声明式的依赖 Python 环境;多语言 hook 各建一套虚拟环境,首次执行很慢仓库里已经有 Python,或者需要一堆现成 hook
lefthook一个 Go 二进制,go install 就能装;并行执行,三平台行为一致生态小,什么都得自己写 run:纯 Go / Node 仓库,团队不想为了提交装 Python

我的 Go 项目现在用 lefthook:

# lefthook.yml
pre-commit:
  parallel: true
  commands:
    gofmt:
      glob: "*.go"
      run: gofmt -l -w {staged_files}
      stage_fixed: true
    lint-new:
      glob: "*.go"
      run: golangci-lint run --new-from-rev=HEAD ./...

两个细节值得抄:stage_fixed: true 会把格式化后的文件自动放回暂存区,否则你会提交一份"已格式化但没 add"的代码;--new-from-rev=HEAD 只检查本次改动,把钩子耗时压到一两秒——钩子超过三秒,一定有人用 --no-verify 绕过,而绕过一次就会绕过一百次。

钩子里只放"必须失败才能提交"的东西。我见过把全部单测、全部 lint、全部类型检查塞进 pre-commit 的仓库,最后所有人的肌肉记忆都是 git commit --no-verify,那份配置等于零。

六、踩过的几个坑,每条都真实发生过

现象原因怎么确认 / 怎么修
保存后代码编译不过,导入被删了自动保存 + "always" 的导入整理,把你临时注释或半写完的代码判成"未使用"把 source.organizeImports 改成 "explicit",或关掉 files.autoSave。机制上它删的是未使用的导入,不是变量,但感受一模一样
函数签名莫名其妙变了gopls 的 unusedparams 分析在 Ctrl+. 里提供了"移除未使用参数"的快速修复,顺手按下去就改了签名我现在的做法是留着这条分析(它有价值),但养成"快速修复前先看 diff"的习惯
格式化结果在保存前后反复跳go.formatTool 和 gopls 的 formatting.gofumpt 同时生效用 gofmt -l . 的结果和编辑器保存后的结果对比;只留一个 formatter
git diff 满屏红,实际只改了两行行尾从 LF 变 CRLF(换了编辑器,或克隆时 core.autocrlf 不一致)git diff --stat 看改动行数;git ls-files --eol <file> 看实际行尾
make 报 missing separator编辑器把 Makefile 的 tab 转成了空格cat -A Makefile | head,看配方行开头是 ^I 还是空格
同事 clone 下来没有我的检查配置写在 user settings 里,或者插件没装该提交的是 .vscode/settings.json 和 .vscode/extensions.json,个人偏好留在 user settings

七、不要为了编辑器写一堆没人维护的配置

我提交过一份两百多行的 settings.json:七八种语言、二十多个 snippets、每个文件类型各有一套 formatter 覆盖。三个月后我自己改这个文件,已经说不清哪条是为了解决什么问题了——那它就只是一堆随时会咬人的历史遗留。

现在的纪律是三条:

  1. 仓库里只放"团队需要一致"的配置:formatOnSave、eol、默认 formatter、extensions.json 的推荐插件。字号、主题、快捷键、AI 补全这些个人偏好一律不提交——把别人的编辑器变成你的编辑器,是这类配置文件被删的最常见原因。
  2. 优先选"能自动生效"的机制,而不是写文档。.vscode/extensions.json 的 recommendations 比 README 里那句"请安装以下插件"有用一百倍;.editorconfig 比"请用 tab 缩进"有用一百倍。文档会被跳过,机制不会。
  3. 每条规则必须有一处会被强制执行的地方。只在编辑器里的规则等于不存在——总有人不用 VSCode,总有人用 vim。所以优先级是 CI > 提交钩子 > 编辑器:CI 全绿而编辑器什么配置都没有,代码质量不会掉;反过来编辑器配置再全、CI 不查,半年后仓库就会花掉。

给个可操作的上限:一个新的 Go 项目,.vscode/settings.json 我不超过 25 行,.editorconfig 不超过 15 行,.golangci.yml 只开 6~8 条。多出来的部分等它被咬一次再加——被咬过之后加的那条配置,才有人愿意为它写注释、也才有人会在半年后知道它为什么在那儿。

现在开新项目我的顺序是:先写 CI(一条 golangci-lint run ./...),再写 .editorconfig,最后才按需加编辑器配置。顺序反过来最容易掉进一个坑:花一整天让编辑器"看起来在管事",而真正的把关还在别人的流水线里。

返回博客列表