上个月帮同事在他的新机器上装环境,我在 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" | 统一 LF | Windows 上保存成 CRLF,git diff 显示"整文件重写" |
files.insertFinalNewline | 文件末尾补换行 | diff 末尾出现 \ No newline at end of file,review 全是噪音 |
files.trimTrailingWhitespace | 去掉行尾空格 | whitespace 类的 lint 规则报警,但本地看不见 |
[markdown].trimTrailingWhitespace: false | Markdown 行尾两个空格是硬换行 | 草稿的换行被吃掉,排版莫名其妙错乱 |
三、golangci-lint:本地检查和 CI 检查必须是同一条命令
目标只有一句:CI 上会红的规则,我在本地必须能提前看到。 做不到这一点,配置写多少条都是自我安慰。
我的做法是三条:
- 配置只有一份
.golangci.yml,放在仓库根目录。编辑器插件、提交钩子、CI 都读它,谁都不许有自己的例外。 - 版本号写进
Makefile和 CI,不用latest。本地跑 v1、CI 跑 v2 这种事,会让"本地过了"变成一句空话。 - 编辑器里可以用轻量旗子做即时反馈,但全量检查必须用和 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 覆盖。三个月后我自己改这个文件,已经说不清哪条是为了解决什么问题了——那它就只是一堆随时会咬人的历史遗留。
现在的纪律是三条:
- 仓库里只放"团队需要一致"的配置:
formatOnSave、eol、默认 formatter、extensions.json的推荐插件。字号、主题、快捷键、AI 补全这些个人偏好一律不提交——把别人的编辑器变成你的编辑器,是这类配置文件被删的最常见原因。 - 优先选"能自动生效"的机制,而不是写文档。
.vscode/extensions.json的recommendations比 README 里那句"请安装以下插件"有用一百倍;.editorconfig比"请用 tab 缩进"有用一百倍。文档会被跳过,机制不会。 - 每条规则必须有一处会被强制执行的地方。只在编辑器里的规则等于不存在——总有人不用 VSCode,总有人用
vim。所以优先级是 CI > 提交钩子 > 编辑器:CI 全绿而编辑器什么配置都没有,代码质量不会掉;反过来编辑器配置再全、CI 不查,半年后仓库就会花掉。
给个可操作的上限:一个新的 Go 项目,.vscode/settings.json 我不超过 25 行,.editorconfig 不超过 15 行,.golangci.yml 只开 6~8 条。多出来的部分等它被咬一次再加——被咬过之后加的那条配置,才有人愿意为它写注释、也才有人会在半年后知道它为什么在那儿。
现在开新项目我的顺序是:先写 CI(一条 golangci-lint run ./...),再写 .editorconfig,最后才按需加编辑器配置。顺序反过来最容易掉进一个坑:花一整天让编辑器"看起来在管事",而真正的把关还在别人的流水线里。