API REST in Go: fondamenti e primo server

API REST in Go: fondamenti e primo server

Questo articolo apre una serie di sette puntate dedicate alla costruzione di API REST in Go. Partiremo dalle fondamenta, con un semplice server basato sulla libreria standard, e arriveremo progressivamente a un'applicazione completa dotata di persistenza, middleware, autenticazione e test. Il filo conduttore sarà sempre lo stesso: un catalogo di libri, che faremo crescere articolo dopo articolo. In questa prima puntata ci concentriamo sui concetti architetturali di REST e sul funzionamento del pacchetto net/http.

Che cosa significa REST

REST, acronimo di Representational State Transfer, non è un protocollo né una libreria, ma uno stile architetturale definito da Roy Fielding nel 2000. Un'API che aderisce a questo stile espone delle risorse identificate da URL e le manipola attraverso i metodi del protocollo HTTP. L'idea centrale è che il server non conserva lo stato della conversazione con il client: ogni richiesta contiene tutte le informazioni necessarie per essere elaborata. Questa proprietà, detta stateless, rende le API REST facili da scalare orizzontalmente, perché qualsiasi istanza del server è in grado di rispondere a qualsiasi richiesta.

Una risorsa è un'entità concettuale del dominio applicativo: nel nostro caso, un libro. Ogni risorsa è raggiungibile tramite un identificatore univoco, l'URL, e le operazioni su di essa vengono espresse dai metodi HTTP. La corrispondenza tipica tra i metodi e le operazioni CRUD (Create, Read, Update, Delete) è la seguente:

  • Il metodo GET recupera una rappresentazione della risorsa senza modificarla.
  • Il metodo POST crea una nuova risorsa all'interno di una collezione.
  • Il metodo PUT sostituisce integralmente una risorsa esistente.
  • Il metodo PATCH applica una modifica parziale a una risorsa.
  • Il metodo DELETE rimuove la risorsa.

A ogni richiesta il server risponde con un codice di stato numerico che ne comunica l'esito. I codici nella famiglia 2xx indicano successo, quelli 4xx segnalano un errore imputabile al client (per esempio una risorsa inesistente o dati malformati), mentre quelli 5xx indicano un problema interno del server. Rispettare la semantica di metodi e codici di stato è ciò che distingue un'API realmente RESTful da un semplice insieme di endpoint HTTP.

Perché Go per le API REST

Go è particolarmente adatto alla scrittura di servizi HTTP. Il pacchetto net/http della libreria standard è maturo, performante e sufficiente per costruire API di produzione senza dipendenze esterne. Il modello di concorrenza basato sulle goroutine consente al server di gestire un numero elevato di connessioni simultanee con un consumo di memoria contenuto: ogni richiesta in arrivo viene servita da una goroutine dedicata, senza che il programmatore debba gestire esplicitamente un pool di thread. A questo si aggiungono la compilazione in un singolo binario statico, che semplifica enormemente il deployment, e tempi di avvio pressoché istantanei.

Il server minimo

Cominciamo con il programma più semplice possibile: un server che risponde a una singola richiesta. Creiamo un file main.go e scriviamo il codice seguente.

package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    // Registriamo una funzione che gestisce le richieste dirette a "/"
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Il server delle API funziona")
    })

    log.Println("Server in ascolto su http://localhost:8080")

    // ListenAndServe avvia il server e resta in ascolto sulla porta 8080.
    // Restituisce un errore solo se il server non riesce ad avviarsi.
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}

Compiliamo ed eseguiamo il programma con go run main.go. Aprendo il browser all'indirizzo http://localhost:8080 vedremo comparire il messaggio di benvenuto. In poche righe abbiamo un server HTTP funzionante, senza alcuna libreria di terze parti.

Analizziamo i due elementi fondamentali. La funzione http.HandleFunc associa un pattern di percorso a una funzione gestore. La funzione http.ListenAndServe apre un socket sulla porta indicata e mette il programma in attesa delle connessioni in ingresso; il secondo argomento, qui impostato a nil, indica che vogliamo usare il router predefinito del pacchetto, noto come DefaultServeMux.

Handler e HandlerFunc

Il cuore di net/http è l'interfaccia Handler, che dichiara un unico metodo.

type Handler interface {
    ServeHTTP(w http.ResponseWriter, r *http.Request)
}

Qualsiasi tipo che implementi questo metodo può gestire una richiesta HTTP. Nella pratica, però, definire un tipo per ogni gestore sarebbe scomodo. Per questo la libreria offre il tipo http.HandlerFunc, un adattatore che trasforma una comune funzione con la firma appropriata in un Handler. È esattamente ciò che accade dietro le quinte quando usiamo http.HandleFunc.

I due parametri del gestore sono i protagonisti di ogni interazione. Il primo, di tipo http.ResponseWriter, è l'interfaccia attraverso la quale scriviamo la risposta: le intestazioni, il codice di stato e il corpo. Il secondo, un puntatore a http.Request, contiene tutti i dati della richiesta in arrivo: il metodo, l'URL, le intestazioni e il corpo inviato dal client.

Un primo endpoint significativo

