Un e-commerce di esempio con Go e ScyllaDB

In questo articolo costruiremo un e-commerce di esempio utilizzando Go come linguaggio di backend e ScyllaDB come database distribuito. ScyllaDB è un database NoSQL compatibile con Apache Cassandra, scritto in C++, progettato per offrire bassa latenza e altissimo throughput. La combinazione con Go, grazie alla sua concorrenza nativa e alle performance elevate, rende questo stack particolarmente adatto a carichi di lavoro tipici di un negozio online con migliaia di prodotti e utenti concorrenti.

Perché ScyllaDB per un e-commerce

Un e-commerce deve gestire simultaneamente letture frequenti (cataloghi, ricerche, dettagli prodotto) e scritture (ordini, aggiornamenti di stock, carrelli). I database relazionali tradizionali possono diventare un collo di bottiglia quando il traffico cresce. ScyllaDB distribuisce i dati su più nodi tramite un modello a partizioni, garantendo scalabilità orizzontale lineare. Inoltre, il suo modello di consistenza tunable permette di scegliere il giusto compromesso tra latenza e affidabilità per ogni singola operazione.

Preparazione dell'ambiente

Per iniziare, avviamo un'istanza di ScyllaDB tramite Docker. Questo è il modo più rapido per ottenere un ambiente di sviluppo funzionante senza dover installare manualmente il database.

docker run --name scylla-shop -p 9042:9042 -d scylladb/scylla --smp 2

Una volta avviato il container, possiamo collegarci al database utilizzando il client cqlsh incluso nell'immagine:

docker exec -it scylla-shop cqlsh

Creiamo quindi un nuovo progetto Go e inizializziamo il modulo:

mkdir go-scylla-shop
cd go-scylla-shop
go mod init github.com/example/go-scylla-shop
go get github.com/gocql/gocql
go get github.com/gorilla/mux

Progettazione dello schema

In ScyllaDB, a differenza dei database relazionali, lo schema deve essere progettato partendo dalle query che verranno eseguite. Questo principio è noto come query-driven design. Per il nostro e-commerce identifichiamo quattro entità principali: prodotti, utenti, ordini e articoli del carrello.

Creiamo prima il keyspace, che è l'equivalente di un database in SQL:

CREATE KEYSPACE shop
WITH replication = {
    'class': 'SimpleStrategy',
    'replication_factor': 1
};

USE shop;

Ora definiamo le tabelle. La tabella dei prodotti utilizza l'identificativo del prodotto come chiave primaria:

CREATE TABLE products (
    product_id uuid PRIMARY KEY,
    name text,
    description text,
    price decimal,
    stock int,
    category text,
    created_at timestamp
);

Per permettere la ricerca per categoria, creiamo una tabella denormalizzata. In ScyllaDB la denormalizzazione è una pratica comune e incoraggiata:

CREATE TABLE products_by_category (
    category text,
    product_id uuid,
    name text,
    price decimal,
    stock int,
    PRIMARY KEY (category, product_id)
);

La tabella degli ordini usa una chiave composta: l'identificativo utente come partition key e l'identificativo dell'ordine come clustering key, ordinato in modo decrescente per mostrare prima gli ordini più recenti:

CREATE TABLE orders_by_user (
    user_id uuid,
    order_id timeuuid,
    total decimal,
    status text,
    items list<frozen<tuple<uuid, int, decimal>>>,
    created_at timestamp,
    PRIMARY KEY (user_id, order_id)
) WITH CLUSTERING ORDER BY (order_id DESC);

Connessione al database da Go

Il driver più utilizzato per connettersi a ScyllaDB da Go è gocql, che supporta pienamente il protocollo CQL. Creiamo un package dedicato alla gestione della sessione:

package database

import (
    "log"
    "time"

    "github.com/gocql/gocql"
)

// Session contiene la sessione condivisa verso il cluster ScyllaDB
var Session *gocql.Session

// Connect inizializza la connessione al cluster
func Connect(hosts []string, keyspace string) error {
    cluster := gocql.NewCluster(hosts...)
    cluster.Keyspace = keyspace
    cluster.Consistency = gocql.Quorum
    cluster.Timeout = 5 * time.Second
    cluster.ProtoVersion = 4

    session, err := cluster.CreateSession()
    if err != nil {
        return err
    }

    Session = session
    log.Println("Connessione a ScyllaDB stabilita")
    return nil
}

// Close chiude la sessione verso il cluster
func Close() {
    if Session != nil {
        Session.Close()
    }
}

Definizione dei modelli

Definiamo le strutture Go che rappresentano le nostre entità. Ogni campo è mappato a una colonna della tabella corrispondente:

