Salta al contenuto principale

Costruire Servizi HTTP in Produzione con Go

·7 minuti
Indice dei contenuti
La libreria standard 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.

main.go
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.

routes.go
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.

middleware.go
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:

main.go
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.

main.go
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,
}
TimeoutCosa copreValore tipico
ReadHeaderTimeoutTempo per ricevere tutti gli header della richiesta2-5s
ReadTimeoutTempo per ricevere la richiesta completa incluso il body5-30s
WriteTimeoutTempo per inviare la risposta completa5-30s
IdleTimeoutDurata di vita di una connessione keep-alive inattiva60-120s
Importante

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.

main.go
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.

handlers.go
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.

**Nessun timeout sul server.** Il valore zero per tutti i campi timeout significa nessun timeout. Un singolo client lento può tenere una goroutine aperta indefinitamente. Imposta sempre almeno `ReadHeaderTimeout` e `WriteTimeout`. **Nessun graceful shutdown.** Senza `Shutdown`, un deploy invia `SIGKILL` che chiude bruscamente tutte le connessioni in volo. I client vedono errori di broken pipe. Implementa il pattern del signal handler fin dal primo giorno. **Stato globale negli handler.** Le funzioni handler devono essere sicure per l'uso concorrente. Qualsiasi stato a cui accedono (connessioni al database, cache, configurazione) deve essere immutabile dopo l'avvio o protetto con un mutex. Passa le dipendenze come campi su un receiver di struct, non come variabili a livello di package. **Non leggere il body della richiesta completamente prima di rispondere.** Se un handler ritorna senza consumare completamente `r.Body`, la connessione TCP non può essere riutilizzata per richieste keep-alive. Usa sempre `io.Copy(io.Discard, r.Body)` alla fine degli handler che non hanno bisogno del body completo, oppure usa `defer r.Body.Close()` in modo consistente. **Ignorare l'errore di `json.NewEncoder(w).Encode()`.** Una volta iniziata la scrittura della risposta, non puoi cambiare lo status code. Codifica la risposta in un buffer prima e scrivi su `w` solo quando sai che la codifica è riuscita.

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.

Articoli correlati