Go 開發技術棧
一套有主見、現代化的 Go 開發環境。每個工具專注於單一職責,零功能重疊。
何時使用
- 從零開始建立新的 Go 專案
- 新增 lint、格式化或測試基礎設施
- 為 Go 服務或函式庫設定 CI/CD
- 建立 Justfile 以取代 Makefile
- 新增資料庫遷移工具
- 從分散的 gofmt/govet/staticcheck 調用遷移到統一的設定
不適用於定期合併上游程式碼的分支:替換其 Makefile 並使用 gofumpt 重新格式化整個目錄樹會在每次合併時產生衝突,並將實際的改動淹沒在重新格式化的 diff 中。請保留上游的 gofmt 和建置工具,僅新增檢查項目。
工具棧
| 工具 | 版本 | 角色 | 取代 |
|---|---|---|---|
| Go | 1.27+ | 語言、工具鏈、go mod、go fix | - |
| golangci-lint | v2.14+ | 元 linter(100+ 個 linter + 格式化工具 + fmt 指令) | 分開執行的 gofmt, govet, staticcheck, errcheck |
| gofumpt | v0.12+ | 嚴格的格式化工具(gofmt 的超集,19 條預設規則) | gofmt |
| gotestsum | v1.13+ | 測試執行器,提供可讀性輸出、監聽模式、JUnit XML | 原始的 go test |
| just | 1.58+ | 任務執行器 | Makefile |
| golang-migrate | v4.20+ | 資料庫遷移(CLI + 函式庫 + embed.FS) | 手動 SQL 腳本 |
| lefthook | v2.1+ | Git hooks(單一二進位檔,平行執行) | 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
不要透過工具模式安裝 golangci-lint。 上游明確指出:「使用 go install/go get、『工具模式』以及 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 來編譯二進位檔」,而且它無法鎖定版本)。請注意,go tool 會根據當前目錄的模組進行解析——「其他工具可以在當前模組的 go.mod 中定義」——因此在 monorepo 中,如果 recipe 沒有設定 [working-directory(...)],它會失敗並顯示 go: no such tool "..."。
在第一次提交之前,有兩個 Go 指令的行為值得了解:
go mod init在 1.N 工具鏈下會寫入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,任何機器上若其自身的go版本較舊,govulncheck就會根據該版本掃描標準函式庫的建議,因此一個過時的修補版本會自行讓 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 會重複該檢查——但仍然保留它以獲得可讀的 diff。
Lefthook 設定
對於 Go 專案,Lefthook 優於 pre-commit——它是一個單一的 Go 二進位檔,可以平行執行 hooks,並且不需要 Python。
go install github.com/evilmartians/lefthook/[email protected] # 需要 Go 1.26+
lefthook install
# lefthook.yml
assert_lefthook_installed: true # 大聲失敗,而不是跳過所有規則
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 提供了一種靈活的方式來定義任務,同時支援指令和腳本。Jobs 可以分組以實現進階的流程控制。」commands: 並未被棄用,並且仍然有完整的文件說明;當你需要分組、巢狀流程控制或在一個 hook 中混合使用內聯指令和腳本時,可以考慮使用 jobs:。
還有四個值得串接的項目:
- 上方的
assert_lefthook_installed: true是解決下方休眠陷阱的解毒劑:「如果無法在 $PATH 中找到lefthook可執行檔,則失敗(退出碼 1)」。 - 在 CI 中使用
lefthook validate可以在一個格式錯誤的lefthook.yml悄悄停用 hooks 之前將其捕獲;當一個 hook 的行為與編寫的不符時,lefthook dump會列印合併後的有效設定。 - 一個被 gitignore 的
lefthook-local.yml允許開發者新增或跳過任務,而不會強加給隊友——「當你想在本機使用 lefthook 而不強加給隊友時,這非常有用。」 - 在 monorepo 中,為每個 job 指定一個指向其模組目錄的
root:;否則go mod tidy和go tool會針對 repo 根目錄執行並失敗。 - 在
mod-tidy和 lint 任務上使用skip: [merge, rebase]可以讓它們在進行合併或 rebase 期間不參與提交,這樣解決衝突時也不會同時重新整理 tidy 改寫或--fix編輯。
pre-push 的測試套件會重複 CI 已經執行過的內容。一旦它需要幾分鐘,就將其刪除或將其範圍限定為已更改的套件——否則開發者會學會使用 LEFTHOOK=0 來推送。從 hook 執行的測試也會繼承 git 的 hook 環境(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: 鍵下鏡像這些檢查,否則合併會導致未經 lint 的程式碼落地。
Hook 指令會從呼叫者的 PATH 解析工具,因此缺少的二進位檔會以純粹的 exit 127 導致提交失敗。將它們作為 go tool <name>(tool 指令)執行可以使 hook 像建置過程一樣具有可重現性。
處於 Beta 階段,但值得了解的是:ai: 可以在同一個檔案中宣告 LLM 代理 hook——「在 lefthook install 期間,lefthook 會產生提供者特定的設定檔,以便代理在觸發 hook 時呼叫 lefthook run <hook>。」
陷阱
在嘗試找出工作流程中的問題時,請檢查這些項目:
- 休眠的 lefthook:一個團隊成員從未執行
lefthook install,因此 Git hooks 從未執行。assert_lefthook_installed: true會讓git commit失敗。 - 沉默的 lefthook:執行
lefthook install會設定預提交 hook,但lefthook uninstall不會觸發任何錯誤;向 git 詢問哪個 hook 處於作用中狀態:ls -laF .git/hooks/pre-commit。 - 文明衝突:
pre-commithook 中的go mod tidy可能會修改 go.sum,從而導致一個部分提交,其中索引與工作目錄不同步。stage_fixed: true會重新暫存檔案,但前提是返回的檔案與提交內容相符——如果 tidy 新增了一個先前未暫存的條目,則提交會因 dirty index 而失敗。 - golangci-lint fmt 與目錄:
golangci-lint fmt {staged_files}會在檔案列表跨越兩個目錄時失敗。 - Go 工具鏈政策:設定
toolchain go1.27.0意味著go指令永遠不會自行降級到該版本以下——如果本機的go是 1.27.3,它就會使用 1.27.3。如果 policy 想要 1.27.0,請使用GOTOOLCHAIN=go1.27.0。只要go.mod指定go 1.27,任何 1.27.x 工具鏈都可以編譯程式碼;但govulncheck會根據 toolchain 行中提到的特定補丁來掃描 CVE。一個在 1.27.0 上通過但在 1.27.3 上失敗的 CVE 警報是真實的——go.mod + toolchain應該始終是最新的補丁。 - macOS 上來自 Homebrew 的 golangci-lint:根據上游的說法,「在 macOS 上,Homebrew 可能會使用意外版本的 Go 來建置 golangci-lint」——這表示 lint 結果可能因為 Go 版本而有所不同。在 CI 中使用
install.sh,並考慮將其用於本機開發。 - Go workspaces:
golangci-lint僅在檔案層級運作,並且不會在 workspace 模式下自動將子模組視為套件——你需要為每個 module 單獨呼叫它。go tool指令遵循go.work來解析工具。gotestsum作為一個獨立的二進位檔,不受影響。 - golangci-lint 快取:
golangci-lint會快取 lint 結果;一個毀損的快取(例如,由於 Go 版本變更)可能會導致持續的誤判。golangci-lint cache clean可以清除它。在本機上使用GOLANGCI_LINT_CACHE來隔離不同專案。 go mod tidy -diff在空白儲存庫中:如果 go.sum 不存在,則會失敗並顯示error verifying module... sum is missing。go mod verify也依賴於一個存在的 go.sum。順序應該是go mod tidy && go mod verify,而不是反過來。- golangci-lint v2
fmt子指令:不同於gofumpt,golangci-lint fmt不會匯入重新排序——它只格式化。如果你的工作流程依賴於goimports來管理匯入,請保持gofumpt+golangci-lint fmt的組合。 - 從 Makefile 遷移:如果你從一個已存在的 Makefile 遷移,請先備份它——
just是一個任務執行器,它本身不能執行完整的 shell,也沒有內建的條件式。justrecipe 是純執行緒的,而不是聲明式的。 - golangci-lint
fmt與--fix:golangci-lint fmt是格式化工具的前端。golangci-lint run --fix是 linter 修正的前端。fmt子指令不會執行 linter,反之亦然。如果一個 linter 和一個格式化工具報告了相同的問題(例如,gofumpt在兩者中都已啟用),你會看到重複的報告——解決方案是在linters.settings中停用格式化工具的重複報告,或者讓一個工具承擔該責任。 - pre-commit 與 pre-push:如果
pre-pushhook 需要很長時間(例如,執行完整的測試套件),開發者會找到繞過的方法。建議將pre-commithook 保持輕量(格式化 + lint),並將pre-pushhook 限制為僅檢查已更改的套件。
啟動腳本
以下是一個用於引導新 Go 專案的範例腳本:
#!/usr/bin/env bash
set -euo pipefail
PROJECT=$1
MODULE=${2:-"github.com/yourorg/$PROJECT"}
mkdir -p "$PROJECT/cmd/$PROJECT" "$PROJECT/internal" "$PROJECT/migrations"
cd "$PROJECT"
go mod init "$MODULE"
# 根據需要複製設定檔……
cat > .golangci.yml << 'EOF'
version: "2"
# ... 在此處貼上上方完整的設定……
EOF
cat > Justfile << 'JUST'
# ... 在此處貼上上方完整的 Justfile……
JUST
echo "Project $PROJECT 已初始化"
參考資料
- golangci-lint 使用者指南:安裝說明、設定參考、linter 清單。
- gofumpt 儲存庫:關於 gofumpt 規則、與 gofmt 的比較以及版本相容性的詳細文件。
- gotestsum 儲存庫:安裝、設定、格式、監聽模式及 JUnit XML 輸出。
- just 使用者指南:recipe 語法、變數、函數及條件式。
- golang-migrate 儲存庫:CLI 用法、函式庫整合、資料庫驅動程式及 migration 格式。
- lefthook 文件:hook 設定、平行執行、
jobs語法及ai:代理支援。 - Go 工具鏈文件:關於
GOTOOLCHAIN、toolchain指令及go mod tidy行為的管理政策。