Go 開発スタック
意見に基づいた、モダンなGo開発セットアップ。関心事ごとに一つのツール、重複はありません。
使用するタイミング
- ゼロから新しいGoプロジェクトを始める場合
- lint、フォーマット、テストインフラを追加する場合
- GoサービスやライブラリのCI/CDを設定する場合
- Makefileを置き換えるJustfileを作成する場合
- データベースマイグレーションツールを追加する場合
- バラバラの gofmt/govet/staticcheck 呼び出しから統一されたセットアップに移行する場合
上流から定期的にマージするフォークには使用しないでください。Makefileを置き換え、gofumptでツリーを再フォーマットすると、マージのたびに競合が発生し、実際の変更が再フォーマットの差分に埋もれてしまいます。上流のgofmtとビルドツールはそのままにし、チェックのみを追加してください。
スタック
| ツール | バージョン | 役割 | 置き換えるもの |
|---|---|---|---|
| Go | 1.27+ | 言語、ツールチェーン、go mod、go fix | - |
| golangci-lint | v2.14+ | メタリンター (100以上のリンター+フォーマッター+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+ | DBマイグレーション (CLI + ライブラリ + embed.FS) | 手動SQLスクリプト |
| lefthook | v2.1+ | Gitフック (単一バイナリ、並列実行) | pre-commit (Python) |
バージョンの下限は重要です。 golangci-lint は「コンパイルに使用されたGoバージョン以下のGoバージョンをサポート」するため、Goツールチェーンより古いバージョンを指定すると完全に失敗します。Go 1.27のサポートはgolangci-lint v2.13.0で追加されたため、Go 1.27プロジェクトでは v2.13 が下限となり、このスキルではgofumpt v0.12.0をバンドルする最初のリリースである v2.14.0 を固定しています。さらに最近、2つの下限が移動しました。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+ ツールディレクティブ)。バージョンを固定 - @latest は避けてください。
# @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、"tools pattern"、tool コマンド/ディレクティブによるインストールは保証されません。バイナリインストールを推奨します。」共有リポジトリでこれが問題になる理由は依存関係の漏洩にあります - 「ツールの依存関係が別のツールやプロジェクトの依存関係を変更する可能性があります。」どうしても go.mod に入れたい場合は、独自の -modfile の背後に隔離してください - golangci-lint リファレンス を参照。
go get -tool は追跡し、go 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によるシステムインストール - ただしgolangci-lintの場合、「Homebrewは予期しないバージョンのGoを使用してバイナリをビルドする可能性があり」、バージョンを固定できません)を想定しています。go tool はカレントディレクトリのモジュールに対して解決されることに注意してください。「現在のモジュールのgo.modで追加のツールが定義されている場合もあります」 - そのため、モノレポでは、レシピに [working-directory(...)] を設定しないと go: no such tool "..." で失敗します。
最初のコミットの前に知っておくべき2つの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が古いマシン上でgovulncheckがstdlibアドバイザリをスキャンする対象となるgoを選択するためです。古いパッチはそれ自体でCIを赤くします - 以下のFootgunsを参照。
.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` は無視されます(ルールが2回登録されるため)、
# そのため許可リストはここでは機能しません - `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 ./...
# リンターを実行
[group('quality')]
lint:
golangci-lint run ./...
# 自動修正付きでリンターを実行
[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 "All checks passed"
# ビルドアーティファクトをクリーン
[group('ci')]
clean:
go clean
rm -f {{ binary }} coverage.out
check は意図的に vuln を除外しています。govulncheck はネットワークを呼び出し、脆弱性データベースの更新により、触っていない変更でゲートが赤くなることがあります。代わりに、独自のCIジョブとして実行してください。golangci-lint run は有効なフォーマッターも実行し、その問題を報告するため、fmt-check はそのチェックを重複しています - 読みやすい差分のために残しておいてください。
Lefthook 設定
Lefthook は Go プロジェクトでは pre-commit よりも推奨されます。単一のGoバイナリであり、フックを並列実行し、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` に渡さないでください。2つのディレクトリに
# またがるリストは完全に拒否され、マルチファイルパッケージの1つのファイルは
# 幻の `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: 分割とともに新しいプリミティブです。「ジョブはタスクを定義する柔軟な方法を提供し、コマンドとスクリプトの両方をサポートします。ジョブは高度なフロー制御のためにグループ化できます。」commands: は非推奨ではなく、完全に文書化されたままです。グループ化、ネストされたフロー制御、または1つのフック内のインラインコマンドとスクリプトの混合が必要な場合は jobs: に手を伸ばしてください。
さらに配線する価値のある4つの事項:
- 上記の
assert_lefthook_installed: trueは、以下にある休眠footgunに対する解毒剤です。「$PATHにlefthook実行可能ファイルが見つからない場合、失敗する (終了ステータス1)」。 - CI内の
lefthook validateは、不正なlefthook.ymlが静かにフックを無効にする前にキャッチします。lefthook dumpは、フックが記述されたとおりに動作しない場合に、マージされた有効な設定を出力します。 - gitignoreされた
lefthook-local.ymlにより、開発者はチームメイトに強制することなく、ジョブを追加またはスキップできます。「これは、lefthookをローカルで使用したいが、チームメイトに強制したくない場合に便利です。」 - モノレポでは、各ジョブにモジュールディレクトリを指す
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: キーの下にミラーリングするか、マージがlintされずにランディングします。
フックコマンドは呼び出し元のPATHからツールを解決するため、バイナリがないと exit 127 でコミットが失敗します。それらを go tool <name> (ツールディレクティブ) として実行すると、フックはビルドと同じくらい再現可能になります。
ベータ版ですが、知っておく価値があります。ai: は同じファイル内にLLMエージェントフックを宣言します - 「lefthook install 中に、lefthook はプロバイダー固有の設定ファイルを生成し、エージェントがトリガーされたときに lefthook run <hook> を呼び出すようにします。」
Footguns(よくある落とし穴)
lefthook の休眠
「lefthook install は _ で始まらないフックをインストールします。」誤って _lefthook.yml と名付けたり、lefthook.yml を壊したり、ある開発者がインストールを忘れたりすると、フックは静かに実行されません。これが assert_lefthook_installed: true と CI での lefthook validate が存在する理由です。
golangci-lint run へのファイルリストの受け渡し
golangci-lint run file1.go file2.go を決して行わないでください。2つの異なるパッケージからのファイルは「golangci-lint: 1 つのディレクトリのみ許可」で拒否され、1つのパッケージからの1つのファイルは「不明なx」型チェックエラーを報告します。常にパッケージを渡すか、./... を渡すか、lefthookの printf ... xargs dirname トリックを使用してください。
go.work は go vet の動作を変更する
go.work ファイルが存在する場合、go vet (および golangci-lint) はすべてのワークスペースモジュールに対して実行されます。大きなモノレポでは、不要なモジュールをチェックするため、予期せずタイムアウトになる可能性があります。GOWORK=off go vet ./... または golangci-lint run --modules-download-mode vendor ./my-service/... を使用してスコープを制限してください。
golangci-lint 出力のパイプ処理
--color および --output.text.path を stdout に設定した golangci-lint は、TTYが接続されている場合にのみ色を出力します。golangci-lint run ./... | tee report.txt は生のリセットコード ([0m) をファイルに書き込みます。CIでは --color=always を使用してください。
lefthook 環境の継承
pre-push 内で go test として実行されるテストは、GIT_DIR、GIT_INDEX_FILE、GIT_WORK_TREE を継承します。gitリポジトリ内で t.TempDir() を使用する func TestRepo は、親のgitメタデータを継承します。cmd.Env でそれらをクリアしない限り、テストは奇妙に失敗するか、開発者の作業ディレクトリを台無しにします。
マージフックの欠落
pre-commit フックは git merge 中には実行されません。マージは pre-merge-commit を呼び出します(手動の git commit --no-edit は除く)。lint ジョブを pre-merge-commit キーの下にミラーリングしない限り、lint されていないコードがマージされます。
GOTOOLCHAIN のディストリビューションパッケージとの相互作用
Goが apt, brew, nix 経由でインストールされているマシンでは、GOTOOLCHAIN=auto がディストリビューションの go バージョンよりも優先される場合があります。GOTOOLCHAIN=go1.27.1 を go env -w するか、CIランナーにエイリアスを設定してください。toolchain ディレクティブがどのように強制されるかを理解するには、Go wikiの GOTOOLCHAIN を参照してください。
govulncheck の誤検知
govulncheck は、Goの脆弱性データベースに対してコード内で使用されているシンボルと関数をクロスリファレンスします。未使用の推移的依存関係にある脆弱性は報告しませんが、データベース自体が変更される可能性があります。check から除外し、代わりに別のCIジョブとして実行する理由はここにあります。
パッチバージョンのずれ
go.mod の toolchain ディレクティブを意図的に現在のパッチ(go1.27.1)に設定し、.0 にしないでください。ローカルのGoがデフォルトのままのマシン(たとえばGitHub Actionsの stable)では、toolchain ディレクティブが go バイナリを選択します。govulncheck はそのバージョンに対してstdlibアドバイザリをスキャンします。go1.27.0 は、Go 1.27.0のリリース後に発見された脆弱性に対して既知の脆弱性としてフラグを立てます。toolchain がまだ壊れている可能性のあるパッチに設定されている場合、govulncheckはCI全体を赤くします。
golangci-lint fmt の並列処理
golangci-lint fmt は複数のコアを使用できるため、大きなリポジトリでは gofmt -l . よりも大幅に高速です。ただし、goimports も有効になっている場合、golangci-lint fmt は goimports を直接呼び出しません。代わりにリンターとフォーマッターエンジンを介して実行されるため、スタンドアロンの goimports とは動作が異なる場合があります。整合性が重要な場合は、goimports のみに頼らず、.golangci.yml のフォーマッター設定に注目してください。
移行の失敗を見逃さない
golang-migrate の移行は、SQLエラーが発生してもゼロ以外の終了コードで失敗しない場合があります。特に migrate -path ... up は、データベースへの接続が少なくとも一度成功した場合、ステータスコード0で終了することが知られています。CIで安全策として migrate -verbose -path ... up 2>&1 | grep -i error をパイプするか、アプリケーションの起動シーケンスの一部として go-migrate ライブラリを使用してください。ここでのCLIは迅速な初期開発に適しています。
アンインストール
各ツールはクリーンなパスを持っています:
golangci-lint:rm $(which golangci-lint)- gofumpt、gotestsum:
go.modからツールディレクティブ行を削除 - golang-migrate:
rm $(which migrate) - just: 使用したインストール方法に従う (
brew uninstall just、rm $(which just)) - lefthook:
lefthook uninstallしてからrm $(which lefthook) - フック:
lefthook uninstallは.git/hooks内のlefthookフックをすべて削除します