package models

import (
    "time"

    "github.com/gocql/gocql"
    "gopkg.in/inf.v0"
)

// Product rappresenta un prodotto del catalogo
type Product struct {
    ID          gocql.UUID `json:"id"`
    Name        string     `json:"name"`
    Description string     `json:"description"`
    Price       *inf.Dec   `json:"price"`
    Stock       int        `json:"stock"`
    Category    string     `json:"category"`
    CreatedAt   time.Time  `json:"created_at"`
}

// OrderItem rappresenta un singolo articolo di un ordine
type OrderItem struct {
    ProductID gocql.UUID `json:"product_id"`
    Quantity  int        `json:"quantity"`
    UnitPrice *inf.Dec   `json:"unit_price"`
}

// Order rappresenta un ordine effettuato da un utente
type Order struct {
    UserID    gocql.UUID  `json:"user_id"`
    OrderID   gocql.UUID  `json:"order_id"`
    Total     *inf.Dec    `json:"total"`
    Status    string      `json:"status"`
    Items     []OrderItem `json:"items"`
    CreatedAt time.Time   `json:"created_at"`
}

Repository dei prodotti

Il pattern repository ci permette di isolare la logica di accesso ai dati dal resto dell'applicazione. Creiamo un repository per gestire le operazioni sui prodotti:

package repository

import (
    "time"

    "github.com/example/go-scylla-shop/database"
    "github.com/example/go-scylla-shop/models"
    "github.com/gocql/gocql"
)

// CreateProduct inserisce un nuovo prodotto in entrambe le tabelle
func CreateProduct(p *models.Product) error {
    p.ID = gocql.TimeUUID()
    p.CreatedAt = time.Now()

    // Inserimento nella tabella principale
    if err := database.Session.Query(`
        INSERT INTO products (product_id, name, description, price, stock, category, created_at)
        VALUES (?, ?, ?, ?, ?, ?, ?)`,
        p.ID, p.Name, p.Description, p.Price, p.Stock, p.Category, p.CreatedAt,
    ).Exec(); err != nil {
        return err
    }

    // Inserimento nella tabella denormalizzata per categoria
    return database.Session.Query(`
        INSERT INTO products_by_category (category, product_id, name, price, stock)
        VALUES (?, ?, ?, ?, ?)`,
        p.Category, p.ID, p.Name, p.Price, p.Stock,
    ).Exec()
}

// GetProductByID recupera un singolo prodotto tramite identificativo
func GetProductByID(id gocql.UUID) (*models.Product, error) {
    var p models.Product
    err := database.Session.Query(`
        SELECT product_id, name, description, price, stock, category, created_at
        FROM products WHERE product_id = ?`, id,
    ).Scan(&p.ID, &p.Name, &p.Description, &p.Price, &p.Stock, &p.Category, &p.CreatedAt)

    if err != nil {
        return nil, err
    }
    return &p, nil
}

// GetProductsByCategory recupera tutti i prodotti di una categoria
func GetProductsByCategory(category string) ([]models.Product, error) {
    var products []models.Product
    iter := database.Session.Query(`
        SELECT product_id, name, price, stock
        FROM products_by_category WHERE category = ?`, category,
    ).Iter()

    var p models.Product
    p.Category = category
    for iter.Scan(&p.ID, &p.Name, &p.Price, &p.Stock) {
        products = append(products, p)
    }

    if err := iter.Close(); err != nil {
        return nil, err
    }
    return products, nil
}

Gestione degli ordini

La creazione di un ordine è un'operazione più complessa perché deve aggiornare lo stock dei prodotti e registrare i dati dell'ordine. In ScyllaDB possiamo usare le batch per raggruppare più operazioni, anche se è importante ricordare che non offrono le stesse garanzie transazionali dei database relazionali:

package repository

import (
    "time"

    "github.com/example/go-scylla-shop/database"
    "github.com/example/go-scylla-shop/models"
    "github.com/gocql/gocql"
    "gopkg.in/inf.v0"
)

