Go 开发工具栈
有主见的、现代化的 Go 开发环境。每个关注点对应一个工具,功能零重叠。
何时使用
- 从零开始一个新 Go 项目
- 添加 lint、格式化或测试基础设施
- 为 Go 服务或库搭建 CI/CD
- 创建 Justfile 来替代 Makefile
- 接入数据库迁移工具
- 从零散的 gofmt/govet/staticcheck 调用迁移到统一环境
不适合定期从上游合并的 fork:替换其 Makefile 并用 gofumpt 重新格式化整个代码树会在每次合并时产生冲突,并将真正的变更淹没在格式化差异中。应该保留上游的 gofmt 和构建工具,仅添加检查。
工具栈
| 工具 | 版本 | 作用 | 替代品 |
|---|---|---|---|
| Go | 1.27+ | 语言、工具链、go mod、go fix | - |
| golangci-lint | v2.14+ | 元 linter(100+ linter + formatter + fmt 命令) | 分别运行 gofmt、govet、staticcheck、errcheck |
| gofumpt | v0.12+ | 严格格式化工具(gofmt 的超集,19 条默认规则) | gofmt |
| gotestsum | v1.13+ | 测试运行器,输出可读、支持 watch 模式、JUnit XML | 原始 go test |
| just | 1.58+ | 任务运行器 | Makefile |
| golang-migrate | v4.20+ | 数据库迁移(CLI + 库 + embed.FS) | 手动 SQL 脚本 |
| lefthook | v2.1+ | Git 钩子(单个二进制,并行执行) | pre-commit (Python) |
版本下限是功能依赖的关键。 golangci-lint "支持低于或等于其编译时所用 Go 版本的 Go 版本" — 如果你固定的版本比你的 Go 工具链还旧,它会直接失败。Go 1.27 的支持在 golangci-lint v2.13.0 中引入,因此 v2.13 是 Go 1.27 项目的下限;本技能固定为 v2.14.0,这是第一个捆绑 gofumpt v0.12.0 的版本。最近还有两个版本下限发生了变动:gofumpt v0.12.0 "基于 Go 1.27 的 gofmt,需要 Go 1.26 或更高版本",lefthook 的 go install 路径现在要求 Go 1.26+。
快速入门:新项目
# 1. 创建模块
mkdir myapp && cd myapp
go mod init github.com/yourorg/myapp
# 2. 创建目录结构
mkdir -p cmd/myapp internal migrations
# 3. 将 golangci-lint 作为二进制安装,而不是作为模块工具(见下方说明)
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.14.0
# 4. 在 go.mod 中跟踪其余工具(Go 1.24+ tool 指令)。固定版本 — 永远不要用 @latest,
# 它会在每次 CI 运行时重新编译工具,并且导致不同机器上的版本漂移。
go get -tool mvdan.cc/[email protected]
go get -tool gotest.tools/[email protected]
# golang-migrate 需要一个构建标签,因此直接安装
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/[email protected]
# 5. 创建配置文件(模板见下文)
# 6. 运行:just check
不要通过 tools 模式安装 golangci-lint。 上游明确说明:"使用 go install/go get、tools pattern 和 tool 命令/指令的安装方式不保证能工作。我们推荐使用二进制安装。" 在共享仓库中,这一点很重要,因为 "一个工具的依赖可能会修改另一个工具或你的项目的依赖"。如果你必须将其放在 go.mod 中,请使用单独的 -modfile 隔离它 — 参见 golangci-lint 参考。
go get -tool 跟踪依赖;go tool 运行工具。 tool 指令将依赖记录在 go.mod 中,但不会将其放入你的 PATH。要么通过工具链调用 — go tool gofumpt -l .、go tool gotestsum --format testname — 要么先运行 go install tool 将其安装到 $(go env GOPATH)/bin。下面的 Justfile 直接调用裸二进制文件,因此它假设走 go install tool 路线(或者通过 Homebrew 进行系统安装 — 但 "Homebrew 可能使用意外版本的 Go 来构建二进制文件" 用于 golangci-lint,并且它无法固定版本)。注意,go tool 会解析当前目录模块中的定义 — "额外的工具可以在当前模块的 go.mod 中定义" — 因此在 monorepo 中,如果 recipe 没有设置 [working-directory(...)],它会失败并报错 go: no such tool "..."。
在第一次提交之前,有两个 Go 命令行为值得了解:
- 在 1.N 工具链下运行
go mod init会写入go 1.(N-1).0,而不是1.N— "使用版本为1.N.X的工具链运行go mod init将创建一个指定 Go 版本为go 1.(N-1).0的go.mod文件。" 如果你想要 1.N 的语言特性,请有意地手动升级该指令。 - 在
go.mod中设置toolchain go1.27.1行,但要明确它只是一个下限,而不是固定版本:在默认的GOTOOLCHAIN=auto下,go命令只有在 "<tname>比默认 Go 工具链更新" 时才会切换,因此本地更新版本的go会胜出。要实现精确的工具链固定,请设置GOTOOLCHAIN=go1.27.1(CI 或go env -w);使用GODEBUG=toolchaintrace=1 go version可以查看实际选择了哪一个。保持该行指向当前的补丁版本,而不是.0:它决定了在任何一个本地go版本更旧的机器上,govulncheck扫描 stdlib 安全公告时所针对的go版本,因此一个过时的补丁会自行导致 CI 变红 — 参见下面的“陷阱”部分。
.golangci.yml
version: "2"
run:
timeout: 5m
build-tags:
- integration # 否则 Justfile 中使用 integration 标签的文件永远不会被 lint
linters:
default: standard
enable:
- bodyclose
- copyloopvar
- dupl
- durationcheck
- err113
- errname
- errorlint
- exhaustive
- exptostd
- fatcontext
- goconst
- gocritic
- gosec
- intrange
- misspell
- modernize
- musttag
- nakedret
- nestif
- nilerr
- noctx
- nolintlint
- nonamedreturns
- perfsprint
- prealloc
- revive
- sqlclosecheck
- testifylint
- thelper
- unconvert
- unparam
- usestdlibvars
- usetesting
- wastedassign
- whitespace
- wrapcheck
settings:
govet:
enable:
- shadow
gocritic:
enabled-checks:
- nestingReduce
revive:
enable-all-rules: true
rules:
# enable-all-rules 会打开 `unhandled-error`,这会在 main 函数中标记 `fmt.Println`。
# 在 enable-all-rules 下,规则的 `arguments` 会被忽略(规则会注册两次),
# 因此白名单在这里不起作用 — 只有 `disabled` 生效。
- name: unhandled-error
disabled: true
errcheck:
check-type-assertions: true
exclusions:
generated: strict
presets:
- comments
- std-error-handling
- common-false-positives
rules:
- path: _test\.go
linters:
- errcheck
- dupl
- gosec
- wrapcheck
formatters:
enable:
- gofumpt
- goimports
settings:
gofumpt:
# 单独选择规则。`extra-rules: true` 已弃用,并且它还会打开
# `balance_calls`,而 gofumpt 本身已经将其降级为有争议的规则。
extra:
group-params: true
clothe-returns: true
balance-calls: false
exclusions:
generated: strict
paths:
- vendor/
output:
formats:
text:
path: stdout
print-linter-name: true
colors: true
sort-order:
- linter
- file
show-stats: true
Justfile
set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load
binary := "myapp"
[private]
default:
@just --list --unsorted
# ── 代码质量 ──────────────────────────────────────────────
# 格式化所有 Go 代码
[group('quality')]
fmt:
golangci-lint fmt ./...
# 检查格式化而不修改(CI 安全)
[group('quality')]
fmt-check:
golangci-lint fmt --diff ./...
# 运行 linter
[group('quality')]
lint:
golangci-lint run ./...
# 运行 linter 并自动修复
[group('quality')]
lint-fix:
golangci-lint run --fix ./...
# 运行漏洞检查
[group('quality')]
vuln:
govulncheck ./...
# ── 测试 ─────────────────────────────────────────────────
# 运行所有测试,启用竞态检测
[group('test')]
test *args="./...":
gotestsum --format testname -- -race {{ args }}
# 运行测试并生成覆盖率
[group('test')]
test-cov:
gotestsum --format testname -- -race -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -func=coverage.out
# 在浏览器中打开覆盖率报告
[group('test')]
coverage: test-cov
go tool cover -html=coverage.out
# 运行集成测试
[group('test')]
test-integration:
gotestsum --format testname -- -race -tags=integration ./...
# 开发期间观察测试
[group('test')]
test-watch:
gotestsum --watch --watch-clear --format testname
# 运行基准测试
[group('test')]
bench:
go test -bench=. -benchmem ./...
# ── 构建 ─────────────────────────────────────────────────
# 构建二进制文件
[group('build')]
build:
go build -o {{ binary }} ./cmd/{{ binary }}
# 构建优化后的发布二进制文件
[group('build')]
build-release:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o {{ binary }} ./cmd/{{ binary }}
# ── 依赖管理 ────────────────────────────────────────────
# 整理并验证模块
[group('deps')]
tidy:
go mod tidy
go mod verify
# 在不修改文件的情况下检查 go.mod/go.sum 是否整洁(CI 安全)
[group('deps')]
tidy-check:
go mod tidy -diff
# 运行代码生成器
[group('deps')]
generate:
go generate ./...
# ── 数据库 ──────────────────────────────────────────────
# 应用所有待处理的迁移
[group('db')]
migrate-up:
migrate -path migrations -database "$DATABASE_URL" up
# 回滚上一次迁移
[group('db')]
migrate-down:
migrate -path migrations -database "$DATABASE_URL" down 1
# 创建新的迁移
[group('db')]
migrate-create name:
migrate create -ext sql -dir migrations -seq {{ name }}
# ── CI ────────────────────────────────────────────────────
# 完整 CI 门控(格式检查 + lint + 测试)
[group('ci')]
check: fmt-check lint test
@echo "所有检查通过"
# 清理构建产物
[group('ci')]
clean:
go clean
rm -f {{ binary }} coverage.out
check 有意排除了 vuln:govulncheck 需要联网,漏洞数据库的更新可能在一项未改动任何代码的变更上将门控变红。应该将其作为独立的 CI 任务运行。golangci-lint run 也会运行已启用的格式化工具并报告它们的问题,因此 fmt-check 是重复的检查 — 但为了可读的差异输出,保留它。
Lefthook 配置
对于 Go 项目,lefthook 优于 pre-commit — 它是一个单一的 Go 二进制文件,可以并行运行钩子,并且不需要 Python。
go install github.com/evilmartians/lefthook/[email protected] # 需要 Go 1.26+
lefthook install
# lefthook.yml
assert_lefthook_installed: true # 如果 lefthook 未安装,则大声失败,而不是跳过所有规则
pre-commit:
piped: true # 快速失败 — 在第一个失败的任务处停止
commands:
fmt:
glob: "*.go"
run: golangci-lint fmt {staged_files}
stage_fixed: true
lint:
glob: "*.go"
# 永远不要将裸文件列表传递给 `golangci-lint run`:跨越两个目录的列表
# 会被直接拒绝,而一个多文件包中的单个文件会报告虚假的 `undefined:` 类型检查错误。
# 应该对包进行 lint。
run: printf '%s\n' {staged_files} | xargs -n1 dirname | sort -u | xargs golangci-lint run --fix
stage_fixed: true
mod-tidy:
glob: "*.{go,mod,sum}"
run: go mod tidy
pre-push:
commands:
test:
run: go test -race ./...
piped: true 是快速失败机制,而不是排序 — lefthook "默认按顺序运行命令和脚本",而 piped 增加了 "如果其中一个命令或脚本失败,则停止运行后续的命令和脚本"。它不能与 parallel: true 结合使用。
jobs:(在 lefthook 1.10.0 中引入)是较新的原语,与 commands:/scripts: 的分拆并存 — “Jobs 提供了一种灵活的方式来定义任务,同时支持 commands 和 scripts。Jobs 可以分组以实现更高级的流程控制。” commands: 并未废弃,并且仍然有完整的文档说明;当你需要分组、嵌套控制流或在一个钩子中混合使用内联命令和脚本时,可以使用 jobs:。
还有四个值得配置的:
- 上面的
assert_lefthook_installed: true是解决懒散陷阱的良药:"如果 $PATH 中找不到lefthook可执行文件,则失败(退出码 1)"。 - CI 中的
lefthook validate可以在畸形lefthook.yml导致钩子被静默禁用之前捕获它;当钩子行为与预期不符时,lefthook dump可以打印合并后的有效配置。 - 一个被 gitignore 的
lefthook-local.yml允许开发者添加或跳过任务,而不会强加给团队成员 — “当你想要在本地使用 lefthook 但又不想强加给队友时,这很有用。” - 在 monorepo 中,为每个任务指定
root:指向其模块目录;否则go mod tidy和go tool会在仓库根目录下运行并失败。 - 在
mod-tidy和 lint 任务上添加skip: [merge, rebase],以防止它们在合并或变基过程中的提交中运行,从而避免解决冲突时同时重新暂存 tidy 重写或--fix编辑。
pre-push 中的测试套件与 CI 运行的内容重复。一旦它耗时数分钟,就删除它,或者将其范围限制在更改的包上 — 否则开发者会学会使用 LEFTHOOK=0 来推送。从钩子运行的测试也会继承 git 的钩子环境(GIT_DIR、GIT_INDEX_FILE、GIT_WORK_TREE),因此一个在 t.TempDir() 中运行 git init 或 git commit 的测试辅助函数会作用于真实的仓库 — 请在 cmd.Env 中清除这些变量。
一个没有冲突的 git merge 永远不会运行 pre-commit:git 会调用 pre-merge-commit 代替,并且只在完成一个有冲突的合并并运行 git commit 时才会运行 pre-commit。在 pre-merge-commit: 键下镜像这些检查,否则合并可能会带着未格式化的代码落地。
钩子命令从调用者的 PATH 中解析工具,因此缺少的二进制文件会以 bare exit 127 使提交失败。将它们运行为 go tool <name>(使用 tool 指令)可以使钩子像构建过程一样可重现。
Beta 特性,但值得了解:ai: 在同一个文件中声明 LLM 代理钩子 — “在 lefthook install 期间,lefthook 会生成特定于提供商的设置文件,以便代理在需要时调用 lefthook run <hook>。
...
(原文被截断)