Restituire testo semplice è poco interessante per un'API. Le API REST comunicano quasi sempre attraverso JSON. Modifichiamo il gestore in modo che risponda con un oggetto JSON, impostando correttamente l'intestazione Content-Type e il codice di stato.

package main

import (
    "encoding/json"
    "log"
    "net/http"
)

func main() {
    http.HandleFunc("/health", healthHandler)

    log.Println("Server in ascolto su http://localhost:8080")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}

// healthHandler risponde con lo stato di salute del servizio.
func healthHandler(w http.ResponseWriter, r *http.Request) {
    // Dichiariamo al client che il corpo della risposta e in formato JSON
    w.Header().Set("Content-Type", "application/json")

    // Impostiamo esplicitamente il codice di stato 200 OK
    w.WriteHeader(http.StatusOK)

    // Costruiamo la risposta come una mappa e la codifichiamo in JSON
    response := map[string]string{
        "status":  "ok",
        "service": "catalogo-libri",
    }
    json.NewEncoder(w).Encode(response)
}

Interroghiamo l'endpoint con curl dal terminale.

curl -i http://localhost:8080/health

La risposta conterrà l'intestazione Content-Type: application/json, il codice di stato 200 e il corpo {"status":"ok","service":"catalogo-libri"}. Abbiamo appena costruito il tipico endpoint di health check, quello che i sistemi di orchestrazione interrogano per verificare che il servizio sia attivo.

È importante notare l'ordine delle operazioni all'interno del gestore. Le intestazioni vanno impostate prima di chiamare WriteHeader, perché una volta scritto il codice di stato le intestazioni vengono inviate e non possono più essere modificate. Analogamente, WriteHeader va chiamato prima di scrivere il corpo. Se ometti la chiamata, Go la esegue automaticamente con il codice 200 al primo tentativo di scrittura del corpo, ma affidarsi a questo comportamento implicito rende il codice meno leggibile.

Configurare un server esplicito

La funzione http.ListenAndServe è comoda per gli esempi, ma in un'applicazione reale conviene istanziare direttamente una struttura http.Server. In questo modo possiamo configurare parametri fondamentali per la robustezza del servizio, come i timeout delle richieste.

package main

import (
    "log"
    "net/http"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/health", healthHandler)

    // Configuriamo esplicitamente il server con timeout ragionevoli.
    // I timeout proteggono il servizio da connessioni lente o malevole.
    server := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    log.Println("Server in ascolto su http://localhost:8080")
    if err := server.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

Qui abbiamo introdotto due elementi. Il primo è http.NewServeMux, che crea un router dedicato invece di affidarsi a quello globale predefinito: usare un mux esplicito evita conflitti di registrazione e rende il codice più testabile. Il secondo è la struttura http.Server con i suoi timeout. Il ReadTimeout limita il tempo concesso per leggere l'intera richiesta, il WriteTimeout il tempo per scrivere la risposta, mentre l'IdleTimeout chiude le connessioni persistenti rimaste inattive. Trascurare questi valori è uno degli errori più comuni nei servizi Go messi in produzione, perché espone il server a un facile esaurimento delle risorse.

Lo spegnimento controllato

Un servizio ben scritto deve potersi arrestare senza interrompere bruscamente le richieste in corso. Go offre il metodo Shutdown, che smette di accettare nuove connessioni e attende il completamento di quelle attive entro un limite di tempo. Combinandolo con l'ascolto dei segnali del sistema operativo otteniamo uno spegnimento pulito.

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/health", healthHandler)

    server := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 10 * time.Second,
    }

    // Avviamo il server in una goroutine separata cosi da non bloccare
    // il flusso principale, che rimane libero di attendere i segnali.
    go func() {
        log.Println("Server in ascolto su http://localhost:8080")
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()

    // Restiamo in attesa di un segnale di interruzione o terminazione
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("Spegnimento in corso...")

    // Concediamo fino a dieci secondi alle richieste in corso per completare
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        log.Fatal("spegnimento forzato:", err)
    }
    log.Println("Server arrestato correttamente")
}

Il meccanismo è il seguente: il server viene avviato in una goroutine, mentre il flusso principale si blocca sulla lettura del canale quit. Quando l'utente preme Ctrl+C, o quando l'orchestratore invia il segnale SIGTERM, il canale riceve un valore e il programma prosegue chiamando Shutdown. Nota che al termine dell'operazione ListenAndServe restituisce l'errore sentinella http.ErrServerClosed, che non è un vero errore e va quindi ignorato esplicitamente.

Conclusione

In questa prima puntata abbiamo posto le basi concettuali di REST e costruito lo scheletro di un server HTTP robusto in Go, utilizzando esclusivamente la libreria standard. Abbiamo visto l'interfaccia Handler, il ruolo di ResponseWriter e Request, la configurazione esplicita di http.Server con i timeout e lo spegnimento controllato. Nella prossima puntata affronteremo il routing: impareremo a instradare le richieste in base al metodo e al percorso, a estrarre i parametri dagli URL e a leggere il corpo delle richieste, cominciando finalmente a modellare la nostra risorsa "libro".