035.3 Lektion 1
Zertifikat: |
Web Development Essentials |
---|---|
Version: |
1.0 |
Thema: |
035 NodeJS Server-Programmierung |
Lernziel: |
035.3 SQL Grundlagen |
Lektion: |
1 von 1 |
Einführung
Obwohl Sie eigene Funktionen schreiben können, um eine dauerhafte Speicherung zu implementieren, kann ein Datenbankmanagementsystem helfen, die Entwicklung zu beschleunigen sowie höhere Sicherheit und Stabilität für tabellenformatierte Daten zu gewährleisten. Die beliebteste Strategie für die Speicherung von Daten, die in miteinander verbundenen Tabellen organisiert sind – insbesondere wenn diese Tabellen häufig abgefragt und aktualisiert werden –, ist die Installation einer relationalen Datenbank, die die Structured Query Language (SQL) unterstützt, eine Sprache, die auf relationale Datenbanken ausgerichtet ist. Node.js unterstützt verschiedene SQL-Datenbankmanagementsysteme. Gemäß den Prinzipien der Portabilität und der Ausführung im Userspace, die von Node.js Express übernommen wurden, ist SQLite eine geeignete Wahl für die dauerhafte Speicherung von Daten, die von dieser Art von HTTP-Server verwendet werden.
SQL
Die Structured Query Language ist spezifisch für Datenbanken. Schreib- und Leseoperationen werden in Sätzen ausgedrückt, die Statements (Anweisungen) und Queries (Abfragen) genannt werden. Sowohl Anweisungen als auch Abfragen bestehen aus Clauses (Klauseln), die die Bedingungen für die Ausführung der Operation festlegen.
Namen und E-Mail-Adressen können zum Beispiel in einer Datenbanktabelle gespeichert werden, die die Felder name
und email
enthält. Eine Datenbank kann mehrere Tabellen enthalten, so dass jede Tabelle einen eindeutigen Namen haben muss. Wenn wir den Namen contacts
für die Tabelle der Namen und E-Mail-Adressen verwenden, kann ein neuer Datensatz mit dem folgenden Statement eingefügt werden:
INSERT INTO contacts (name, email) VALUES ("Carol", "carol@example.com");
Diese Einfügeanweisung besteht aus der Klausel INSERT INTO
, die die Tabelle und die Felder definiert, in die die Daten eingefügt werden sollen. Die zweite Klausel, VALUES
, legt die Werte fest, die eingefügt werden. Es ist nicht notwendig, Klauseln mit Großbuchstaben zu schreiben, aber es ist gängige Praxis, um SQL-Schlüsselwörter innerhalb einer Anweisung oder Abfrage besser zu erkennen.
Eine Abfrage der Tabelle contacts
erfolgt auf ähnliche Weise, jedoch unter Verwendung der Klausel SELECT
:
SELECT email FROM contacts;
dave@example.com
carol@example.com
In diesem Fall wählt die Klausel SELECT email
ein Feld aus den Einträgen in der Tabelle contacts
aus. Die WHERE
-Klausel schränkt die Abfrage auf bestimmte Zeilen ein:
SELECT email FROM contacts WHERE name = "Dave";
dave@example.com
SQL verfügt über viele weitere Klauseln, von denen wir einige in späteren Abschnitten betrachten werden. Aber zuerst müssen wir die SQL-Datenbank in Node.js integrieren.
SQLite
SQLite ist wahrscheinlich die einfachste Lösung für die Einbindung von SQL-Datenbankfunktionen in eine Anwendung. Im Gegensatz zu anderen gängigen Datenbankmanagementsystemen ist SQLite kein Datenbankserver, mit dem sich ein Client verbindet. Stattdessen bietet SQLite eine Reihe von Funktionen, mit denen der Entwickler eine Datenbank wie eine herkömmliche Datei erstellt. Im Falle eines mit Node.js Express implementierten HTTP-Servers befindet sich diese Datei normalerweise im selben Verzeichnis wie das Serverskript.
Bevor Sie SQLite in Node.js verwenden, müssen Sie das Modul sqlite3
installieren. Führen Sie den folgenden Befehl im Installationsverzeichnis des Servers aus, d.h. in dem Verzeichnis, in dem sich das Node.js-Skript befindet, das Sie ausführen werden:
$ npm install sqlite3
Beachten Sie, dass es mehrere Module gibt, die SQLite unterstützen, wie z.B. better-sqlite3
, deren Verwendung sich geringfügig von sqlite3
unterscheidet. Die Beispiele in dieser Lektion gelten für das Modul sqlite3
, daher könnten sie nicht wie erwartet funktionieren, wenn Sie ein anderes Modul wählen.
Datenbank öffnen
Um zu demonstrieren, wie ein Node.js-Express-Server mit einer SQL-Datenbank arbeitet, schreiben wir ein Skript, das Nachrichten speichert und anzeigt, die von einem durch ein Cookie identifizierten Client gesendet werden. Der Client sendet die Nachrichten über die HTTP-POST-Methode, und die Serverantwort kann als JSON oder HTML (aus einem Template) formatiert werden, je nach dem vom Client gewünschten Format. In dieser Lektion wird nicht im Detail auf die Verwendung von HTTP-Methoden, Cookies und Vorlagen eingegangen. Die hier gezeigten Code-Ausschnitte gehen davon aus, dass Sie bereits einen Node.js-Express-Server haben, auf dem diese Funktionen konfiguriert und verfügbar sind.
Am einfachsten lassen sich die vom Client gesendeten Nachrichten in einem globalen Array speichern, in dem jede zuvor gesendete Nachricht mit einem eindeutigen Identifikationsschlüssel für jeden Client verknüpft ist. Dieser Schlüssel wird dem Client als Cookie übermittelt, das dem Server bei künftigen Anfragen zum Abrufen früherer Nachrichten vorgelegt wird.
Dieser Ansatz hat jedoch einen Schwachpunkt: Da die Nachrichten nur in einem globalen Array gespeichert werden, gehen alle Nachrichten verloren, sobald die aktuelle Serversitzung beendet wird. Einer der Vorteile der Arbeit mit Datenbanken besteht darin, dass die Daten dauerhaft gespeichert werden und nicht verloren gehen, wenn der Server neu gestartet wird.
Mit der Datei index.js
als Hauptskript des Servers binden wir das Modul sqlite3
ein und geben die Datei an, die als Datenbank dient:
const sqlite3 = require('sqlite3')
const db = new sqlite3.Database('messages.sqlite3');
Falls noch nicht vorhanden, wird die Datei messages.sqlite3
im selben Verzeichnis wie die Datei index.js
erstellt. In dieser einen Datei werden alle Strukturen und die entsprechenden Daten gespeichert. Alle Datenbankoperationen, die im Skript ausgeführt werden, werden durch die Konstante db
vermittelt, die der Name des neuen sqlite3
-Objekts ist, das die Datei messages.sqlite3
öffnet.
Tabellenstruktur
Es können keine Daten in die Datenbank eingefügt werden, bevor nicht mindestens eine Tabelle erstellt wurde. Tabellen werden mit der Anweisung CREATE TABLE
erzeugt:
db.run('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid CHAR(36), message TEXT)')
Die Methode db.run()
dient dazu, SQL-Anweisungen in der Datenbank auszuführen. Die Anweisung selbst wird als Parameter für die Methode geschrieben. Obwohl SQL-Anweisungen mit einem Semikolon enden müssen, wenn sie in einem Befehlszeilenprozessor eingegeben werden, ist das Semikolon bei Anweisungen, die als Parameter in einem Programm übergeben werden, optional.
Da die Methode run
jedes Mal ausgeführt wird, wenn das Skript mit node index.js
ausgeführt wird, enthält die SQL-Anweisung die Bedingungsklausel IF NOT EXISTS
, um Fehler bei künftigen Ausführungen zu vermeiden, wenn die Tabelle messages
bereits existiert.
Die Felder, aus denen die Tabelle messages
besteht, sind id
, uuid
und message
. Das Feld id
ist eine eindeutige Ganzzahl, die der Identifizierung jedes Eintrags in der Tabelle dient; daher wird es als PRIMARY KEY
angelegt. Primärschlüssel können nicht ungültig sein, und es kann nicht zwei identische Primärschlüssel in derselben Tabelle geben. Daher hat fast jede SQL-Tabelle einen Primärschlüssel, um den Inhalt der Tabelle zu verfolgen. Obwohl es möglich ist, den Wert für den Primärschlüssel eines neuen Datensatzes explizit zu wählen (sofern er noch nicht in der Tabelle existiert), ist es praktisch, wenn der Schlüssel automatisch erzeugt wird. Das AUTOINCREMENT
-Flag im id
-Feld dient diesem Zweck.
Note
|
Das explizite Setzen von Primärschlüsseln in SQLite ist optional, da SQLite selbst automatisch einen Primärschlüssel erzeugt. Wie in der SQLite-Dokumentation angegeben: “In SQLite haben Tabellenzeilen normalerweise eine 64-bit vorzeichenbehaftete Ganzzahl |
Die Felder uuid
und message
speichern die Client-Identifikation bzw. den Inhalt der Nachricht. Ein Feld vom Typ CHAR(36)
speichert eine feste Anzahl von 36 Zeichen, und ein Feld vom Typ TEXT
speichert Texte beliebiger Länge.
Dateneingabe
Die Hauptfunktion unseres Beispielservers besteht darin, Nachrichten zu speichern, die mit dem Client verknüpft sind, der sie gesendet hat. Der Client sendet die Nachricht im Feld message
im Body der mit der HTTP-POST-Methode gesendeten Anfrage. Die Identifikation des Clients befindet sich in einem Cookie namens uuid
. Mit diesen Informationen können wir die Express-Route schreiben, um neue Nachrichten in die Datenbank einzufügen:
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})
})
})
Dieses Mal führt die Methode db.run()
eine Einfügeanweisung aus, aber beachten Sie, dass uuid
und req.body.message
nicht direkt in die Anweisungszeile geschrieben werden. Stattdessen wurden die Werte durch Fragezeichen ersetzt. Jedes Fragezeichen entspricht einem Parameter, der der SQL-Anweisung in der Methode db.run()
folgt.
Die Fragezeichen als Platzhalter in der Anweisung, die in der Datenbank ausgeführt wird, erleichtert es SQLite, zwischen den statischen Elementen der Anweisung und den variablen Daten zu unterscheiden. Diese Strategie ermöglicht es SQLite, den Inhalt der Variablen, die Teil der Anweisung sind, zu übergehen (escape) oder zu entfernen (sanitize), was eine häufige Sicherheitslücke namens SQL Injection schließt. Bei diesem Angriff fügen böswillige Benutzer SQL-Anweisungen in die Variablendaten ein, in der Hoffnung, dass die Anweisungen versehentlich ausgeführt werden. Die Bereinigung vereitelt den Angriff, indem gefährliche Zeichen in den Daten deaktiviert werden.
Abfragen (Queries)
Wie im Beispielcode gezeigt, wollen wir mit derselben Route neue Nachrichten in die Datenbank einfügen und die Liste der zuvor gesendeten Nachrichten erstellen. Die Methode db.all()
gibt die Sammlung aller Einträge in der Tabelle zurück, die den in der Abfrage definierten Kriterien entsprechen.
Im Gegensatz zu den Anweisungen, die von db.run()
ausgeführt werden, erzeugt db.all()
eine Liste von Datensätzen, die von der im letzten Parameter angegebenen Pfeilfunktion verarbeitet werden:
(err, rows) => {}
Diese Funktion benötigt wiederum zwei Parameter: err
und rows
. Der Parameter err
wird verwendet, wenn ein Fehler auftritt, der die Ausführung der Abfrage verhindert. Bei Erfolg sind alle Datensätze im Array rows
verfügbar, wobei jedes Element ein Objekt ist, das einem einzelnen Datensatz der Tabelle entspricht. Die Eigenschaften dieses Objekts entsprechen den in der Abfrage angegebenen Feldnamen: uuid
und message
.
Das Array rows
ist eine JavaScript-Datenstruktur. Als solche kann es verwendet werden, um Antworten mit Methoden zu generieren, die von Express bereitgestellt werden, wie res.json()
und res.render()
. Wird es innerhalb eines EJS-Templates gerendert, kann eine herkömmliche Schleife alle Datensätze auflisten:
<ul>
<% rows.forEach( (row) => { %>
<li><strong><%= row.id %></strong>: <%= row.message %></li>
<% }) %>
</ul>
Anstatt das Array rows
mit allen von der Abfrage zurückgegebenen Datensätzen zu füllen, kann es in manchen Fällen bequemer sein, jeden Datensatz einzeln mit der Methode db.each()
zu behandeln. Die Syntax der Methode db.each()
ist ähnlich wie die der Methode db.all()
, aber der Parameter row
in (err, row) => {}
entspricht jeweils einem einzelnen Datensatz.
Datenbankinhalte ändern
Bislang kann unser Client nur Nachrichten auf dem Server hinzufügen und abfragen. Da der Client nun die id
der zuvor gesendeten Nachrichten kennt, können wir eine Funktion zur Änderung eines bestimmten Datensatzes implementieren. Die geänderte Nachricht kann auch an eine HTTP-POST-Methoden-Route gesendet werden, aber diesmal mit einem Routen-Parameter, um die vom Client im Anforderungspfad angegebene id
zu erfassen:
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)
})
}
})
Diese Route zeigt, wie Sie mit den Klauseln UPDATE
und WHERE
einen bestehenden Datensatz ändern. Ein wichtiger Unterschied zu den vorherigen Beispielen sind benannte Parameter, bei denen die Werte in einem einzigen Objekt (param
) gebündelt und an die Methode db.run()
übergeben werden, anstatt jeden Wert einzeln anzugeben. In diesem Fall sind die Feldnamen (mit vorangestelltem $
) die Eigenschaften des Objekts. Benannte Parameter erlauben die Verwendung von Feldnamen (mit vorangestelltem $
) als Platzhalter anstelle von Fragezeichen.
Eine Anweisung wie die im Beispiel führt zu keiner Änderung in der Datenbank, wenn die in der WHERE
-Klausel genannte Bedingung nicht auf einen Datensatz in der Tabelle zutrifft. Um auszuwerten, ob irgendwelche Datensätze durch die Anweisung geändert wurden, hilft eine Callback-Funktion als letzter Parameter der Methode db.run()
. Innerhalb der Funktion kann die Anzahl der geänderten Datensätze aus this.changes
abgefragt werden. Beachten Sie, dass Pfeilfunktionen in diesem Fall nicht verwendet werden können, da nur reguläre Funktionen der Form function(){}
das this
-Objekt definieren.
Das Löschen eines Datensatzes ist dem Ändern eines Datensatzes sehr ähnlich. Wir können zum Beispiel weiterhin den Routenparameter :id
verwenden, um die zu löschende Nachricht zu identifizieren, aber dieses Mal in einer Route, die von der HTTP-DELETE-Methode des Clients aufgerufen wird:
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)
})
}
})
Mit der Klausel DELETE FROM
löschen Sie Datensätze aus einer Tabelle. Wir nutzen wieder die Callback-Funktion, um zu ermitteln, wie viele Einträge aus der Tabelle entfernt wurden.
Datenbank schließen
Sobald das Objekt db
definiert ist, kann während der Skriptausführung jederzeit darauf verwiesen werden, da die Datenbankdatei während der aktuellen Sitzung geöffnet bleibt. Es ist nicht üblich, die Datenbank zu schließen, während das Skript läuft.
Eine Funktion zum Schließen der Datenbank ist jedoch nützlich, um ein abruptes Schließen der Datenbank bei Beendigung des Serverprozesses zu vermeiden. Auch wenn es unwahrscheinlich ist, kann ein abruptes Schließen der Datenbank zu Inkonsistenzen führen, wenn die Daten im Speicher noch nicht in die Datei übertragen wurden. Ein abruptes Herunterfahren der Datenbank mit Datenverlust kann z.B. auftreten, wenn das Skript vom Benutzer durch Drücken der Tastenkombination Strg+C beendet wird.
In dem soeben beschriebenen Szenario Strg+C kann die Methode process.on()
die vom Betriebssystem gesendeten Signale abfangen und ein geordnetes Herunterfahren sowohl der Datenbank als auch des Servers durchführen:
process.on('SIGINT', () => {
db.close()
server.close()
console.log('HTTP server closed')
})
Die Tastenkombination Strg+C ruft das Betriebssystemsignal SIGINT
auf, das ein Vordergrundprogramm im Terminal beendet. Bevor der Prozess beim Empfang des Signals SIGINT
beendet wird, ruft das System die Callback-Funktion auf (der letzte Parameter in der Methode process.on()
). In die Callback-Funktion kann beliebiger Aufräumcode eingefügt werden, insbesondere die Methode db.close()
zum Schließen der Datenbank und server.close()
, mit der die Express-Instanz selbst ordnungsgemäß geschlossen wird.
Geführte Übungen
-
Was ist der Zweck eines Primärschlüssels in einer SQL-Datenbanktabelle?
-
Was ist der Unterschied zwischen der Abfrage mit
db.all()
unddb.each()
? -
Warum ist es wichtig, Platzhalter zu verwenden und vom Client gesendete Daten nicht direkt in eine SQL-Anweisung oder -Abfrage aufzunehmen?
Offene Übungen
-
Welche Methode im Modul
sqlite3
dient dazu, nur einen Tabelleneintrag zurückzugeben, auch wenn die Abfrage auf mehrere Einträge passt? -
Das Array
rows
wurde als Parameter an eine Callback-Funktion übergeben und enthält das Ergebnis einer mitdb.all()
durchgeführten Abfrage. Wie referenzieren Sie ein Feld namensprice
, das sich an der ersten Position vonrows
befindet, innerhalb der Callback-Funktion? -
Die Methode
db.run()
führt Anweisungen zur Änderung der Datenbank aus, wie z.B.INSERT INTO
. Wie rufen Sie nach dem Einfügen eines neuen Datensatzes in eine Tabelle den Primärschlüssel des neu eingefügten Datensatzes ab?
Zusammenfassung
Diese Lektion behandelt die grundlegende Verwendung von SQL-Datenbanken in Node.js Express-Anwendungen. Das Modul sqlite3
bietet eine einfache Möglichkeit, persistente Daten in einer SQLite-Datenbank zu speichern, wobei eine einzige Datei die gesamte Datenbank enthält und keinen speziellen Datenbankserver benötigt. In dieser Lektion werden die folgenden Konzepte und Verfahren behandelt:
-
Wie man von Node.js aus eine Datenbankverbindung herstellt.
-
Wie man eine einfache Tabelle erstellt und die Rolle von Primärschlüsseln.
-
Verwendung der SQL-Anweisung
INSERT INTO
, um neue Daten aus dem Skript heraus hinzuzufügen. -
SQL-Abfragen mit Standard-SQLite-Methoden und Callback-Funktionen.
-
Ändern von Daten in der Datenbank mit den SQL-Anweisungen
UPDATE
undDELETE
.
Lösungen zu den geführten Übungen
-
Was ist der Zweck eines Primärschlüssels in einer SQL-Datenbanktabelle?
Der Primärschlüssel ist das eindeutige Identifikationsfeld für jeden Datensatz in einer Datenbanktabelle.
-
Was ist der Unterschied zwischen der Abfrage mit
db.all()
unddb.each()
?Die Methode
db.all()
ruft die Callback-Funktion mit einem einzigen Array auf, das alle der Abfrage entsprechenden Einträge enthält. Die Methodedb.each()
ruft die Callback-Funktion für jede Ergebniszeile auf. -
Warum ist es wichtig, Platzhalter zu verwenden und vom Client gesendete Daten nicht direkt in eine SQL-Anweisung oder -Abfrage aufzunehmen?
Bei Platzhaltern werden die vom Benutzer eingegebenen Daten vor der Aufnahme in die Abfrage oder Anweisung mit einem Escape versehen. Dies erschwert Angriffe per SQL Injection, bei denen SQL-Anweisungen in variable Daten eingefügt werden, um beliebige Operationen in der Datenbank durchzuführen.
Lösungen zu den offenen Übungen
-
Welche Methode im Modul
sqlite3
dient dazu, nur einen Tabelleneintrag zurückzugeben, auch wenn die Abfrage auf mehrere Einträge passtDie Methode
db.get()
hat die gleiche Syntax wiedb.all()
, gibt aber nur den ersten Eintrag zurück, der der Abfrage entspricht. -
Das Array
rows
wurde als Parameter an eine Callback-Funktion übergeben und enthält das Ergebnis einer mitdb.all()
durchgeführten Abfrage. Wie referenzieren Sie ein Feld namensprice
, das sich an der ersten Position vonrows
befindet, innerhalb der Callback-Funktion?Jedes Element in
rows
ist ein Objekt, dessen Eigenschaften den Namen von Datenbankfeldern entsprechen. Der Wert des Feldesprice
im ersten Ergebnis steht also inrows[0].price
. -
Die Methode
db.run()
führt Anweisungen zur Änderung der Datenbank aus, wie z.B.INSERT INTO
. Wie rufen Sie nach dem Einfügen eines neuen Datensatzes in eine Tabelle den Primärschlüssel des neu eingefügten Datensatzes ab?Eine reguläre Funktion der Form
function(){}
kann als Callback-Funktion der Methodedb.run()
verwendet werden. Darin enthält die Eigenschaftthis.lastID
den Primärschlüsselwert des zuletzt eingefügten Datensatzes.