// CreateOrder crea un nuovo ordine per l'utente indicato
func CreateOrder(userID gocql.UUID, items []models.OrderItem) (*models.Order, error) {
    orderID := gocql.TimeUUID()
    total := inf.NewDec(0, 0)

    // Calcolo del totale dell'ordine
    for _, item := range items {
        lineTotal := new(inf.Dec).Mul(item.UnitPrice, inf.NewDec(int64(item.Quantity), 0))
        total.Add(total, lineTotal)
    }

    // Conversione degli articoli in tuple per ScyllaDB
    tupleItems := make([][]interface{}, len(items))
    for i, item := range items {
        tupleItems[i] = []interface{}{item.ProductID, item.Quantity, item.UnitPrice}
    }

    order := &models.Order{
        UserID:    userID,
        OrderID:   orderID,
        Total:     total,
        Status:    "pending",
        Items:     items,
        CreatedAt: time.Now(),
    }

    err := database.Session.Query(`
        INSERT INTO orders_by_user (user_id, order_id, total, status, items, created_at)
        VALUES (?, ?, ?, ?, ?, ?)`,
        order.UserID, order.OrderID, order.Total, order.Status, tupleItems, order.CreatedAt,
    ).Exec()

    if err != nil {
        return nil, err
    }
    return order, nil
}

// GetOrdersByUser restituisce gli ordini di un utente in ordine cronologico inverso
func GetOrdersByUser(userID gocql.UUID) ([]models.Order, error) {
    var orders []models.Order
    iter := database.Session.Query(`
        SELECT user_id, order_id, total, status, created_at
        FROM orders_by_user WHERE user_id = ?`, userID,
    ).Iter()

    var o models.Order
    for iter.Scan(&o.UserID, &o.OrderID, &o.Total, &o.Status, &o.CreatedAt) {
        orders = append(orders, o)
    }

    if err := iter.Close(); err != nil {
        return nil, err
    }
    return orders, nil
}

Esposizione di un'API HTTP

Utilizziamo gorilla/mux per esporre gli endpoint REST. Il file principale dell'applicazione si occupa di collegare tutti i componenti:

package main

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

    "github.com/example/go-scylla-shop/database"
    "github.com/example/go-scylla-shop/models"
    "github.com/example/go-scylla-shop/repository"
    "github.com/gocql/gocql"
    "github.com/gorilla/mux"
)

func main() {
    // Connessione al cluster ScyllaDB
    if err := database.Connect([]string{"127.0.0.1"}, "shop"); err != nil {
        log.Fatal(err)
    }
    defer database.Close()

    router := mux.NewRouter()
    router.HandleFunc("/products", createProductHandler).Methods("POST")
    router.HandleFunc("/products/{id}", getProductHandler).Methods("GET")
    router.HandleFunc("/categories/{category}/products", listByCategoryHandler).Methods("GET")
    router.HandleFunc("/users/{id}/orders", createOrderHandler).Methods("POST")
    router.HandleFunc("/users/{id}/orders", listOrdersHandler).Methods("GET")

    log.Println("Server in ascolto sulla porta 8080")
    log.Fatal(http.ListenAndServe(":8080", router))
}

func createProductHandler(w http.ResponseWriter, r *http.Request) {
    var p models.Product
    if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    if err := repository.CreateProduct(&p); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(p)
}

func getProductHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id, err := gocql.ParseUUID(vars["id"])
    if err != nil {
        http.Error(w, "Identificativo non valido", http.StatusBadRequest)
        return
    }

    product, err := repository.GetProductByID(id)
    if err != nil {
        http.Error(w, "Prodotto non trovato", http.StatusNotFound)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(product)
}

func listByCategoryHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    products, err := repository.GetProductsByCategory(vars["category"])
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(products)
}

Considerazioni sulle performance

Quando si lavora con ScyllaDB è importante tenere presente alcuni principi. Le query devono sempre specificare la partition key, altrimenti ScyllaDB dovrà interrogare tutti i nodi del cluster, con un impatto notevole sulle performance. Le scansioni complete delle tabelle vanno evitate in produzione.

Il livello di consistenza va scelto in base al caso d'uso: per la lettura di un catalogo prodotti, ONE è spesso sufficiente e garantisce la massima velocità. Per la creazione di un ordine, invece, QUORUM offre un buon compromesso tra affidabilità e latenza. Le operazioni che richiedono atomicità forte, come la verifica dello stock prima di confermare un acquisto, possono utilizzare le lightweight transactions tramite la clausola IF, ricordando però che hanno un costo in termini di latenza.

Conclusioni

Abbiamo visto come costruire le fondamenta di un e-commerce utilizzando Go e ScyllaDB. La combinazione offre ottime performance, scalabilità orizzontale e un modello di sviluppo relativamente semplice. Gli aspetti chiave da ricordare sono la progettazione query-driven dello schema, l'uso consapevole della denormalizzazione e la scelta appropriata del livello di consistenza per ogni operazione. Da qui si può estendere il progetto aggiungendo autenticazione, gestione del carrello con TTL automatico, sistemi di pagamento e meccanismi di caching per ridurre ulteriormente il carico sul database.