035.3 Lecke 1
Tanúsítvány: |
Web Development Essentials |
---|---|
Verzió: |
1.0 |
Témakör: |
035 NodeJS szerverprogramozás |
Fejezet: |
035.3 SQL alapok |
Lecke: |
1/1 |
Bevezetés
Bár írhatunk saját függvényeket a tartós tárolás megvalósításához, de a fejlesztés felgyorsítása, valamint a nagyobb biztonság és stabilitás biztosítása érdekében kényelmesebb lehet egy adatbázis-kezelő rendszer használata a táblázatformátumú adatok esetén. Az egymással összefüggő táblázatokba rendezett adatok tárolásának legnépszerűbb stratégiája - különösen, ha ezeket a táblákat gyakran lekérdezik és frissítik - egy relációs adatbázis telepítése, amely támogatja a Structured Query Language (strukturált lekérdezőnyelv - SQL) nyelvet, ami a relációs adatbázisokhoz igazodik. A Node.js különböző SQL adatbázis-kezelő rendszereket támogat. A Node.js Express által elfogadott hordozhatósági és felhasználói térbeli végrehajtási elveket követve az SQLite megfelelő választás az ilyen típusú HTTP-szerver által használt adatok tartós tárolására.
SQL
A Structured Query Language az adatbázisokra jellemző. Az írási és olvasási műveleteket statements (állítások) és queries (lekérdezések) nevű mondatokban fejezzük ki. Mind az állítások, mind a lekérdezések kifejezésekből állnak, amelyek meghatározzák a művelet végrehajtásának feltételeit.
Neveket és e-mail címeket például tárolhatunk egy olyan adatbázis-táblában, amely tartalmazza a name
és az email
mezőket. Egy adatbázis több táblát is tartalmazhat, ezért minden táblának egyedi névvel kell rendelkeznie. Ha a contacts
nevet használjuk a nevek és e-mail címek táblához, akkor egy új rekordot a következő statement-tel (állítással) lehet beszúrni:
INSERT INTO contacts (name, email) VALUES ("Carol", "carol@example.com");
Ez a beszúrási utasítás az INSERT INTO
záradékból áll, amely meghatározza a táblát és a mezőket, ahová az adatok beillesztésre kerülnek. A második, VALUES
záradék a beszúrandó értékeket határozza meg. A záradékokat nem szükséges nagybetűvel írni, de ez a bevett gyakorlat, azért, hogy az SQL kulcsszavakat jobban felismerjük egy utasításon vagy lekérdezésen belül.
A kapcsolatok táblából való lekérdezés hasonló módon történik, de a SELECT
záradék használatával:
SELECT email FROM contacts;
dave@example.com
carol@example.com
Ebben az esetben a SELECT email
záradék egy mezőt választ ki a contacts
tábla bejegyzéseiből. A WHERE
záradék a lekérdezést meghatározott sorokra korlátozza:
SELECT email FROM contacts WHERE name = "Dave";
dave@example.com
Az SQL-nek számos más záradéka is van, és ezek közül néhányat a későbbi szekciókban fogunk megvizsgálni. De előbb meg kell néznünk, hogyan integráljuk az SQL-adatbázist a Node.js-be.
SQLite
Az SQLite valószínűleg a legegyszerűbb megoldás az SQL-adatbázis funkcióinak beépítésére egy alkalmazásba. Más népszerű adatbázis-kezelő rendszerekkel ellentétben az SQLite nem egy adatbázis-kiszolgáló, amelyhez a kliens csatlakozik. Az SQLite számos olyan funkciót biztosít, amelyek lehetővé teszik a fejlesztő számára, hogy hagyományos fájlként hozzon létre egy adatbázist. A Node.js Express-szel megvalósított HTTP-szerver esetében ez a fájl általában a szerverszkripttel azonos könyvtárban található.
Az SQLite Node.js-ben való használata előtt telepíteni kell az sqlite3
modult. Futtassuk a következő parancsot a szerver telepítési könyvtárában; azaz abban a könyvtárban, amely a futtatni kívánt Node.js szkriptet tartalmazza.
$ npm install sqlite3
Ne felejtsük el, hogy több modul is támogatja az SQLite-ot, például a better-sqlite3
, amelynek használata egy kicsit eltér az sqlite3
-tól. A leckében szereplő példák a sqlite3
modulra vonatkoznak, így előfordulhat, hogy nem a várt módon működnek, ha más modult használunk.
Az adatbázis megnyitása
Ahhoz, hogy bemutassuk, hogy egy Node.js Express szerver hogyan tud együttműködni egy SQL-adatbázissal, írjunk egy szkriptet, amely tárolja és megjeleníti a sütivel azonosított kliens által küldött üzeneteket. Az üzeneteket a kliens a HTTP POST módszerrel küldi el, a szerver válasza pedig a kliens által kért formátumtól függően JSON vagy HTML (sablon) formátumú lehet. Ez a lecke nem fogja részletezni a HTTP-módszerek, a sütik és a sablonok használatát. Az itt bemutatott kódrészletek feltételezik, hogy már van egy Node.js Express szerver, ahol ezek a szolgáltatások már be vannak állítva és elérhetőek.
A kliens által küldött üzenetek tárolásának legegyszerűbb módja, ha egy globális tömbben tároljuk őket, ahol minden korábban elküldött üzenethez egy egyedi azonosító kulcs tartozik kliensenként. Ezt a kulcsot sütiként lehet elküldeni a kliensnek, amelyet a szerver a jövőbeni kéréseknél bemutat a korábbi üzenetek lekérdezésére.
Ennek a megközelítésnek azonban van egy gyenge pontja: mivel az üzenetek csak egy globális tömbben tárolódnak, az összes üzenet elvész, amikor az aktuális szerver-munkamenet megszűnik. Az adatbázisokkal való munkának éppen ez az egyik előnye, hogy az adatok tartósan tárolódnak, és nem vesznek el, ha a szerver újraindul.
Az index.js
fájlt használva a szerver fő szkriptjeként, beépíthetjük az sqlite3
modult és megadhatjuk az adatbázisként szolgáló fájlt az alábbiak szerint:
const sqlite3 = require('sqlite3')
const db = new sqlite3.Database('messages.sqlite3');
Ha még nem létezik, a messages.sqlite3
fájl ugyanabban a mappában jön létre, mint az index.js
fájl. Ebben az egyetlen fájlban tárolódik az összes struktúra és a hozzájuk tartozó adatok. A szkriptben végrehajtott összes adatbázis-műveletet a db
konstans fogja közvetíteni, amely a messages.sqlite3
fájlt megnyitó új sqlite3
objektum neve.
Táblastruktúra
Addig nem lehet adatokat beszúrni az adatbázisba, amíg legalább egy tábla létre nem jön. A táblák létrehozása a CREATE TABLE
utasítással történik:
db.run('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid CHAR(36), message TEXT)')
A db.run()
metódus az SQL-utasítások végrehajtására szolgál az adatbázisban. Maga az utasítás a metódus paramétereként íródik. Bár az SQL-állításoknak pontosvesszővel kell végződniük, amikor egy parancssori feldolgozóba írjuk be őket, a pontosvessző a programban paraméterként átadott kijelentésekben opcionális.
Mivel a run
metódus minden alkalommal végrehajtásra kerül, amikor a szkriptet a node index.js
segítségével hajtjuk végre, az SQL utasítás tartalmazza a IF NOT EXISTS
feltételes záradékot, hogy elkerüljük a hibákat a jövőbeli végrehajtások során, ha a messages
tábla már létezik.
A messages
táblát a következő mezők alkotják: id
, uuid
és message
. Az id
mező egy egyedi egész szám, amely a táblázat minden egyes bejegyzésének azonosítására szolgál, ezért PRIMARY KEY
mezőként van létrehozva. Az elsődleges kulcsok értéke nem lehet null, és ugyanabban a táblában nem lehet két azonos elsődleges kulcs. Ezért szinte minden SQL-táblának van elsődleges kulcsa a tábla tartalmának nyomonkövetése érdekében. Bár lehetőség van arra, hogy egy új rekord elsődleges kulcsának értékét explicit módon válasszuk ki (feltéve, hogy még nem létezik a táblában), célszerű, ha a kulcs automatikusan generálódik. Erre a célra az id
mezőben található AUTOINCREMENT
jelzőt használjuk.
Note
|
Az SQLite-ban az elsődleges kulcsok explicit beállítása opcionális, mivel az SQLite maga is automatikusan létrehoz egy elsődleges kulcsot. Ahogyan az SQLite dokumentációban szerepel: “Az SQLite-ban a táblázatok sorai általában egy 64 bites előjeles egész számmal, a |
Az uuid
és az message
mezők az ügyfél azonosítóját, illetve az üzenet tartalmát tárolják. A CHAR(36)
típusú mező rögzített hosszúságú és 36 karakterből áll, a TEXT
típusú mező pedig tetszőleges hosszúságú szövegeket tárol.
Adatbevitel
Példánk szerverének fő funkciója az üzenetek tárolása, amelyek az őket küldő klienshez kapcsolódnak. A kliens az üzenetet a HTTP POST módszerrel küldött kérés törzsében található message
mezőben küldi el. Az ügyfél azonosítója egy uuid
nevű sütiben található. Ezen információk birtokában megírhatjuk az Express utat az új üzenetek adatbázisba való beillesztéséhez:
app.post('/', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined )
uuid = uuidv4()
// Új üzenet beszúrása az adatbázisba
db.run('INSERT INTO messages (uuid, message) VALUES (?, ?)', uuid, req.body.message)
// Ha hiba lenne, az err objektum tartalmazza a hibaüzenetet.
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: "Üzeneteim", rows: rows})
})
})
Ezúttal az db.run()
módszer végrehajt egy beszúró utasítást, de figyeljük meg, hogy a uuid
és a req.body.message
nem íródik közvetlenül az utasítás sorába. Ehelyett kérdőjelek kerültek az értékek helyére. Minden kérdőjel megfelel egy paraméternek, amely az SQL utasítást követi a db.run()
metódusban.
A kérdőjelek placeholderként való használata az adatbázisban végrehajtott utasításban megkönnyíti az SQLite számára, hogy különbséget tegyen az utasítás statikus elemei és a változó adatok között. Ez a stratégia lehetővé teszi az SQLite számára, hogy az utasítás részét képező változó tartalmát feloldja (escape) vagy szanálja (sanitize), ezzel megelőzve az SQL injenction-nek nevezett gyakori biztonsági rést. Ebben a támadásban a rosszindulatú felhasználók SQL utasításokat illesztenek a változó adatokba, abban a reményben, hogy az utasítások véletlenül végrehajtásra kerülnek; a szanálás meghiúsítja a támadást az adatokban lévő veszélyes karakterek letiltásával.
Lekérdezések
Ahogy a mintakódban látható, a szándékunk az, hogy ugyanazt az utat használjuk az új üzenetek adatbázisba való beillesztésére és a korábban elküldött üzenetek listájának létrehozására. A db.all()
metódus visszaadja a táblázatban lévő összes olyan bejegyzés gyűjteményét, amely megfelel a lekérdezésben meghatározott kritériumoknak.
A db.run()
által végrehajtott utasításokkal ellentétben a db.all()
rekordlistákat generál, amelyeket az utolsó paraméterben megadott nyílfüggvény kezel:
(err, rows) => {}
Ez a függvény viszont két paramétert vesz fel: az err
és a rows
paramétereket. Az err
paramétert akkor használjuk, ha olyan hiba lép fel, amely megakadályozza a lekérdezés végrehajtását. Siker esetén az összes rekord elérhető a rows
tömbben, ahol minden elem egy objektum, amely a táblázat egyetlen rekordjának felel meg. Ennek az objektumnak a tulajdonságai megfelelnek a lekérdezésben megadott mezőneveknek: uuid
és message
.
A rows
tömb egy JavaScript adatszerkezet. Mint ilyen, válaszok generálására használható az Express által biztosított módszerekkel, mint például a res.json()
és a res.render()
. Egy EJS sablonon belül megjelenítve egy hagyományos ciklus az összes rekordot kilistázhatja:
<ul>
<% rows.forEach( (row) => { %>
<li><strong><%= row.id %></strong>: <%= row.message %></li>
<% }) %>
</ul>
Ahelyett, hogy a rows
tömböt töltenénk meg a lekérdezés által visszaadott összes rekorddal, bizonyos esetekben kényelmesebb lehet minden rekordot külön-külön kezelni a db.each()
metódussal. A db.each()
metódus szintaxisa hasonló a db.all()
metóduséhoz, de az (err, row) ⇒ {}
paraméter row
paramétere egyszerre csak egyetlen rekordra illeszkedik.
Adatbázis tartalmának megváltoztatása
A kliensünk a szerveren még csak üzeneteket tud hozzáadni és lekérdezni. Mivel a kliens most már ismeri a korábban elküldött üzenetek id
-jét, implementálhatunk egy függvényt egy adott rekord módosítására. A módosított üzenetet szintén elküldhetjük egy HTTP POST módszerrel készült útra, de ezúttal egy paraméterrel, hogy a kliens által a kérés útvonalában megadott id
-t elkapjuk:
app.post('/:id', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined ){
uuid = uuidv4()
// 401 Unauthorized
res.sendStatus(401)
}
else {
// A tárolt üzenet módosítása
// nevesített paraméterek használatával
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) státuszkód azt jelenti, hogy a művelet
// lefutott és nem kell további információt szolgáltatni.
res.sendStatus(204)
}
else
res.sendStatus(404)
})
}
})
Ez az út demonstrálja, hogy hogyan használjuk az UPDATE
és a WHERE
záradékokat egy meglévő rekord módosítására. Fontos különbség az előző példákhoz képest a named parameters (nevesített paraméterek) használata, ahol az értékeket egyetlen objektumba (param
) gyűjtjük és átadjuk a db.run()
metódusnak ahelyett, hogy minden egyes értéket egymagában adnánk meg. Ebben az esetben a mezőnevek (amelyek előtt $
áll) az objektum tulajdonságai. A névvel ellátott paraméterek lehetővé teszik a mezőnevek ($
előtaggal) placeholderként való használatát kérdőjelek helyett.
A példában szereplőhöz hasonló utasítás nem okoz semmilyen módosítást az adatbázisban, ha a WHERE
záradék által meghatározott feltétel nem felel meg a tábla valamelyik rekordjának. Annak kiértékelésére, hogy az utasítás módosított-e bármilyen rekordot, egy visszahívási (callback) függvényt használhatunk a db.run()
metódus utolsó paramétereként. A függvényen belül a megváltozott rekordok száma lekérdezhető a this.changes
függvényből. Jegyezzük meg, hogy ebben az esetben nem használhatók nyílfüggvények, mert csak a function(){}
formájú szabályos függvények definiálják a this
objektumot.
Egy rekord eltávolítása nagyon hasonló a módosításához. Például továbbra is használhatjuk az :id
paramétert a törlendő üzenet azonosítására, de ezúttal egy olyan úton, amelyet a kliens HTTP DELETE módszere hív meg:
app.delete('/:id', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined ){
uuid = uuidv4()
res.sendStatus(401)
}
else {
// Nevesített paraméterek
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)
})
}
})
A rekordok törlése egy táblából a DELETE FROM
záradékkal történt. Ismét a callback függvényt használtuk annak kiértékelésére, hogy hány bejegyzést távolítottunk el a táblából.
Az adatbázis bezárása
A definiálás után az db
objektumra bármikor lehet hivatkozni a szkript végrehajtása során, mivel az adatbázisfájl az aktuális munkamenet alatt nyitva marad. Nem szokás az adatbázist bezárni a szkript futása közben.
Az adatbázis bezárására szolgáló függvény azonban hasznos lehet, hogy elkerüljük az adatbázis hirtelen bezárását, amikor a szerverfolyamat befejeződik. Bár valószínűtlen, az adatbázis hirtelen leállítása inkozisztenciákat eredményezhet, ha a memórián belüli adatok még nem lettek rögzítve a fájlban. Például adatvesztéssel járó hirtelen adatbázislezárás következhet be, ha a szkriptet a felhasználó a Ctrl+C billentyűkombináció megnyomásával fejezi be.
Az imént ismertetett Ctrl+C esetében a process.on()
metódus képes az operációs rendszer által küldött jeleket elfogni és végrehajtani az adatbázis és a szerver szabályos leállítását:
process.on('SIGINT', () => {
db.close()
server.close()
console.log('HTTP-szerver zárva')
})
A Ctrl+C parancsikon meghívja a SIGINT
operációs rendszer-jelet, amely a terminálon lévő előtérben lévő programot befejezi. A SIGINT
jel fogadásakor a rendszer a folyamat befejezése előtt meghívja a callback függvényt (a process.on()
metódus utolsó paramétere). A callback függvényen belül bármilyen takarító kódot elhelyezhetünk, különösen a db.close()
metódust az adatbázis bezárására és a server.close()
metódust, amely elegánsan zárja be magát az Express példányt.
Gyakorló feladatok
-
Mi a célja az elsődleges kulcsnak egy SQL adatbázis táblában?
-
Mi a különbség a
db.all()
és adb.each()
használatával történő lekérdezés között? -
Miért fontos, hogy placeholdereket használjunk és a kliens által küldött adatokat ne közvetlenül az SQL utasításban vagy lekérdezésben szerepeltessük?
Gondolkodtató feladatok
-
A
sqlite3
modul melyik metódusa használható arra, hogy csak egy tábla bejegyzést adjon vissza, még akkor is, ha a lekérdezés több bejegyzésre is megfelel? -
Tegyük fel, hogy a
rows
tömböt egy callback függvény paramétereként adtuk át, és adb.all()
lekérdezés eredményét tartalmazza. Hogyan hivatkozhatunk a visszahívási függvényen belül egyprice
nevű mezőre, amely arows
első pozíciójában található? -
A
db.run()
metódus végrehajtja az adatbázis-módosító utasításokat, például azINSERT INTO
utasítást. Miután új rekordot illesztettünk be egy táblába, hogyan tudnánk lekérdezni az újonnan beillesztett rekord elsődleges kulcsát?
Összefoglalás
Ez a lecke az SQL-adatbázisok Node.js Express alkalmazásokon belüli alapvető használatát tárgyalja. Az sqlite3
modul egyszerű módját kínálja a tartós adatok SQLite adatbázisban való tárolásának, ahol egyetlen fájl tartalmazza a teljes adatbázist, és nincs szükség speciális adatbázis-kiszolgálóra. Ez a lecke a következő fogalmakat és eljárásokat veszi át:
-
Hogyan hozzunk létre adatbázis-kapcsolatot a Node.js-ből.
-
Az elsődleges kulcsok szerepét és azt, hogy hogyan hozzunk létre egy egyszerű táblát.
-
Az
INSERT INTO
SQL utasítás használata új adatok hozzáadásához a szkriptből. -
SQL lekérdezések a szabványos SQLite metódusok és callback függvények használatával.
-
Az adatok módosítása az adatbázisban az
UPDATE
ésDELETE
SQL utasítások használatával.
Válaszok a gyakorló feladatokra
-
Mi a célja az elsődleges kulcsnak egy SQL adatbázis táblában?
Az elsődleges kulcs egy adatbázis táblájában minden egyes rekord egyedi azonosítója.
-
Mi a különbség a
db.all()
és adb.each()
használatával történő lekérdezés között?A
db.all()
módszer a callback függvényt hívja meg egy olyan tömbbel, amely a lekérdezésnek megfelelő összes bejegyzést tartalmazza. Adb.each()
metódus minden egyes eredménysorhoz meghívja a callback függvényt. -
Miért fontos, hogy placeholdereket használjunk és a kliens által küldött adatokat ne közvetlenül az SQL utasításban vagy lekérdezésben szerepeltessük?
A placeholderekkel a felhasználó által megadott adatok a lekérdezésbe vagy utasításba való bekerülés előtt feloldásra kerülnek. Ez megnehezíti az SQL-injection támadásokat, amelyek során SQL-utasításokat helyeznek el a változókon belülre, hogy tetszőleges műveleteket hajtsanak végre az adatbázison.
Válaszok a gondolkodtató feladatokra
-
A
sqlite3
modul melyik metódusa használható arra, hogy csak egy tábla bejegyzést adjon vissza, még akkor is, ha a lekérdezés több bejegyzésre is megfelel?A
db.get()
módszer ugyanazzal a szintaxissal rendelkezik, mint adb.all()
, de csak a lekérdezésnek megfelelő első bejegyzést adja vissza. -
Tegyük fel, hogy a
rows
tömböt egy callback függvény paramétereként adtuk át, és adb.all()
lekérdezés eredményét tartalmazza. Hogyan hivatkozhatunk a visszahívási függvényen belül egyprice
nevű mezőre, amely arows
első pozíciójában található?A
rows
minden egyes eleme egy objektum, amelynek tulajdonságai megfelelnek az adatbázis mezőinek. Így az első eredményben aprice
mező értéke arows[0].price
mezőben található. -
A
db.run()
metódus végrehajtja az adatbázis-módosító utasításokat, például azINSERT INTO
utasítást. Miután új rekordot illesztettünk be egy táblába, hogyan tudnánk lekérdezni az újonnan beillesztett rekord elsődleges kulcsát?A
function(){}
formájú hagyományos függvény használható adb.run()
metódus visszahívási függvényeként. Ezen belül athis.lastID
tulajdonság tartalmazza az utoljára beillesztett rekord elsődleges kulcsának értékét.