Przejdź do głównej zawartości

Wzorce języka Go

Twoja usługa Go obsługuje 200 req/s na deweloperce bez problemu, a potem wywraca się przy pierwszym kontakcie z prawdziwym ruchem. Goroutines, które uruchamiasz per żądanie, nigdy nie kończą działania, pula pgx głoduje, bo nic nie zwalnia połączeń, a brak podpiętego timeoutu kontekstu sprawia, że wolne wywołania do usług downstream piętrzą się, aż maszynie zabraknie pamięci. Kod wygenerowany przez agenta AI wyglądał idiomatycznie — po prostu nie był utwardzony pod produkcję.

Ta receptura jest o tym, jak zrobić Go dobrze za pierwszym razem z Cursor, Claude Code i Codex: o promptach, które od początku proszą o graceful shutdown, ograniczoną współbieżność i limity puli połączeń, plus o promptach uzupełniających, które wyłapują to, o czym pierwsze podejście zapomina.

  • Gotowy do wklejenia prompt, który scaffolduje serwer HTTP na Chi ze strukturalnym logowaniem i graceful shutdown
  • Prompt na worker pool, który ogranicza współbieżność i nigdy nie wycieka goroutine
  • Prompt na repozytorium pgxpool z rozsądnymi limitami puli i wrapperem transakcji wycofującym zmiany przy panice
  • Prompt na testy table-driven (testify + równoległe podtesty) oraz prompt na test integracyjny z testcontainers, korzystający z aktualnego API postgres.Run
  • Prompt na usługę gRPC z interceptorami i health checkami
  • Checklista „Kiedy to się sypie” dla trybów awarii, które kod Go generowany przez AI po cichu wdraża

Zanim poprosisz o kod, daj agentowi plik z regułami, żeby każda sugestia trzymała się Twoich konwencji. Mechanika różni się w zależności od narzędzia, ale treść jest identyczna.

Utwórz .cursor/rules/go.mdc (pojedynczy plik .cursorrules jest przestarzały — Cursor czyta teraz katalog .cursor/rules/*.mdc):

---
description: Go conventions for this service
globs: ["**/*.go"]
alwaysApply: true
---
- Target Go 1.25+; use range-over-func and the standard library where it fits
- Standard layout: cmd/, internal/, no global state
- Wrap errors with fmt.Errorf("context: %w", err); define sentinel errors
- Accept interfaces, return concrete structs
- Every blocking call takes a context.Context as its first arg
- Table-driven tests with t.Parallel(); use testify require/assert

Przypnij aktualny toolchain w go.mod, żeby agent nie sięgał po przestarzałe API:

module github.com/yourorg/yourservice
go 1.25
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/jackc/pgx/v5 v5.8.0
github.com/stretchr/testify v1.10.0
go.uber.org/zap v1.27.1
golang.org/x/sync v0.11.0
)

Największą rzeczą, którą AI psuje za pierwszym razem, jest shutdown: uruchamia serwer w goroutine i zapomina opróżnić żądania w locie. Wymień graceful shutdown w prompcie, a dostaniesz go za darmo.

Model zwraca coś takiego — zwróć uwagę na signal.NotifyContext zamiast ręcznie sklejanego kanału os.Signal, co jest nowoczesnym idiomem:

func main() {
logger, _ := zap.NewProduction()
defer func() { _ = logger.Sync() }()
r := chi.NewRouter()
r.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
r.Route("/api/v1", func(r chi.Router) {
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
})
srv := &http.Server{Addr: ":8080", Handler: r}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Fatal("server failed", zap.Error(err))
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Fatal("shutdown failed", zap.Error(err))
}
}

Jak to ocenić: sprawdź, czy błąd nasłuchiwania jest porównywany przez errors.Is(err, http.ErrServerClosed) (zwykłe != błędnie zaklasyfikuje opakowany błąd) oraz czy shutdown używa świeżego kontekstu — użycie anulowanego ctx natychmiast przerwałoby opróżnianie.

Naiwny prompt „przetwórz te elementy współbieżnie” produkuje nieograniczony fan-out go func(), który pod obciążeniem wyczerpie deskryptory plików albo połączenia do bazy. Poproś wprost o ograniczony worker pool lub errgroup.SetLimit.

func (s *Service) ProcessBatch(ctx context.Context, items []Item) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, item := range items {
g.Go(func() error {
if err := ctx.Err(); err != nil {
return err
}
return s.processItem(ctx, item)
})
}
if err := g.Wait(); err != nil {
return fmt.Errorf("batch processing failed: %w", err)
}
return nil
}

Od Go 1.22 zmienna pętli for jest per-iterację, więc stara linia przechwytująca item := item to martwy kod — jeśli agent ją dodaje, to sygnał, że czerpie z danych treningowych sprzed 1.22. Odpowiedz: „Remove the item := item line; we’re on Go 1.25 where loop vars are per-iteration.”

