- Published on
Come architetturo un backend
- Authors
- Name
- Giovanni Orciuolo
- @WolfrostFM
Dopo più di 3 anni rieccomi sul mio blog! Oggi voglio condividere con voi il mio personale modo di progettare un applicativo puramente backend. Dato che amo definirmi full-stack, scriverò un post a parte per quanto riguarda il frontend. Forse. In futuro, comunque. Alla fine non mi piace così tanto il frontend, dai.
Ci tengo a precisare che la metodologia che andrò a descrivere non è di mia invenzione, anzi, deriva dalle pratiche standard di varie aziende (serie si intende, non boomer) con un pizzico di personalità aggiuntiva nei vari linguaggi, derivante dalla mia esperienza lavorativa di ormai quasi 6 anni (come passa il tempo, sono vecchio).
I fondamentali
Iniziamo dalle basi. Cos'è un frontend e cos'è un backend?
Qualsiasi applicativo esistente espone un "fronte" all'utente che permette a quest'ultimo di interagire con le funzionalità del programma. Pensa al portale home banking della tua banca, ad esempio, oppure a un social network come Twitter (che ormai sta diventando una merda ma sorvoliamo). Tutte queste componenti puramente grafiche (UI) e di interazione (UX) fanno parte del frontend. In questo articolo non tratterò di questa parte, ma di ciò che sta dietro a ogni funzionalità dell'applicativo, che viene comunemente chiamato "backend". Ad esempio, quando dal frontend dell'home banking inviamo un bonifico a qualcuno, cosa succede dietro le quinte?
Così come ho definito i termini è un po' troppo generico, dunque vado a essere più specifico: in questo articolo andrò a parlare di backend pensati per comunicare con applicativi web (le cosiddette web application o web-app). Può non sembrare ma così facendo ho ristretto di molto il nostro focus: le web app possono essere usate esclusivamente tramite un browser (oppure sacrificando l'anima a Satana usando un framework tipo Electron per ottenere una normale app desktop installabile).
L'applicativo che gira sul browser comunica con il backend tramite il protocollo HTTP. Questo protocollo si basa a sua volta sul TCP, dunque prevede una singola richiesta (Request) a fronte di una singola risposta (Response). Molto semplice no?
Tutto ciò che (ad alto livello) il backend fa è, dunque:
- Avviarsi (eseguendo la funzione main, banalmente): qui viene fatto il setup delle varie componenti del sistema (ad esempio connettersi a un database o costruire il container per la dependency injection, o ancora aprire connessioni WebSocket. Insomma tanta roba).
- Mettersi in ascolto di richieste su una determinata porta di rete (network port): questa può essere decisa arbitrariamente, ammesso che non sia già occupata da un altro programma in esecuzione sulla macchina.
- Quando arriva una richiesta HTTP: decidere quale funzione invocare e rispondere. Qui si apre una vera e propria cornucopia di metodologie e standard diversi per ottenere il medesimo risultato (REST, SOAP, GraphQL, tRPC, gRPC, fino ad arrivare a roba custom boomerissima che fa accaponare la pelle...). Io seguo un singolo standard: REST, che sarà il focus di questo articolo.
- Quando arriva un segnale di chiusura (ad esempio SIGKILL): Eseguire il graceful shutdown, ossia chiudere tutte le connessioni aperte con servizi vari allo scopo di non lasciare appeso nulla (leaking/dangling references). Questo è importante per evitare che il nostro server esploda dopo tanti riavvii. Ma tanto ormai è tutto cloud no? Maledetti...
Andiamo a porre il focus sul terzo punto, gli altri punti potranno essere trattati in articoli a parte.
Perché usare REST?
La domanda sorge quasi spontanea: "Giovanni, ma perché tra tutti gli standard possibili da utilizzare usi proprio questo benedetto REST?". Beh, caro lettore, lo standard REST è semplicemente il migliore per la tipologia di applicativi che di solito sviluppo.
Questi applicativi sono (solitamente) dei semplici CRUD con dietro della logica business più o meno complessa, quindi ho bisogno di un modo per interfacciarmi con le entità del database in maniera standardizzata e trasparente all'utente finale dell'API (che di solito sono io stesso dal frontend).
Qui lo standard REST interviene in mio soccorso perché basa la metodologia con cui viene scelta la funzione da invocare su 2 elementi: il verbo della chiamata (GET/POST/PUT/PATCH/DELETE) e il suo URI (Universal Resource Identifier). Questo rende la mia API molto leggibile e intuitiva:
GET /products/12
Seguendo il solo standard REST so già che questa chiamata ritornerà la risorsa Product con l'identificativo univoco nel database 12 (questo prende il nome di path parameter), oppure errore 404 nel caso di risorsa non trovata, oppure 5XX (500 o 502 in genere) in caso di server non disponibile o errore interno al database. La cosa veramente figa è che quella che ho scritto sopra (al netto dei vari headers che vanno a comporre le righe successive) è già una chiamata HTTP perfettamente valida!
La potenza dello standard REST è proprio la sua espressività e semplicità. Solamente seguendolo posso evitare di scrivere pagine su pagine di documentazione PDF che andrà a finire chissà dove. Una vera sconfitta per i boomer!
Vediamo altri esempi:
POST /products
Content-Type: application/json
Authorization: Bearer ey...
{
"name": "Mela Melinda",
"quantity": 2,
"price": 5
}
Con questa chiamata (omettendo alcuni header secondari) posso creare un nuovo articolo sul sistema. Notare come l'utente finale dell'API non ha idea di quale sia il database sottostante, o se addirittura sia presente o no un database. Questa trasparenza aiuta molto nella composizione di sistemi distribuiti.
Ci ho infilato anche un Authorization header, che consente di identificare l'utente che sta effettuando la chiamata in maniera autenticata. L'header Content-Type serve a specificare che il payload è in formato JSON. Il JSON è sempre da preferirsi perché più snello ed espressivo delle alternative boomer (prima tra tutti quella merda fumante di XML). Anche la risposta sarà in formato JSON, e sarà l'entità stessa appena creata sul database inclusa di identificativo e altre proprietà scritte dal backend.
Nota: lo standard REST non definisce quale transport usare, detto questo U̶S̵A̵T̸E̷ ̴J̵S̶O̸N̴ ̴O̶ ̴C̴O̷S̶E̸ ̶O̷R̴R̴I̵P̸I̵L̷A̴N̷T̵I̷ ̸A̵C̵C̵A̵D̶R̵A̶N̵N̶O̷ ̴A̷I̸ ̶V̷O̷S̵T̸R̶I̴ ̶P̷R̸I̵M̸O̸G̵E̵N̴I̵T̷I̷. Siete avvisati.
PUT /products/10
Content-Type: application/json
Authorization: Bearer ey...
{
"name": "Banana Chikita",
"quantity": 10,
"price": 2
}
Con questa chiamata vado ad aggiornare l'articolo con identificativo 10. Dato che è una PUT, devo passare l'intera entità in quanto va a fare un replace intero di tutte le proprietà. Se volessi invece fare un update parziale, userei il verbo PATCH. A volte questa piccolezza non viene seguita, quindi a quel punto occorre fare riferimento alla documentazione. La response sarà ancora una volta il JSON contenente l'entità aggiornata così come appare adesso sul database.
Concludiamo con l'immancabile eliminazione:
DELETE /products/10
Authorization: Bearer ey...
Semplice e veloce. Sto eliminando l'articolo con identificativo 10. Più facile di così... La risposta è uno status code 204 No Content
, oppure un 200 OK
che contiene l'entità appena eliminata, ad indicare che la risorsa è stata eliminata correttamente.
Il resto dei verbi (OPTIONS/HEAD/TRACE, etc...) si usano per operazioni un po' più tecniche dunque non vado a spiegarli anche perché questo articolo sta già diventando una rottura da leggere. Comunque sono quasi fuori dallo standard REST, che non li usa per interagire direttamente con le risorse (ad esempio OPTIONS viene usato dal browser per gestire il CORS).
Ricapitolando:
- GET per recuperare una o più risorse
- POST per creare una risorsa
- PUT/PATCH per aggiornare una risorsa
- DELETE per eliminare una risorsa
Tutte insieme, questi endpoint vanno a formare il famoso CRUD (Create / Read / Update / Delete), tipo pezzi di Exodia. Non sono simpatico.
Notare come lo standard, seppur semplice, permette di eseguire tutte le operazioni che ci interessano senza dover stare ad inventare uno standard nostro che non usa nessuno e che deve pure essere documentato.
Nota: Le operazioni che non ricadono strettamente sulla gestione delle risorse ma che causano qualsiasi effetto collaterale (es. creano un file sulla macchina) usano il verbo POST, e sono comunque posizionate sotto la risorsa sulla quale intervegono.
Ad esempio se volessi stampare la scheda di un articolo in formato PDF (la chiamata scrive un PDF su disco), penserei una chiamata di questo tipo:
POST /products/{id}/pdf
La struttura mentale da imparare è: seguire il path partendo dalla radice. Partendo dal primo /, incontriamo products
, dunque sappiamo che ci troviamo nel contesto di "articoli". In seguito, è presente un identificativo, quindi stiamo andando sul dettaglio dell'articolo con quell'identificativo. Infine, incontriamo pdf
, dunque vogliamo generare un PDF.
Notate come il path stesso ci parla e ci dice esattamente cosa vogliamo fare? Che potenza assurda REST...
Un esempio da non fare assolutamente: POST /products/print/{id}
. Il primo errore è usare il verbo print. Il verbo è già POST, il resto è puramente sostantivo.
Altri orrori: PUT /print-product-pdf
. Qui il verbo è sbagliato (deve essere POST, non sto aggiornando niente), inoltre abbiamo completamente ucciso lo standard REST perché non stiamo più usando la struttura che abbiamo imparato. Dove passo l'id? Devo per forza leggere la documentazione, che palle...
In aggiunta all'URI è possibile specificare dei query parameters, che sarebbero tutti i parametri aggiuntivi che vengono dopo il ?
nell'URI. Molto utile quando ad esempio voglio specificare opzioni di paginazione:
GET /products?skip=0&limit=10
Questa chiamata recupera i primi 10 articoli. Posso anche comporre oggetti complessi in questo modo:
GET /products?pagination[skip]=0&pagination[limit]=10
Che va a mappare a questo JSON:
{
"pagination": {
"skip": 0,
"limit": 10
}
}
Di solito queste conversioni le fa il framework che adoperiamo quindi non serve scrivere alcuna funzione custom per interpretare i query params (non serve reinventare la ruota).
Consiglio di approfondire lo standard REST partendo dalla pagina Wikipedia, è un vero rabbit hole di conference talk e blog posts quindi siete avvisati. Comunque il modo migliore per impararlo è usarlo quindi andiamo a vedere com'è fatto un mio backend tipico!
Prima di passare alla struttura del backend, una piccola ricapitolazione degli status code in risposta alle varie richieste:
- 2XX indica uno stato di successo. Non si deve mai ritornare un codice 2XX in caso di chiamata andata in errore. MAI. Banalmente perché è un controsenso.
200 OK
indica che nel body della risposta si possono trovare dei dettagli (es. la risorsa recuperata dal database).201 Created
indica che è stata creata una nuova entità come risultato della chiamata. Tipicamente usato in risposta alle richieste POST di creazione.202 Accepted
usato molto di rado, indica che la richiesta è stata accettata correttamente ma che non è ancora possibile stabilire se è andata a buon fine o no (poiché è andata in pasto a un processo asincrono o sarà processata in batch tra qualche ora).204 No Content
indica che il body della risposta è vuoto. Di solito viene usato in eliminazione, oppure se si invia una chiamata per cui non ci interessa avere nulla in risposta, solo se è andata bene o no.206 Partial Content
indica che il body contiene solo una risposta parziale. Utile nello streaming di file di grandi dimensioni. Si legge l'headerRange
per sapere il range di byte che stiamo leggendo, allo scopo di comporre il file finale. Il component<video>
di HTML5 è in grado di gestire autonomamente questo tipo di risposte, quindi si ha lo streaming!
- 3XX indica un redirect. Usare l'header
Location
per sapere dove è stata spostata la risorsa. Molti browser seguono automaticamente fino a un tot di redirect. - 4XX indica uno stato di errore dovuto a una richiesta malformata. Si usa esclusivamente se l'errore è causato dal client che invia i dati in maniera erronea o non conforme alle specifiche.
400 Bad Request
indica che la richiesta è genericamente malformata o sbagliata in qualche modo. Solitamente il body contiene dettagli sull'errore e cosa fare per correggere il payload.401 Unauthorized
indica che non è possibile operare sulla risorsa in quanto non ci si è autenticati nel sistema, oppure il token di autenticazione non è valido.402 Payment Required
questo è abbastanza sperimentale, lo userei con parsimonia. Indica che l'operazione non è effettuabile per mancanza di fondi (un pagamento è richiesto per accedere alla risorsa).403 Forbidden
indica che la richiesta non può essere effettuata in quanto non si hanno abbastanza permessi (esempio l'operazione è effettuabile solo da utenti amministratori). Se compare indica che comunque il token di autenticazione è corretto e l'utente risulta autenticato, solo che non ha i permessi.404 Not Found
il più famoso! Indica che la risorsa non è stata trovata.405 Method Not Allowed
indica che il verbo non è corretto (esempio si aspettava POST e invece ha ricevuto GET).408 Request Timeout
la richiesta è andata in timeout. Tipico di quando ci si interfaccia con sistemi boomer!409 Conflict
la richiesta è andata in errore in quanto va in conflitto con lo stato attuale del server (ad esempio il numero di telefono del cliente è già registrato su un altro cliente).413 Payload Too Large
la richiesta supera i limiti massimi di grandezza consentiti (di solito impostati a livello proxy).418 I'm a teapot
è letteralmente un pesce d'aprile.422 Unprocessable Content
è praticamente un 400 che ci ha creduto abbastanza. La richiesta è ben formata ma non è processabile per via di errori semantici.423 Locked
indica che l'accesso alla risorsa è stato bloccato. Utile credo?429 Too Many Requests
è un grande classico! Indica che il server non è più in grado di fornire risposta al tuo IP in quanto hai superato il limite di richieste consentite nella finestra temporale.
- 5XX indica uno stato di errore dovuto al server. Si usa esclusivamente se è il server ad essere andato in errore per qualche motivo. Attenzione: se il database va in errore per via del client e non mappiamo bene questa condizione, potremmo avere un 500 ma dovuto al client.
500 Internal Server Error
è l'errore più generico di questo pianeta. Indica che il server è andato in errore. Fine.502 Bad Gateway
di solito questo errore lo tira il proxy ad indicare che ha perso il contatto con l'upstream (ossia il server o i server sottostanti).503 Service Unavailable
indica che il servizio non è disponibile per via di manutenzione o disastro. L'headerRetry-After
dovrebbe comunicare il numero di secondi da aspettare prima di riprovare a effettuare una richiesta. Sarebbe buona etichetta seguirlo ma pochi lo fanno e continuano a spammare chiamate.504 Gateway Timeout
indica che il proxy è andato in timeout.
Il resto ve li potete studiare da qui.
La struttura del backend
Senza andarci a impelagare sui dettagli implementativi di linguaggio di programmazione X, vediamo la struttura ad alto livello di un backend che implementa un'API REST.
Pensiamo alla richiesta HTTP POST /products
con un payload che indica i dettagli dell'articolo da creare, e seguiamo il suo percorso all'interno del backend.
SOLO IN PRODUZIONE: Lo strato zero, il reverse proxy
Questo strato viene incontrato esclusivamente in ambiente di produzione (ossia l'ambiente in cui operano i clienti finali reali). Si occupa di varie cose, tra cui:
- Gestire il rate limiting delle richieste IP-based (impedisce attacchi di tipo DDoS)
- Gestire il ban di IP malevoli (bad IPs, ossia IP provenienti da botnet di hacker molto cattivi)
- Scegliere il server migliore a cui rilanciare la richiesta (su Nginx questi si chiamano upstream) seguendo vari algoritmi (round-robin semplice, comunemente)
Io sono solito usare Nginx, ma tra le alternative troviamo HAProxy, Caddy e Apache.
Primo strato: il router
Se il backend viene eseguito in locale, la richiesta raggiunge direttamente questo strato (banalmente perché è inutile proxare). Viene comunemente gestito dalla libreria o framework backend di nostra scelta, e decide a quale delle nostre routes "lanciare" la richiesta. Le routes sono definite a livello di codice in svariate maniere.
La richiesta si ferma qui e va subito in 404 o 405 qualora non fosse trovata alcuna route in grado di rispettare il verbo o l'URI. Potete pensare le route come a delle vere e proprie "strade" che la richiesta potrebbe intraprendere.
Nel nostro caso di esempio, dato che il verbo è POST
e il path è /products
, si cerca una route che rispecchi queste condizioni. Una volta trovata, viene invocato il metodo del controller corrispondente.
Prima di proseguire, però, vengono invocati i middleware registrati sulla route, che vanno a modificare la richiesta (ad esempio effettuando il parsing del JSON) o controllare alcune precondizioni prima di proseguire. I middleware possono essere scritti da noi oppure importati da una libreria. Un esempio classico è il controllo sull'autenticazione andando a leggere l'header Authorization
e ritornando 401 in caso di token non valido o scaduto. Nota: in contesto Spring i middleware prendono il nome di filter.
Secondo strato: il controller
Il controller si occupa di gestire la richiesta e di fornire una risposta. Non deve eseguire alcuna business logic, al massimo deve occuparsi di controllare alcune precondizioni che non sono state controllate a livello di middleware (ad esempio fornire risposta 400 in caso di payload malformato, oppure predisporre un file a essere ritornato correttamente al chiamante).
Una volta controllato che sia tutto ok, passa la palla al service sottostante. Nel nostro caso, sarà il ProductService
a intervenire.
Terzo strato: il service
I service sono il cuore pulsante del sistema. Devono essere quanto più possibile decoupled (ossia non devono dipendere troppo da loro). In questo punto dell'applicativo viene eseguita tutta la famosa business logic. Ma cos'è questa business logic? Semplicemente sono tutte le logiche che portano valore al cliente finale (e sono solitamente automazioni di processi esistenti nella vita reale, di cui si è fatta l'analisi a priori).
Il service è sempre "agganciato" a un repository sottostante che si occupa di scrivere sul database vero e proprio.
Nel nostro caso, viene invocato il metodo di creazione di un articolo, che va ad invocare a sua volta il ProductRepository
.
Quarto strato: il repository
Il repository è l'unico in grado di comunicare direttamente con il database. Nessun altro può farlo, e solo i service possono dipendere dai repository. Rendendo l'implementazione del repository trasparente rispetto all'esterno è virtualmente possibile cambiare database layer in ogni momento (anche se nella pratica questo è molto difficile per via delle leaky abstractions). Questo è utile se vogliamo andare a cambiare implementazione in fase di test (ad esempio per usare un database in-memory rispetto a uno reale).
I model
I model descrivono le entità del database (vengono anche chiamati DAO - Data Access Object). Di solito sono dichiarati rispetto all'ORM in uso, quindi dipendono molto sia dal linguaggio che dalla libreria. L'ORM è una libreria che semplifica la comunicazione con il database, fornendo strumenti molto utili allo scopo di non effettuare query direttamente sul DB. Questo si paga in performance, ma in cambio abbiamo più sicurezza, più consistenza e più semplicità (che garantisce anche una migliore Developer Experience, per me molto importante). Alcuni ORM sono anche in grado di gestire le migration autonomamente, un toccasana per lo sviluppo su SQL.
Indipendentemente dal linguaggio usato, questi sono i componenti che più o meno sono sempre presenti in ogni mio backend.
Ok, qualche esempio di implementazione?
Volevo usare questo articolo per mettere su delle basi solide sull'aspetto puramente "teorico" della progettazione architetturale di un backend. Nel prossimo post andremo ad esplorare aspetti puramente pratici e creeremo un vero backend funzionante.
Alla prossima!
Appendice A - I miei framework/librerie predilette per sviluppo backend
- TypeScript su Node.js -> Express (Fastify lo sto ancora provando)
- Java -> Ovviamente Spring
- Golang -> Gin (ma sono tutti più o meno validi)
- Rust -> Axum
- Elixir -> Phoenix
Appendice B - I miei ORM prediletti
- TypeScript su Node.js -> Mongoose (per MongoDB) / Prisma (per PostgreSQL)
- Java -> Hibernate (per Spring)
- Golang -> Ent e gorm
- Rust -> Diesel
- Elixir -> Ecto
Sugli altri linguaggi non ne ho ancora provati abbastanza a fondo da avere opinioni solide. Aggiornerò il post in futuro.
Uso solo PostgreSQL se si tratta di lavorare con database relazionali. Questa è la via.