net/http di Go è pronta per la produzione. Con il routing migliorato di Go 1.22+, il matching per metodo e pattern è integrato. Per la maggior parte dei servizi non serve un framework – ma è necessario configurare correttamente timeout, graceful shutdown e middleware fin dall’inizio.La maggior parte dei tutorial Go su HTTP mostra http.ListenAndServe(":8080", nil) e finisce lì. Quel codice funzionerà, ma perderà goroutine sotto carico, si bloccherà per sempre su client lenti e si spegnerà in modo non controllato durante i deploy. Questo articolo mostra come appare davvero un servizio HTTP Go pronto per la produzione, usando solo la libreria standard per il core, con una nota su quando un router esterno vale la pena.
Il Server di Base con Gestione Corretta degli Errori#
Il primo errore da correggere è ignorare il valore di ritorno di ListenAndServe.
package main
import (
"errors"
"log/slog"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", healthHandler)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
slog.Info("avvio server", "addr", srv.Addr)
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
slog.Error("errore server", "err", err)
}
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}http.ErrServerClosed è il sentinel error restituito quando si chiama server.Shutdown(). Non è un vero errore, quindi lo filtriamo. Qualsiasi altro valore non-nil da ListenAndServe significa che il server non è riuscito a fare il bind o ha avuto un errore fatale in runtime.
Routing Basato su Pattern con Go 1.22+#
Prima di Go 1.22, il ServeMux integrato corrispondeva solo ai prefissi dei path e non aveva routing per metodo. Go 1.22 ha aggiunto pattern metodo+path e parametri di path.
package main
import "net/http"
func registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /users", listUsersHandler)
mux.HandleFunc("POST /users", createUserHandler)
mux.HandleFunc("GET /users/{id}", getUserHandler)
mux.HandleFunc("PUT /users/{id}", updateUserHandler)
mux.HandleFunc("DELETE /users/{id}", deleteUserHandler)
}
func getUserHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// id è il valore catturato da {id} nel pattern
w.Write([]byte("user id: " + id))
}La sintassi {id} cattura un singolo segmento del path. {path...} cattura il resto del path (utile per il serving di file). I prefissi di metodo come GET fanno sì che il mux restituisca automaticamente 405 Method Not Allowed quando il path corrisponde ma il metodo no.
Middleware: Logging e Request ID#
Il middleware in Go è semplicemente una funzione che accetta un http.Handler e restituisce un http.Handler. Nessuna magia, nessuna registrazione, nessun framework.
package main
import (
"context"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
)
type contextKey string
const requestIDKey contextKey = "requestID"
// RequestID inietta un ID univoco nel contesto di ogni richiesta e nell'header di risposta.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := uuid.New().String()
ctx := context.WithValue(r.Context(), requestIDKey, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Logger registra metodo, path, status code e durata per ogni richiesta.
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rw, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rw.statusCode,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", r.Context().Value(requestIDKey),
)
})
}
// responseWriter avvolge http.ResponseWriter per catturare lo status code.
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// Chain applica il middleware in ordine: Chain(h, A, B, C) esegue A(B(C(h))).
func Chain(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
for i := len(middleware) - 1; i >= 0; i-- {
h = middleware[i](h)
}
return h
}Usando Chain nel main:
handler := Chain(mux, RequestID, Logger)
srv := &http.Server{Addr: ":8080", Handler: handler}Timeout del Server#
Il http.Server predefinito non ha timeout. Un client lento o malevolo può tenere le connessioni aperte indefinitamente.
srv := &http.Server{
Addr: ":8080",
Handler: handler,
// Tempo per leggere la richiesta completa incluso il body.
ReadTimeout: 5 * time.Second,
// Tempo per leggere solo gli header della richiesta. Protegge da attacchi con header lenti.
// Deve essere minore o uguale a ReadTimeout.
ReadHeaderTimeout: 2 * time.Second,
// Tempo per scrivere la risposta completa. Contato dalla fine degli header della richiesta.
WriteTimeout: 10 * time.Second,
// Tempo in cui una connessione keep-alive inattiva rimane aperta.
IdleTimeout: 120 * time.Second,
}| Timeout | Cosa copre | Valore tipico |
|---|---|---|
ReadHeaderTimeout | Tempo per ricevere tutti gli header della richiesta | 2-5s |
ReadTimeout | Tempo per ricevere la richiesta completa incluso il body | 5-30s |
WriteTimeout | Tempo per inviare la risposta completa | 5-30s |
IdleTimeout | Durata di vita di una connessione keep-alive inattiva | 60-120s |
Per risposte in streaming o upload/download di lunga durata, WriteTimeout e ReadTimeout devono essere più lunghi o gestiti tramite deadline di contesto per singola richiesta. Impostarli globalmente brevi interromperà operazioni legittime di lunga durata.
Graceful Shutdown#
Quando il tuo container orchestrator invia SIGTERM, il processo dovrebbe smettere di accettare nuove connessioni, completare le richieste in volo e poi uscire in modo pulito.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
registerRoutes(mux)
handler := Chain(mux, RequestID, Logger)
srv := &http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 2 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
// ctx viene cancellato quando si riceve SIGINT o SIGTERM.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
slog.Info("avvio server", "addr", srv.Addr)
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
slog.Error("errore server", "err", err)
}
}()
// Blocca fino al segnale.
<-ctx.Done()
slog.Info("segnale di shutdown ricevuto")
// Dai alle richieste in volo fino a 30 secondi per completarsi.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("errore di shutdown", "err", err)
}
slog.Info("server fermato")
}signal.NotifyContext è il modo idiomatico Go 1.16+ per gestire i segnali OS. La goroutine esegue il server, la goroutine principale attende il segnale, poi chiama Shutdown con una deadline.
Pattern per l’Endpoint di Health Check#
Gli health check sono la prima cosa che un load balancer o una liveness probe di Kubernetes chiamerà. Tienili economici: nessuna query al database, solo una conferma che il processo è attivo.
package main
import (
"encoding/json"
"net/http"
"time"
)
type healthResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
resp := healthResponse{
Status: "ok",
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}Per una readiness probe (che verifica se il servizio è pronto ad accettare traffico, incluse le dipendenze), aggiungi un endpoint separato /ready che controlla la connettività al database o la disponibilità della cache.
Quando Usare una Libreria di Routing#
Il mux di Go 1.22+ gestisce la grande maggioranza dei casi d’uso di routing reali. Considera una libreria come chi o gorilla/mux quando:
- Hai più di ~30 route e vuoi raggruppamenti con middleware condiviso per gruppo.
- Hai bisogno di matching URL oltre ai segmenti esatti (ad es. vincoli regex sui parametri di path).
- Vuoi supporto integrato per l’introspezione delle route o la generazione di OpenAPI.
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUserHandler)
mux.HandleFunc("POST /users", createUserHandler)
// Applica il middleware globalmente
handler := Chain(mux, RequestID, Logger)import "github.com/go-chi/chi/v5"
import "github.com/go-chi/chi/v5/middleware"
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Route("/users", func(r chi.Router) {
r.Get("/{id}", getUserHandler)
r.Post("/", createUserHandler)
r.Group(func(r chi.Router) {
r.Use(authMiddleware) // solo su questo gruppo
r.Delete("/{id}", deleteUserHandler)
})
})Per un nuovo microservizio con una manciata di endpoint, inizia con la stdlib. Migra a chi se e quando la logica di routing diventa abbastanza complessa da giustificarlo.
Se vuoi approfondire uno qualsiasi di questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.