Wyczerpanie puli połączeń to klasyczny bug „działa na deweloperce, umiera na produkcji”. Lekarstwem jest ustawienie limitów puli w prompcie, a nie odkrywanie ich w trakcie incydentu.

func (r *Repository) WithTx(ctx context.Context, fn func(pgx.Tx) error) (err error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback(ctx)
panic(p) // re-throw after cleanup
}
if err != nil {
_ = tx.Rollback(ctx)
}
}()
if err = fn(tx); err != nil {
return err
}
return tx.Commit(ctx)
}

Jak to ocenić: nazwany zwracany wynik (err error) pozwala odroczonemu rollbackowi zobaczyć błąd funkcji, więc nie potrzebujesz wywołania rollbacku w każdej gałęzi. Jeśli model wpisuje rollback wprost w każdej ścieżce błędu, poproś o skonsolidowanie do formy odroczonej — łatwo inaczej zapomnieć o którejś gałęzi.

Poproś o testy table-driven z równoległymi podtestami i testify oraz wymień przypadki brzegowe, na których Ci zależy — inaczej dostaniesz pokrycie tylko ścieżki sukcesu.

W testach integracyjnych poproś wprost o aktualny punkt wejścia testcontainers postgres.Run — starsze API GenericContainer/RunContainer jest przestarzałe, a agent po nie sięgnie, jeśli nie powiesz inaczej.

Mapowanie błędów domenowych na kody statusu to część, którą trzeba przejrzeć — wygenerowany handler zwracający surowy err ujawnia klientom wewnętrzne szczegóły:

func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
user, err := s.svc.GetUser(ctx, req.GetId())
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, status.Error(codes.NotFound, "user not found")
}
return nil, status.Error(codes.Internal, "internal error")
}
return &pb.User{Id: user.ID, Name: user.Name, Email: user.Email}, nil
}

Prompty powyżej są identyczne we wszystkich trzech narzędziach. Tam, gdzie narzędzia się różnią, to sposób ich uruchamiania:

Używaj trybu Agent do wielo­plikowych scaffoldów (serwer + trasy + repozytorium w jednym przebiegu) oraz edycji inline (Cmd/Ctrl+K) do chirurgicznych poprawek, takich jak zamiana GenericContainer na postgres.Run. Checkpointy pozwalają cofnąć złą wielo­plikową generację jednym kliknięciem. Dla promptów mocno opartych na architekturze — pełny scaffolding usługi, złożone wielo­plikowe refaktoryzacje — używaj Claude Fable 5 (/model fable), gdy szybkość i jakość są ważniejsze niż koszt; Opus 4.8 pozostaje dobrym domyślnym wyborem. Zejdź do Sonnet 4.6 dla mechanicznych przebiegów generowania testów. Szczegóły znajdziesz w porównaniu modeli.

Nawet przy dobrych promptach kod Go generowany przez AI wdraża te tryby awarii. Wypatruj ich w przeglądzie:

  • Wycieki goroutine. go func(), które blokuje się na wysyłce do kanału, którego nikt nie odbiera, albo goroutine per żądanie bez ścieżki wyjścia po ctx, wycieka jedną goroutine na wywołanie. Uruchamiaj z go test -race i dodaj goleak.VerifyTestMain (go.uber.org/goleak), żeby wyłapać wycieki w CI. Prompt uzupełniający: „Audit every go func() in this file for a guaranteed exit path under context cancellation.”
  • Nieprzekazany kontekst. Jeśli handler wywołuje context.Background() głęboko w stosie zamiast przekazać ctx żądania, anulowanie i deadline’y po cichu przestają działać. Przeszukaj kod (grep) pod kątem context.Background() poza main i testami.
  • Wyczerpanie puli. Pominięcie defer rows.Close() albo uruchamianie zapytań na pool wewnątrz transakcji, która trzyma osobne połączenie, zagładza pulę. Objaw: żądania wieszają się na pool.Acquire pod obciążeniem. Ustaw MaxConns świadomie i przetestuj obciążeniowo przed wdrożeniem.
  • Artefakty przechwytywania pętli sprzed 1.22. Jeśli model wypluwa item := item albo for i := range xs { go f(i) } z ręczną kopią, pracuje na przestarzałych danych treningowych. Zmienne pętli per-iterację są domyślne od Go 1.22 — usuń obejście.
  • Błędy porównywane przez == zamiast errors.Is. Opakowane błędy (%w) psują err == ErrNotFound. Poproś agenta, żeby używał errors.Is/errors.As wszędzie i definiował sentinele jako zmienne na poziomie pakietu.
  • Przesłonięte nazwy pakietów. Parametr o nazwie url przesłania pakiet net/url, więc url.Parse(url) się nie skompiluje. Jeśli build pada z „type string has no field or method Parse”, sprawdź, czy parametr nie przesłania importu, i zmień jego nazwę (np. rawURL).

Zastosuj to samo podejście prompt-first do swojej warstwy danych we Wzorcach baz danych albo wdróż tę usługę za pomocą Wzorców Kubernetes.