035.3 Lezione 1
Certificazione: |
Web Development Essentials |
---|---|
Versione: |
1.0 |
Argomento: |
035 Programmazione Server con Node.js |
Obiettivo: |
035.3 Fondamenti di SQL |
Lezione: |
1 di 1 |
Introduzione
Anche se è possibile scrivere le proprie funzioni per implementare la memorizzazione persistente, può essere più conveniente usare un sistema di gestione di database per accelerare lo sviluppo e assicurare una migliore sicurezza e stabilità per i dati in formato tabella. La strategia più popolare per memorizzare i dati organizzati in tabelle correlate, specialmente quando queste tabelle sono pesantemente interrogate e aggiornate, è quella di installare un database relazionale che supporti lo Structured Query Language (SQL), un linguaggio orientato ai database relazionali. Node.js supporta diversi sistemi di gestione di database SQL. Seguendo i principi di portabilità ed esecuzione nello spazio utente adottati da Node.js Express, SQLite è una scelta appropriata per la memorizzazione.
SQL
Lo Structured Query Language (SQL) è specifico per i database. Le operazioni di scrittura e di lettura sono espresse in frasi chiamate statement e query. Sia gli statement sia le query sono costituiti da clausole, che definiscono le condizioni di esecuzione dell’operazione.
Nomi e indirizzi e-mail, per esempio, possono essere memorizzati in una tabella del database che contiene i campi nome e email. Un database può contenere diverse tabelle, quindi ogni tabella deve avere un nome unico. Se usiamo il nome contacts
per la tabella dei nomi e delle email, un nuovo record può essere inserito con la seguente dichiarazione (statement):
INSERT INTO contacts (name, email) VALUES ("Carol", "carol@example.com");
Questa dichiarazione di inserimento è composta dalla clausola INSERT INTO
, che definisce la tabella e i campi in cui i dati saranno inseriti. La seconda clausola, VALUES
, imposta i valori che saranno inseriti. Non è necessario scrivere in maiuscolo le clausole, ma è una pratica comune, in modo da riconoscere meglio le parole chiave SQL all’interno di una dichiarazione o di una query.
Una query sulla tabella contacts è fatta in modo simile, ma usando la clausola SELECT
:
SELECT email FROM contacts;
dave@example.com
carol@example.com
In questo caso, la clausola SELECT
email seleziona un campo dalle voci della tabella contacts. La clausola WHERE
restringe la query a righe specifiche:
SELECT email FROM contacts WHERE name = "Dave";
dave@example.com
SQL ha molte altre clausole, e ne vedremo alcune nelle sezioni successive. Ma prima è necessario vedere come integrare il database SQL con Node.js.
SQLite
SQLite è probabilmente la soluzione più semplice per incorporare le caratteristiche del database SQL in un’applicazione. A differenza di altri popolari sistemi di gestione di database, SQLite non è un server di database a cui un client si connette. SQLite fornisce un insieme di funzioni che permettono allo sviluppatore di creare un database come un file convenzionale. Nel caso di un server HTTP implementato con Node.js Express, questo file si trova solitamente nella stessa directory dello script del server.
Prima di usare SQLite in Node.js, è necessario installare il modulo sqlite3
. Esegui il seguente comando nella directory di installazione del server; cioè la directory che contiene lo script Node.js che eseguirai.
$ npm install sqlite3
Sii consapevole che ci sono diversi moduli che supportano SQLite, come better-sqlite3
, il cui uso è leggermente diverso da sqlite3
. Gli esempi in questa lezione sono per il modulo sqlite3
, quindi potrebbero non funzionare come previsto se scegliete un altro modulo.
Accedere al Database
Per dimostrare come un server Node.js Express può lavorare con un database SQL, scriviamo uno script che memorizza e visualizza i messaggi inviati da un client identificato da un cookie. I messaggi sono inviati dal client tramite il metodo HTTP POST e la risposta del server può essere formattata come JSON o HTML (da un template) a seconda del formato richiesto dal client. Questa lezione non entrerà nel dettaglio dell’uso dei metodi HTTP, dei cookie e dei template. Le parti di codice mostrate qui presuppongono che tu abbia già un server Node.js Express dove queste caratteristiche sono configurate e disponibili.
Il modo più semplice per memorizzare i messaggi inviati dal client è quello di memorizzarli in un array globale, dove ogni messaggio precedentemente inviato è associato a una chiave di identificazione unica per ogni client. Questa chiave può essere inviata al client come un cookie, che viene presentato al server per recuperare i suoi messaggi precedenti nelle richieste future .
Tuttavia, questo approccio ha una debolezza: poiché i messaggi sono memorizzati solo in un array globale, tutti i messaggi saranno persi quando la sessione corrente del server verrà terminata. Questo è uno dei vantaggi di lavorare con i database, perché i dati sono memorizzati in modo persistente e non vengono persi se il server viene riavviato.
Usando il file index.js
come script principale del server possiamo incorporare il modulo sqlite3
e indicare il file che serve come database nella seguente maniera:
const sqlite3 = require('sqlite3')
const db = new sqlite3.Database('messages.sqlite3');
Se non esiste già, il file messages.sqlite3
verrà creato nella stessa directory del file index.js
. All’interno di questo singolo file, saranno memorizzate tutte le strutture e i rispettivi dati. Tutte le operazioni sul database eseguite nello script saranno intermediate dalla costante db
, che è il nome dato al nuovo oggetto sqlite3
che apre il file messages.sqlite3
.
Struttura di una Tabella
Nessun dato può essere inserito nel database finché non viene creata almeno una tabella. Le tabelle vengono create con l’istruzione CREATE TABLE
:
db.run('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid CHAR(36), message TEXT)')
Il metodo db.run()
è usato per eseguire istruzioni SQL nel database. L’istruzione stessa è scritta come parametro per il metodo. Anche se le istruzioni SQL devono terminare con un punto e virgola quando sono inserite in un programma a riga di comando, il punto e virgola è opzionale nelle istruzioni passate come parametri in un programma.
Poiché il metodo run
verrà eseguito ogni volta che lo script viene lanciato con node index.js
, l’istruzione SQL include la clausola condizionale IF NOT EXISTS
per evitare errori nelle esecuzioni future quando la tabella messages
esisterà già.
I campi che compongono la tabella messages
sono id
, uuid
e message
. Il campo id
è un intero unico usato per identificare ogni voce nella tabella, quindi è creato come PRIMARY KEY
. Le chiavi primarie non possono essere nulle e non ce ne possono essere due identiche nella stessa tabella. Pertanto, quasi ogni tabella SQL ha una chiave primaria per tracciare il contenuto della tabella. Anche se è possibile scegliere esplicitamente il valore per la chiave primaria di un nuovo record (purché non esista ancora nella tabella), è conveniente che la chiave sia generata automaticamente. Il flag AUTOINCREMENT
nel campo id
è usato a questo scopo.
Note
|
L’impostazione esplicita delle chiavi primarie in SQLite è opzionale, perché SQLite stesso crea automaticamente una chiave primaria. Come indicato nella documentazione di SQLite: “In SQLite, le righe di una tabella hanno normalmente un intero firmato a 64 bit |
I campi uuid
e message
memorizzano rispettivamente l’identificazione del cliente e il contenuto del messaggio. Un campo di tipo CHAR(36)
memorizza una quantità fissa di 36 caratteri, e un campo di tipo TEXT
memorizza testi di lunghezza arbitraria.
Inserimento dei Dati
La funzione principale del nostro server di esempio è quella di memorizzare i messaggi che sono collegati al client che li ha inviati. Il client invia il messaggio nel campo message
all’interno del corpo della richiesta inviata con il metodo HTTP POST
. L’identificazione del client è in un cookie chiamato uuid
. Con queste informazioni, possiamo scrivere quanto necessario per inserire nuovi messaggi nel database:
app.post('/', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined )
uuid = uuidv4()
// Insert new message into the database
db.run('INSERT INTO messages (uuid, message) VALUES (?, ?)', uuid, req.body.message)
// If an error occurrs, err object contains the error message.
db.all('SELECT id, message FROM messages WHERE uuid = ?', uuid, (err, rows) => {
let expires = new Date(Date.now());
expires.setDate(expires.getDate() + 30);
res.cookie('uuid', uuid, { expires: expires })
if ( req.headers.accept == "application/json" )
res.json(rows)
else
res.render('index', {title: "My messages", rows: rows})
})
})
Questa volta il metodo db.run()
esegue una dichiarazione di inserimento, ma si noti che sia uuid
sia req.body.message
non sono scritti direttamente nella linea di dichiarazione. Invece, i punti interrogativi sono stati sostituiti per i valori. Ogni punto interrogativo corrisponde ad un parametro che segue lo statement SQL nel metodo db.run()
.
Usare i punti interrogativi come marcatore nell’istruzione che viene eseguita nel database rende più facile per SQLite distinguere tra gli elementi statici dell’istruzione e i suoi dati variabili. Questa strategia permette a SQLite di eliminare o pulire il contenuto delle variabili che sono parte dello statement, prevenendo una comune violazione della sicurezza chiamata SQL injection. In questo attacco, utenti malintenzionati inseriscono dichiarazioni SQL nei dati variabili nella speranza che le dichiarazioni vengano eseguite inavvertitamente: la “pulizia” sventa l’attacco disabilitando i caratteri pericolosi nei dati.
Query
Come mostrato nel codice di esempio, il nostro intento è quello di utilizzare lo stesso metodo per inserire nuovi messaggi nel database e per generare la lista dei messaggi precedentemente inviati. Il metodo db.all()
restituisce l’insieme di tutte le voci della tabella che corrispondono ai criteri definiti nella query.
A differenza delle istruzioni eseguite da db.run()
, db.all()
genera una lista di record che sono gestiti dalla funzione arrow (⇒) designata nell’ultimo parametro:
(err, rows) => {}
Questa funzione, a sua volta, prende due parametri: err
e rows
. Il parametro err
viene utilizzato se si verifica un errore che impedisce l’esecuzione della query. In caso di successo, tutti i record sono disponibili nell’array rows
, dove ogni elemento è un oggetto corrispondente a un singolo record della tabella. Le proprietà di questo oggetto corrispondono ai nomi dei campi indicati nella query: uuid
e message
.
L’array rows
è una struttura dati JavaScript. Come tale, può essere usato per generare risposte con metodi forniti da Node.js Express, come res.json()
e res.render()
. Quando viene mostrato all’interno di un template EJS, un ciclo convenzionale può elencare tutti i record:
<ul>
<% rows.forEach( (row) => { %>
<li><strong><%= row.id %></strong>: <%= row.message %></li>
<% }) %>
</ul>
Invece di riempire l’array rows
con tutti i record restituiti dalla query, in alcuni casi potrebbe essere più conveniente trattare ogni record individualmente con il metodo db.each()
. La sintassi del metodo db.each()
è simile al metodo db.all()
, ma il parametro row
in (err, row) ⇒ {}
corrisponde ad un singolo record alla volta.
Modificare i Contenuti di un Database
Finora il nostro client può solo aggiungere e interrogare i messaggi sul server. Dato che il client ora conosce l' id
dei messaggi precedentemente inviati, possiamo implementare una funzione per modificare un record specifico. Il messaggio modificato può anche essere inviato a una destinazione con metodo HTTP POST
, ma questa volta con un parametro per catturare l' id
dato dal client nel percorso della richiesta:
app.post('/:id', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined ){
uuid = uuidv4()
// 401 Unauthorized
res.sendStatus(401)
}
else {
// Update the stored message
// using named parameters
let param = {
$message: req.body.message,
$id: req.params.id,
$uuid: uuid
}
db.run('UPDATE messages SET message = $message WHERE id = $id AND uuid = $uuid', param, function(err){
if ( this.changes > 0 )
{
// A 204 (No Content) status code means the action has
// been enacted and no further information is to be supplied.
res.sendStatus(204)
}
else
res.sendStatus(404)
})
}
})
Questo dimostra come usare le clausole UPDATE
e WHERE
per modificare un record esistente. Un’importante differenza rispetto agli esempi precedenti è l’uso di parametri nominali, dove i valori sono raggruppati in un singolo oggetto (param
) e passati al metodo db.run()
invece di specificare ogni valore da solo. In questo caso, i nomi dei campi (preceduti da $
) sono le proprietà dell’oggetto. I parametri con nome permettono di usare i nomi dei campi (preceduti da $
) come marcatori invece che come punti interrogativi.
Un’istruzione come quella dell’esempio non causerà alcuna modifica al database se la condizione imposta dalla clausola WHERE
non corrisponde a qualche record della tabella. Per valutare se qualche record è stato modificato dall’istruzione, una funzione di callback può essere usata come ultimo parametro del metodo db.run()
. All’interno della funzione, il numero di record modificati può essere interrogato da this.changes
. Notate che le funzioni arrow non possono essere usate in questo caso, perché solo le funzioni regolari espresse nella forma function(){}
definiscono l’oggetto this
.
La rimozione di un record è molto simile alla sua modifica. Possiamo, per esempio, continuare a usare il parametro :id
per identificare il messaggio da cancellare, ma questa volta invocata dal metodo HTTP DELETE
del client:
app.delete('/:id', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined ){
uuid = uuidv4()
res.sendStatus(401)
}
else {
// Named parameters
let param = {
$id: req.params.id,
$uuid: uuid
}
db.run('DELETE FROM messages WHERE id = $id AND uuid = $uuid', param, function(err){
if ( this.changes > 0 )
res.sendStatus(204)
else
res.sendStatus(404)
})
}
})
I record vengono cancellati da una tabella con la clausola DELETE FROM
. Abbiamo di nuovo usato la funzione di callback per valutare quante voci sono state rimosse dalla tabella.
Chiudere un Database
Una volta definito, l’oggetto db
può essere referenziato in qualsiasi momento durante l’esecuzione dello script, perché il file del database rimane aperto durante la sessione corrente. Non è comune chiudere il database mentre lo script è in esecuzione.
Una funzione per chiudere il database è utile, comunque, per evitare di chiudere bruscamente il database quando il processo del server finisce. Anche se è improbabile, chiudere bruscamente il database può causare incoerenze se i dati in-memoria non sono ancora stati trasferiti nel file. Per esempio, una chiusura improvvisa del database con perdita di dati può verificarsi se lo script viene terminato dall’utente premendo la scorciatoia da tastiera Ctrl+C.
Nello scenario Ctrl+C appena descritto, il metodo process.on()
può intercettare i segnali inviati dal sistema operativo ed eseguire un arresto ordinato sia del database che del server:
process.on('SIGINT', () => {
db.close()
server.close()
console.log('HTTP server closed')
})
La scorciatoia Ctrl+C invoca il segnale del sistema operativo SIGINT
, che termina un programma in primo piano nel terminale. Prima di terminare il processo alla ricezione del segnale SIGINT
, il sistema invoca la funzione di callback (l’ultimo parametro nel metodo process.on()
). All’interno della funzione di callback, puoi mettere qualsiasi codice di pulizia, in particolare il metodo db.close()
per chiudere il database e server.close()
, che chiude correttamente l’istanza Node.js Express stessa.
Esercizi Guidati
-
Qual è lo scopo di una chiave primaria in una tabella di database SQL?
-
Qual è la differenza tra effettuare query usando
db.all()
edb.each()
? -
Perché è importante usare i mercatori e non includere i dati inviati dal client direttamente in uno statement o query SQL?
Esercizi Esplorativi
-
Quale metodo nel modulo
sqlite3
può essere usato per restituire solo una voce della tabella, anche se la query corrisponde a più voci? -
Supponiamo che l’array
rows
sia stato passato come parametro a una funzione di callback e contenga il risultato di una query fatta condb.all()
. Come può un campo chiamatoprice
, che è presente nella prima posizione dirows
, essere referenziato all’interno della funzione di callback? -
Il metodo
db.run()
esegue dichiarazioni di modifica del database, comeINSERT INTO
. Dopo aver inserito un nuovo record in una tabella, come potreste recuperare la chiave primaria del record appena inserito?
Sommario
Questa lezione spiega l’uso di base dei database SQL nelle applicazioni Node.js Express. Il modulo sqlite3
offre un modo semplice di memorizzare dati persistenti in un database SQLite, dove un singolo file contiene l’intero database e non richiede un server di database specializzato. Questa lezione tratta i seguenti concetti e procedure:
-
Come stabilire una connessione al database da Node.js.
-
Come creare una semplice tabella e il ruolo delle chiavi primarie.
-
Usare l’istruzione SQL
INSERT INTO
per aggiungere nuovi dati dall’interno dello script. -
Fare query SQL usando i metodi standard di SQLite e le funzioni di callback.
-
Cambiare i dati nel database usando le istruzioni SQL
UPDATE
eDELETE
.
Risposte agli Esercizi Guidati
-
Qual è lo scopo di una chiave primaria in una tabella di database SQL?
La chiave primaria è il campo di identificazione univoco per ogni record all’interno di una tabella di database.
-
Qual è la differenza tra effettuare query usando
db.all()
edb.each()
?Il metodo
db.all()
invoca la funzione di callback con un singolo array contenente tutte le voci corrispondenti alla query. Il metododb.each()
invoca la funzione di callback per ogni riga di risultato. -
Perché è importante usare i mercatori e non includere i dati inviati dal client direttamente in uno statement o query SQL?
Con i marcatori, i dati inviati dall’utente vengono verificati prima di essere inclusi nella query o nell’istruzione. Questo ostacola gli attacchi di SQL injection, dove le istruzioni SQL sono inserite all’interno di dati variabili nel tentativo di eseguire operazioni arbitrarie sul database.
Risposte agli Esercizi Esplorativi
-
Quale metodo nel modulo
sqlite3
può essere usato per restituire solo una voce della tabella, anche se la query corrisponde a più voci?Il metodo
db.get()
ha la stessa sintassi didb.all()
, ma restituisce solo la prima voce corrispondente alla query. -
Supponiamo che l’array
rows
sia stato passato come parametro a una funzione di callback e contenga il risultato di una query fatta condb.all()
. Come può un campo chiamatoprice
, che è presente nella prima posizione dirows
, essere referenziato all’interno della funzione di callback?Ogni elemento in
rows
è un oggetto le cui proprietà corrispondono ai nomi dei campi del database. Quindi il valore del campoprice
nel primo risultato è inrows[0].price
. -
Il metodo
db.run()
esegue dichiarazioni di modifica del database, comeINSERT INTO
. Dopo aver inserito un nuovo record in una tabella, come potreste recuperare la chiave primaria del record appena inserito?Una funzione regolare della forma
function(){}
può essere usata come funzione di callback del metododb.run()
. Al suo interno, la proprietàthis.lastID
contiene il valore della chiave primaria dell’ultimo record inserito.