Go Entwicklungsstack
Meinungsstarkes, modernes Go-Entwicklungssetup. Ein Werkzeug pro Aufgabe, null Überschneidungen.
Wann verwenden
- Starten eines neuen Go-Projekts von Grund auf
- Hinzufügen von Linting-, Formatierungs- oder Testinfrastruktur
- Einrichten von CI/CD für einen Go-Dienst oder eine Bibliothek
- Erstellen einer Justfile, um ein Makefile zu ersetzen
- Hinzufügen von Datenbank-Migrationstooling
- Migration von verstreuten gofmt/govet/staticcheck-Aufrufen zu einer einheitlichen Einrichtung
Nicht für einen Fork, der regelmäßig von Upstream gemerged wird: Das Ersetzen seines Makefiles und das Neuformatieren des Baums mit gofumpt führt bei jedem Merge zu Konflikten und vergräbt echte Änderungen in einem Reformats-Diff. Behalte dort das gofmt und Build-Tooling von Upstream bei und füge nur Checks hinzu.
Der Stack
| Tool | Version | Rolle | Ersetzt |
|---|---|---|---|
| Go | 1.27+ | Sprache, Toolchain, go mod, go fix | - |
| golangci-lint | v2.14+ | Meta-Linter (100+ Linter + Formatierer + fmt-Befehl) | gofmt, govet, staticcheck, errcheck separat ausführen |
| gofumpt | v0.12+ | Strenger Formatierer (Obermenge von gofmt, 19 Standardregeln) | gofmt |
| gotestsum | v1.13+ | Test-Runner mit lesbarer Ausgabe, Watch-Modus, JUnit-XML | Reines go test |
| just | 1.58+ | Aufgaben-Runner | Makefile |
| golang-migrate | v4.20+ | DB-Migrationen (CLI + Bibliothek + embed.FS) | Manuelle SQL-Skripte |
| lefthook | v2.1+ | Git-Hooks (einzelnes Binary, parallel) | pre-commit (Python) |
Versionsuntergrenzen sind tragend. golangci-lint "unterstützt Go-Versionen, die niedriger oder gleich der Go-Version sind, die für die Kompilierung verwendet wurde" - eine Untergrenze, die älter als deine Go-Toolchain ist, schlägt sofort fehl. Go 1.27-Unterstützung wurde in golangci-lint v2.13.0 eingeführt, daher ist v2.13 die Untergrenze für ein Go 1.27-Projekt; diese Skill legt v2.14.0 fest, die erste Version, die gofumpt v0.12.0 bündelt. Zwei weitere Untergrenzen wurden kürzlich verschoben: gofumpt v0.12.0 "basiert auf Go 1.27s gofmt und erfordert Go 1.26 oder neuer", und der go install-Pfad von lefthook verlangt jetzt Go 1.26+.
Schnellstart: Neues Projekt
# 1. Modul erstellen
mkdir myapp && cd myapp
go mod init github.com/yourorg/myapp
# 2. Verzeichnisse anlegen
mkdir -p cmd/myapp internal migrations
# 3. golangci-lint als Binary installieren, nicht als Modul-Tool (siehe Hinweis unten)
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.14.0
# 4. Den Rest in go.mod nachverfolgen (Go 1.24+ tool-Direktive). Versionen festlegen - niemals @latest,
# da dies das Tool bei jedem CI-Lauf neu kompiliert und zwischen Maschinen abweicht.
go get -tool mvdan.cc/[email protected]
go get -tool gotest.tools/[email protected]
# golang-migrate benötigt ein Build-Tag, daher direkt installieren
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/[email protected]
# 5. Konfigurationsdateien erstellen (Vorlagen unten)
# 6. Ausführen: just check
Installiere golangci-lint nicht über das Tools-Muster. Upstream ist eindeutig: "Die Verwendung von go install/go get, "tools pattern" und tool-Befehl/Direktive-Installationen sind nicht garantiert funktionsfähig. Wir empfehlen die Binary-Installation." Der Grund, warum das in einem gemeinsam genutzten Repository wichtig ist, ist Dependency-Bleeding - "die Abhängigkeiten eines Tools können die Abhängigkeiten eines anderen Tools oder deines Projekts ändern". Wenn es unbedingt in go.mod sein muss, isoliere es mit einem eigenen -modfile - siehe die golangci-lint Referenz.
go get -tool verfolgt; go tool führt aus. Die tool-Direktive zeichnet die Abhängigkeit in go.mod auf, legt aber nichts auf deinem PATH ab. Entweder über die Toolchain aufrufen - go tool gofumpt -l ., go tool gotestsum --format testname - oder einmal go install tool ausführen, um $(go env GOPATH)/bin zu befüllen. Die Justfile unten ruft die reinen Binaries auf, geht also vom go install tool-Weg aus (oder einer Systeminstallation via Homebrew - aber "Homebrew kann eine unerwartete Go-Version zum Erstellen des Binaries verwenden" für golangci-lint, und es kann keine Version festlegen). Beachte, dass go tool gegen das Modul im aktuellen Verzeichnis auflöst - "zusätzliche Tools können in der go.mod des aktuellen Moduls definiert sein" - daher schlägt es in einem Monorepo mit go: no such tool "..." fehl, es sei denn, das Rezept setzt [working-directory(...)].
Zwei Go-Befehlsverhaltensweisen, die vor dem ersten Commit wissenswert sind:
go mod initunter einer 1.N-Toolchain schreibtgo 1.(N-1).0, nicht1.N- "Das Ausführen vongo mod initmit einer Toolchain der Version1.N.Xerstellt einego.mod-Datei, die die Go-Versiongo 1.(N-1).0angibt." Erhöhe die Direktive bewusst, wenn du 1.N-Sprachfunktionen möchtest.- Setze eine
toolchain go1.27.1-Zeile ingo.mod, aber wisse, dass es eine Untergrenze und keine Festlegung ist: Unter dem StandardGOTOOLCHAIN=autowechselt dergo-Befehl nur "wenn ...<tname>neuer als die Standard-Go-Toolchain ist", daher gewinnt eine neuere lokalego. Für eine exakte Toolchain setzeGOTOOLCHAIN=go1.27.1(CI odergo env -w);GODEBUG=toolchaintrace=1 go versionzeigt, welche ausgewählt wurde. Halte die Zeile auf dem aktuellen Patch, nicht auf.0: Sie wählt dasgoaus, gegen dasgovulncheckstdlib-Advisories auf jeder Maschine mit einer älteren eigenengoscannt. Ein veralteter Patch macht CI von selbst rot - siehe Footguns unten.
.golangci.yml
version: "2"
run:
timeout: 5m
build-tags:
- integration # sonst werden Dateien hinter dem Integration-Tag der Justfile nie gelintet
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 aktiviert `unhandled-error`, das `fmt.Println` in main meldet.
# Unter enable-all-rules werden die `arguments` einer Regel ignoriert (die Regel
# registriert sich zweimal), daher funktioniert hier keine Whitelist - nur `disabled` wirkt.
- 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:
# Regeln einzeln auswählen. `extra-rules: true` ist veraltet und schaltet auch
# `balance_calls` ein, das gofumpt selbst als kontrovers herabgestuft hat.
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
# ── Code-Qualität ──────────────────────────────────────────
# Den gesamten Go-Code formatieren
[group('quality')]
fmt:
golangci-lint fmt ./...
# Formatierung prüfen ohne Änderung (CI-sicher)
[group('quality')]
fmt-check:
golangci-lint fmt --diff ./...
# Linter ausführen
[group('quality')]
lint:
golangci-lint run ./...
# Linter mit automatischer Korrektur ausführen
[group('quality')]
lint-fix:
golangci-lint run --fix ./...
# Schwachstellenprüfung durchführen
[group('quality')]
vuln:
govulncheck ./...
# ── Tests ───────────────────────────────────────────────
# Alle Tests mit Race-Erkennung ausführen
[group('test')]
test *args="./...":
gotestsum --format testname -- -race {{ args }}
# Tests mit Coverage ausführen
[group('test')]
test-cov:
gotestsum --format testname -- -race -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -func=coverage.out
# Coverage-Bericht im Browser öffnen
[group('test')]
coverage: test-cov
go tool cover -html=coverage.out
# Integrationstests ausführen
[group('test')]
test-integration:
gotestsum --format testname -- -race -tags=integration ./...
# Tests während der Entwicklung beobachten
[group('test')]
test-watch:
gotestsum --watch --watch-clear --format testname
# Benchmarks ausführen
[group('test')]
bench:
go test -bench=. -benchmem ./...
# ── Build ─────────────────────────────────────────────────
# Das Binary bauen
[group('build')]
build:
go build -o {{ binary }} ./cmd/{{ binary }}
# Optimiertes Release-Binary bauen
[group('build')]
build-release:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o {{ binary }} ./cmd/{{ binary }}
# ── Abhängigkeiten ──────────────────────────────────────────
# Module aufräumen und verifizieren
[group('deps')]
tidy:
go mod tidy
go mod verify
# Fehlschlagen, wenn go.mod/go.sum unordentlich sind, ohne sie zu berühren (CI-sicher)
[group('deps')]
tidy-check:
go mod tidy -diff
# Code-Generatoren ausführen
[group('deps')]
generate:
go generate ./...
# ── Datenbank ──────────────────────────────────────────────
# Alle ausstehenden Migrationen anwenden
[group('db')]
migrate-up:
migrate -path migrations -database "$DATABASE_URL" up
# Letzte Migration rückgängig machen
[group('db')]
migrate-down:
migrate -path migrations -database "$DATABASE_URL" down 1
# Neue Migration erstellen
[group('db')]
migrate-create name:
migrate create -ext sql -dir migrations -seq {{ name }}
# ── CI ────────────────────────────────────────────────────
# Vollständiges CI-Gate (Format-Prüfung + Lint + Test)
[group('ci')]
check: fmt-check lint test
@echo "Alle Prüfungen bestanden"
# Build-Artefakte bereinigen
[group('ci')]
clean:
go clean
rm -f {{ binary }} coverage.out
check lässt bewusst vuln aus: govulncheck ruft das Netzwerk auf, und ein Update der Verwundbarkeitsdatenbank kann das Gate bei einer Änderung rot färben, die nichts berührt. Führe es stattdessen als eigenen CI-Job aus. golangci-lint run führt auch die aktivierten Formatierer aus und meldet deren Probleme, daher dupliziert fmt-check diese Prüfung - behalte es trotzdem wegen des lesbaren Diffs.
Lefthook-Konfiguration
Lefthook wird pre-commit für Go-Projekte vorgezogen - es ist ein einzelnes Go-Binary, führt Hooks parallel aus und benötigt kein Python.
go install github.com/evilmartians/lefthook/[email protected] # benötigt Go 1.26+
lefthook install
# lefthook.yml
assert_lefthook_installed: true # laut fehlschlagen, anstatt jede Regel zu überspringen
pre-commit:
piped: true # fail fast - beim ersten fehlschlagenden Job anhalten
commands:
fmt:
glob: "*.go"
run: golangci-lint fmt {staged_files}
stage_fixed: true
lint:
glob: "*.go"
# Niemals eine reine Dateiliste an `golangci-lint run` übergeben: Eine Liste über zwei
# Verzeichnisse hinweg wird sofort abgelehnt, und eine Datei eines Pakets mit mehreren
# Dateien meldet phantomhafte `undefined:`-Typcheck-Fehler. Lint stattdessen die Pakete.
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 ist fail-fast, keine Reihenfolge - lefthook "führt Befehle und Skripte standardmäßig sequenziell aus", und piped fügt hinzu: "Stoppe die Ausführung von Befehlen und Skripten, wenn einer von ihnen fehlschlägt." Es kann nicht mit parallel: true kombiniert werden.
jobs: (hinzugefügt in lefthook 1.10.0) ist das neuere Primitiv neben der commands:/scripts:-Aufteilung - "Jobs bieten eine flexible Möglichkeit, Aufgaben zu definieren, die sowohl Befehle als auch Skripte unterstützen. Jobs können für eine erweiterte Flusssteuerung gruppiert werden." commands: ist nicht veraltet und bleibt vollständig dokumentiert; greife zu jobs:, wenn du Gruppierung, verschachtelte Flusssteuerung oder eine Mischung aus Inline-Befehlen und Skripten in einem Hook benötigst.
Vier weitere, die es wert sind, verdrahtet zu werden:
assert_lefthook_installed: true, oben, ist das Gegenmittel zur Dormanz-Footgun unten: "fehlschlagen (mit Exit-Status 1), wenn die ausführbare Dateilefthooknicht im $PATH gefunden werden kann".lefthook validatein CI fängt eine fehlerhaftelefthook.ymlab, bevor sie Hooks stillschweigend deaktiviert;lefthook dumpgibt die zusammengeführte wirksame Konfiguration aus, wenn sich ein Hook nicht wie geschrieben verhält.- Eine git-vergessene
lefthook-local.ymlermöglicht es einem Entwickler, Jobs hinzuzufügen oder zu überspringen, ohne sie Teamkollegen aufzuzwingen - "Dies ist nützlich, wenn du lefthook lokal verwenden möchtest, ohne es deinen Teamkollegen aufzuzwingen." - Gib in einem Monorepo jedem Job ein
root:, das auf sein Modulverzeichnis zeigt; ohne dieses laufengo mod tidyundgo toolgegen das Repository-Root und schlagen fehl. skip: [merge, rebase]bei denmod-tidy- und lint-Jobs hält sie von Commits fern, die während eines Merges oder Rebase erstellt werden, sodass das Auflösen von Konflikten nicht auch ein tidy-rewrite oder--fix-Bearbeitungen neu einstaged.
Der pre-push-Testsuite wiederholt das, was CI bereits ausführt. Sobald es Minuten dauert, entferne es oder beschränke es auf geänderte Pakete - andernfalls lernen Entwickler, mit LEFTHOOK=0 zu pushen. Tests, die von einem Hook ausgeführt werden, erben auch die Hook-Umgebung von git (GIT_DIR, GIT_INDEX_FILE, GIT_WORK_TREE), sodass ein Test-Helfer, der git init oder git commit in t.TempDir() ausführt, auf dem eigentlichen Repository arbeitet - lösche diese Variablen in cmd.Env.
Ein konfliktfreier git merge führt niemals pre-commit aus: git ruft stattdessen pre-merge-commit auf und führt pre-commit nur aus, wenn du einen Konflikt-Merge mit git commit abschließt. Spiegle die Prüfungen unter einem pre-merge-commit:-Schlüssel, oder Merges landen ungelintet.
Hook-Befehle lösen Tools aus dem PATH des Aufrufers auf, daher schlägt ein fehlendes Binary den Commit mit einem bloßen exit 127 fehl. Die Ausführung als go tool <name> (tool-Direktive) macht den Hook so reproduzierbar wie den Build.
Beta, aber wissenswert: ai: deklariert LLM-Agent-Hooks in derselben Datei - "Während lefthook install generiert lefthook die anbieterspezifische Einstellungsdatei, sodass der Agent lefthook run <hook> aufruft, wenn ...
[truncated for translation]