API REST in Go: persistenza con database/sql
L'archivio in memoria che abbiamo usato finora ha un difetto evidente: i dati svaniscono a ogni riavvio del server. In questa puntata introduciamo una persistenza reale, collegando l'API a un database relazionale attraverso il pacchetto database/sql della libreria standard. Vedremo come gestire la connessione, eseguire query in sicurezza con i prepared statement, mappare le righe sulle nostre struct e organizzare il tutto secondo il pattern repository, che isola l'accesso ai dati dal resto dell'applicazione. Useremo PostgreSQL come motore, ma i concetti valgono per qualsiasi database supportato.
L'astrazione database/sql
Il pacchetto database/sql non parla direttamente con alcun database: definisce un'interfaccia generica e delega la comunicazione a un driver specifico, importato separatamente. Questo disaccoppiamento permette di cambiare motore modificando poche righe. Per PostgreSQL useremo il driver pgx nella sua modalità compatibile con database/sql.
go get github.com/jackc/pgx/v5
Il driver si registra attraverso un import anonimo, il cui unico scopo è eseguire la funzione init del pacchetto, che aggiunge il driver al registro di database/sql.
import (
"database/sql"
// Import anonimo: registra il driver senza usarne direttamente i simboli
_ "github.com/jackc/pgx/v5/stdlib"
)
Aprire la connessione
La funzione sql.Open non stabilisce immediatamente una connessione: crea un pool di connessioni pigro, che verranno aperte alla bisogna. Per verificare subito che il database sia raggiungibile usiamo PingContext. È buona pratica configurare i limiti del pool, per evitare che l'applicazione saturi le connessioni disponibili sul server.
// OpenDB apre il pool di connessioni verso il database e ne verifica
// la raggiungibilita.
func OpenDB(dsn string) (*sql.DB, error) {
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
// Configuriamo i limiti del pool di connessioni
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
// Verifichiamo la connessione con un timeout, cosi da fallire in fretta
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, err
}
return db, nil
}
Il DSN, ovvero la stringa di connessione, ha una forma come postgres://utente:password@localhost:5432/catalogo?sslmode=disable. In produzione non va mai scritta nel codice: la si legge da una variabile d'ambiente, come vedremo nell'ultima puntata dedicata al deployment.
Lo schema della tabella
Definiamo la tabella che ospiterà i libri. La colonna id è una chiave primaria autoincrementante, mentre imponiamo alcuni vincoli di integrità direttamente a livello di database.
CREATE TABLE books (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
author TEXT NOT NULL,
year INTEGER NOT NULL,
isbn TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Il pattern repository
Anziché disseminare le query SQL nei gestori HTTP, le concentriamo in un tipo dedicato, il repository, che espone metodi dal significato chiaro come Insert, GetByID e List. In questo modo la logica HTTP non sa nulla del database, e viceversa; potremmo in futuro sostituire PostgreSQL con un altro motore, o addirittura con l'archivio in memoria, senza toccare i gestori. Cominciamo dalla definizione del repository e dal metodo di inserimento.
// BookRepository incapsula l'accesso ai dati dei libri.
type BookRepository struct {
db *sql.DB
}
// NewBookRepository costruisce un repository sul pool di connessioni dato.
func NewBookRepository(db *sql.DB) *BookRepository {
return &BookRepository{db: db}
}
// Insert crea un nuovo libro e restituisce la riga completa,
// compresi i valori generati dal database.
func (r *BookRepository) Insert(ctx context.Context, b Book) (Book, error) {
// La clausola RETURNING recupera in un solo giro i valori generati
// dal database, evitando una seconda query.
const query = `
INSERT INTO books (title, author, year, isbn)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at`
err := r.db.QueryRowContext(ctx, query,
b.Title, b.Author, b.Year, b.ISBN,
).Scan(&b.ID, &b.CreatedAt)
if err != nil {
return Book{}, err
}
return b, nil
}
Due aspetti sono degni di nota. I segnaposto $1, $2 e seguenti sono prepared statement: i valori vengono passati separatamente dalla query, e questo è l'unico modo corretto per prevenire l'iniezione SQL. Non concatenare mai i valori nella stringa della query. La clausola RETURNING, specifica di PostgreSQL, ci consente di recuperare l'identificatore e la data di creazione generati dal database nella stessa operazione di inserimento.
Leggere una singola riga
Il metodo che recupera un libro per identificatore usa QueryRowContext, adatto quando ci aspettiamo al più una riga. Dobbiamo distinguere con cura il caso in cui la riga non esiste, che il pacchetto segnala con l'errore sentinella sql.ErrNoRows, dagli errori reali.
// ErrBookNotFound viene restituito quando un libro non esiste.
var ErrBookNotFound = errors.New("libro non trovato")
// GetByID recupera un libro per identificatore.
func (r *BookRepository) GetByID(ctx context.Context, id int64) (Book, error) {
const query = `
SELECT id, title, author, year, isbn, created_at
FROM books
WHERE id = $1`
var b Book
err := r.db.QueryRowContext(ctx, query, id).Scan(
&b.ID, &b.Title, &b.Author, &b.Year, &b.ISBN, &b.CreatedAt,
)
// Traduciamo l'errore sentinella del pacchetto in un errore di dominio
if errors.Is(err, sql.ErrNoRows) {
return Book{}, ErrBookNotFound
}
if err != nil {
return Book{}, err
}
return b, nil
}
La traduzione di sql.ErrNoRows nel nostro ErrBookNotFound è una scelta di design importante: il gestore HTTP non deve conoscere i dettagli del pacchetto database/sql, ma solo gli errori di dominio definiti dal repository. Questo mantiene i livelli dell'applicazione ben separati.
Leggere più righe
Per la lista usiamo QueryContext, che restituisce un iteratore di righe. È fondamentale iterare fino in fondo, chiamare Close per rilasciare la connessione al pool e, al termine, controllare rows.Err per intercettare eventuali errori sopraggiunti durante l'iterazione.
// List restituisce tutti i libri ordinati per identificatore.
func (r *BookRepository) List(ctx context.Context) ([]Book, error) {
const query = `
SELECT id, title, author, year, isbn, created_at
FROM books
ORDER BY id`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
// Il defer garantisce il rilascio della connessione al termine
defer rows.Close()
// Inizializziamo la slice per ottenere [] e non null in caso di lista vuota
books := make([]Book, 0)
for rows.Next() {
var b Book
if err := rows.Scan(
&b.ID, &b.Title, &b.Author, &b.Year, &b.ISBN, &b.CreatedAt,
); err != nil {
return nil, err
}
books = append(books, b)
}
// Controlliamo eventuali errori emersi durante l'iterazione
if err := rows.Err(); err != nil {
return nil, err
}
return books, nil
}
Aggiornare ed eliminare
Le operazioni di modifica e cancellazione usano ExecContext, che non restituisce righe ma un risultato da cui possiamo ricavare il numero di righe interessate. Questo numero ci permette di capire se l'operazione ha effettivamente trovato la risorsa.
// Delete elimina un libro e segnala se non esisteva.
func (r *BookRepository) Delete(ctx context.Context, id int64) error {
const query = `DELETE FROM books WHERE id = $1`
result, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return err
}
// Se nessuna riga e stata eliminata, il libro non esisteva
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrBookNotFound
}
return nil
}
Collegare il repository ai gestori
Il gestore HTTP ora dialoga con il repository invece che con l'archivio in memoria. Nota come propaga il context della richiesta: se il client interrompe la connessione, il context viene cancellato e la query in corso viene automaticamente abortita, liberando le risorse del database.
func getBook(repo *BookRepository) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "identificatore non valido")
return
}
// Propaghiamo il context della richiesta fino al database
book, err := repo.GetByID(r.Context(), id)
if errors.Is(err, ErrBookNotFound) {
writeError(w, http.StatusNotFound, "libro non trovato")
return
}
if err != nil {
log.Printf("errore nel recupero del libro: %v", err)
writeError(w, http.StatusInternalServerError, "errore interno")
return
}
writeJSON(w, http.StatusOK, book)
}
}
La propagazione del context è un dettaglio che fa la differenza sotto carico: senza di essa, una query lenta continuerebbe a occupare una connessione anche dopo che il client ha rinunciato ad attendere la risposta. Passando r.Context() a ogni chiamata del repository colleghiamo il ciclo di vita delle query a quello della richiesta HTTP.
Conclusione
La nostra API ora conserva i dati in un database relazionale. Abbiamo visto come aprire e configurare il pool di connessioni, come usare i prepared statement per prevenire l'iniezione SQL, come mappare righe su struct con Scan, come distinguere l'assenza di dati dagli errori reali e come organizzare il tutto nel pattern repository che tiene separati i livelli dell'applicazione. Abbiamo inoltre sottolineato l'importanza di propagare il context fino al database. Nella prossima puntata affronteremo i middleware, i componenti che ci permettono di aggiungere in modo trasversale funzionalità come logging, gestione degli errori e controllo degli accessi a tutti gli endpoint.