Persystencja danych przy użyciu SQLite
Na ostatniej lekcji doszliśmy do punktu, w którym możemy tworzyć nowe fiszki. Nauczyliśmy się przesyłać dane użytkownika do serwera i poprawnie je obsługiwać. W tej lekcji nauczymy się, jak korzystając z dostępnych narzędzi w Node.js przechowywać dane na naszym serwerze. Ale zanim zaczniemy jeszcze wykorzystamy jedno narzędzie, które pomoże nam w rozwoju naszej aplikacji.
Wideo towarzyszące
Logowanie żądań HTTP
Kiedy jeszcze pracowaliśmy nad podstawowymi serwerami HTTP bez wykorzystania frameworka Express, jedną z informacji, które drukowaliśmy na potrzeby developmentu były informacje o przychodzących żądaniach HTTP. W Express możemy uzyskać podobny efekt dodając do naszej aplikacji dodatkową funkcję do logowania.
import express from "express";
import flashcards from "./models/flashcards.js";
const port = 8000;
const app = express();
app.set("view engine", "ejs");
app.use(express.static("public"));
app.use(express.urlencoded());
function log_request(req, res, next) {
console.log(`Request ${req.method} ${req.path}`);
next();
}
app.use(log_request);
// ... reszta handlerów
Express pozwala na dodawanie własnych funkcji przetwarzających wszystkie żądania i potencjalnie obsługujących je na własną rękę. Ten wzorzec w express jest nazywany middleware i pozwala na elastyczne i czytelne podejście do programowania aplikacji webowych.
Zasada działania funkcji middleware jest prosta. W momencie przyjścia żądania od klienta od serwera, serwer uruchamia kolejno wszystkie funkcje, które zarejestrowaliśmy podczas setupu serwera. W przypadku naszej aplikacji zaczynamy od funkcji zwróconej przez express.static, następnie express.urlencoded, potem nasza nowa funkcja log_request, a następnie nasze handlery ścieżek. Każda funkcja musi dać znać Express, czy w pełni obsłużyła żądanie klienta, czy przekazuje kontrolę kolejnym zarejestrowanym funkcjom. W przypadku przekazania obsługi dalej, funkcja powinna wykonać funkcję przekazaną w parametrze next.
Funkcja middleware może mieć dwa odmienne cele:
- Przetworzenie w jakiś sposób żądania klienta i przekazanie obsługi dalej
- Obsłużenie żądania i odesłanie odpowiedzi do klienta.
Nasza funkcja log_request i ta zwracana przez express.urlencoded stanowią przykład pierwszej kategorii. Pierwsza z nich po prostu drukuje żądanie na konsolę aplikacji, a druga przetwarza dane przesłane przez klienta np. w żądaniu POST i dopisuje je do obiektu req, aby kolejne funkcje miały łatwiejszy dostęp do tych danych.
Funkcja zwrócona przez express.static ma z goła inny cel. Jeżeli żądanie klienta będzie się odnosić do jednego z plików zawartych w katalogu public, to funkcja zwróci do klienta odpowiedź i w tym miejscu przerwie łańcuch przetwarzania żądania.
Zauważ że skoro express.static jest wywołane przed zarejestrowaniem funkcji log_request, co oznacza, że żądania o pliki statyczne nie powinny pojawić się w naszych logach. Upewnij się, że tak jest, a następnie zastanów się, czy chcemy to zmienić. Z jednej strony może dobrze byłoby wiedzieć o wszystkich żądaniach do naszej strony, a z drugiej czy te informacje są rzeczywiście interesujące? Czy pozwolą w lepszy sposób zrozumieć działania naszych użytkowników, czy będą stanowiły szum informacyjny w logach?
Logowanie przy użyciu zewnętrznego narzędzia
Nasza funkcja do logowania spełnia swoje podstawowe zadanie, ale jeżeli chcemy czegoś bardziej wyrafinowanego, to możemy użyć np. middleware morgan, które zaoferuje nam trochę więcej informacji. Zainstalujmy morgan używając npm.
> npm install morganA następnie użyjmy go w naszej aplikacji.
import express from "express";
import morgan from "morgan";
import flashcards from "./models/flashcards.js";
const port = 8000;
const app = express();
app.set("view engine", "ejs");
app.use(express.static("public"));
app.use(express.urlencoded());
app.use(morgan("dev"));
// ... reszta handlerów
Uruchom serwer i wejdź na kilka podstron naszej aplikacji i np. dodaj jakąś nową fiszkę do którejś z kategorii. Sprawdź, jakie informacje dostajemy w logach.
Jak widać pojawia się nie tylko informacja o tym, czego zażądał klient, ale także jaki status HTTP zwróciła nasza aplikacja, ile czasu zajęło naszemu serwerowi przetwarzanie żądania i ile bajtów zawierała odpowiedź serwera. Wszystkie te informacje mogą być bardzo przydatne podczas developmentu.
Jeżeli chcesz, możesz sprawdzić na stronie pakietu morgan, jakie inne informacje możemy uzyskać oraz jakie sposoby logowania poza "dev" oferuje.
Persystencja danych naszej aplikacji
Standardowym rozwiązaniem przechowywania danych w aplikacjach internetowych jest umieszczenie informacji w bazie danych. Istnieje wiele różnych rozwiązań bazodanowych, z których moglibyśmy skorzystać, ale ja zaproponuję skorzystanie z minimalistycznej, lokalnej bazy danych SQLite.
Normalnie korzystanie z bazy danych w frameworku Express wymaga użycia tzw. drivera, jest to pokrótce opisane w dokumentacji Express. Natomiast od wersji 22 Node.js oferuje natywne wsparcie dla SQLite, więc spróbujmy skorzystać z tej opcji. Jest to funkcjonalność jeszcze na etapie rozwoju, ale póki co nie planujemy wypuszczania naszej aplikacji na produkcję.
Tworzenie bazy danych
Ponieważ wszystkie funkcjonalności związane z danymi naszej aplikacji są zawarte w pliku models/flashcards.js, nasze zmiany będą dotyczyły głownie tego pliku.
Póki co pozostawiając naszą testową strukturę danych, utworzymy tylko połączenie z naszą bazą danych i stworzymy w niej tabele, w których umieścimy nasze fiszki.
| |
W linii 4 tworzymy nowe połączenie z bazą danych, która będzie zawarta w całości w pliku db.sqlite w naszym lokalnym katalogu. Jeżeli takiego pliku nie ma, to zostanie on po prostu stworzony. Następnie w linii 7 uruchamiamy kwerendę SQL do wykonania na naszej bazie danych.
Uwaga Na tym etapie warto dopisać do .gitignore plik db.sqlite albo nawet wszystkie pliki *.sqlite, bo prawdopodobnie nie chcemy bazy danych w naszym repozytorium. Po pierwsze git nie radzi sobie najlepiej z plikami binarnymi, a po drugie w przyszłości możemy przypadkiem moglibyśmy udostępnić światu informacje, których nie chcemy nikomu udostępniać.
> echo *.sqlite >> .gitignoreTekst kwerendy tworzącej tabele zawrzemy bezpośrednio w kodzie naszej aplikacji. W linii 8 tworzymy pierwszą z naszych tabel, która będzie zawierać nazwy kategorii fiszek. Ponieważ w naszej aplikacji identyfikatorem kategorii jest ciąg znaków, np. “j-angielski-food”, nadal chcemy zachować tę informację jako pole “id”, ale dobrą praktyką w modelowaniu baz danych jest wybieranie na klucze główne danych liczb całkowitych. W ten sposób prawdopodobnie znajdowanie powiązanych ze sobą wartości będzie szybsze. Dlatego definiujemy w tej tabeli 3 kolumny:
category_idzawierającą liczbę całkowitą jako klucz główny (co oznacza, że będzie to pole niepuste, automatycznie generowane przez SQLite)idjako wartość tekstowa, która musi być unikatowa oraz nie może być pustanamedo przechowywania nazwy do wyświetlania na stronie, która nie może być pusta
W linii 13 stworzymy powiązaną tablicę z fiszkami, która ma cztery kolumny zawierające odpowiednie dane. Kolumna category_id jest tzw. foreign key wskazującym na dane z tabeli fc_categories. To powoduje, że nie może to być kolumna pusta, albo wskazująca na nieistniejący rząd we wskazywanej tabeli. Dodatkowo dodając warunek "ON DELETE NO ACTION" nakładamy na SQLite obowiązek sprawdzenia przed usunięciem kategorii z tabeli fc_categories, czy w ten sposób nie osieroci żadnych wpisów z obecnej tabeli.
Sprawdźmy, czy baza danych zachowa się tak, jak się tego spodziewamy.
Testy warunków w bazie danych
Wklej ten kawałek kodu do pliku models/index.js po utworzeniu tabel w bazie danych. Uruchom serwer i przeanalizuj wyjście programu. Czy wszystkie komendy, które powinny się udać Twoim zdaniem się udają? Czy te, które zwracają błąd Cię w jakiś sposób zaskakują?
// ... po utworzeniu tabel
var result = db
.prepare(
`INSERT INTO fc_categories (id, name) VALUES ('test1', 'kategoria testowa');`
)
.run();
console.log("Insert", result);
var test_id = result.lastInsertRowid;
try {
var result = db
.prepare(
`INSERT INTO fc_categories (id, name) VALUES ('test1', 'kategoria zduplikowana');`
)
.run();
console.log("Insert duplicate", result);
} catch (error) {
console.log(error);
}
try {
var result = db
.prepare(`INSERT INTO fc_categories (id) VALUES ('no-name');`)
.run();
console.log("Insert without name", result);
} catch (error) {
console.log(error);
}
try {
var result = db
.prepare(`INSERT INTO fc_categories (name) VALUES ('Kategoria bez id');`)
.run();
console.log("Insert without id", result);
} catch (error) {
console.log(error);
}
try {
var result = db
.prepare(
`INSERT INTO fc_cards (category_id, front, back) VALUES (?, 'awers', 'rewers');`
)
.run(test_id);
console.log("Insert card", result);
} catch (error) {
console.log(error);
}
try {
var result = db
.prepare(
`INSERT INTO fc_cards (category_id, front, back) VALUES (350, 'awers', 'rewers');`
)
.run();
console.log("Insert card with non-existent category id", result);
} catch (error) {
console.log(error);
}
try {
var result = db
.prepare(`INSERT INTO fc_cards (front, back) VALUES ('awers', 'rewers');`)
.run();
console.log("Insert card without category", result);
} catch (error) {
console.log(error);
}
try {
var result = db
.prepare(`DELETE FROM fc_categories WHERE category_id = ?;`)
.run(test_id);
console.log("Delete non-empty category", result);
} catch (error) {
console.log(error);
}
try {
var result = db
.prepare(`DELETE FROM fc_cards WHERE category_id = ?;`)
.run(test_id);
console.log("Delete cards", result);
} catch (error) {
console.log(error);
}
try {
var result = db
.prepare(`DELETE FROM fc_categories WHERE category_id = ?;`)
.run(test_id);
console.log("Delete empty category", result);
} catch (error) {
console.log(error);
}
db.exec("DROP TABLE fc_cards; DROP TABLE fc_categories;");Przyjrzyjmy się jeszcze powyższemu kodowi, konkretnie ostatniemu blokowi try {...} catch. Funkcja db.prepare() służy do stworzenia tzw. prepared statement, czyli wyrażenia SQL wstępnie skompilowanego, do którego dodatkowo możemy dołożyć parametry z zewnątrz. W tym przypadku uruchamiamy nasze wyrażenie metodą run() z argumentem test_id. To spowoduje, że pierwszy znak zapytania w przygotowanym wyrażeniu zostanie zastąpiony wartością ze zmiennej test_id.
Wypełnienie bazy danych danymi testowymi
Możemy śmiało usunąć powyższy kod, miał nam posłużyć tylko do sprawdzenia, czy baza danych zachowuje się tak, jak tego oczekujemy. Na przyszłość możemy śmiało zakładać, że tak właśnie jest.
Ponieważ pusta baza danych nie pozwoli nam na testowanie naszej obecnej aplikacji, wypełnijmy ją dotychczasowymi danymi testowymi.
| |
W liniach 20-29 stworzyliśmy obiekt, który zawiera przygotowane wyrażenia SQL, których następnie możemy użyć do wstawiania danych do naszej bazy.
W liniach 54-68 mamy kod, który korzystając z powyższych wyrażeń wypełni bazę naszymi danymi testowymi. Warunek sprawdzający zmienną process.env.POPULATE_DB sprawi, że normalnie uruchomiony serwer automatycznie nie uruchomi tego kawałka kodu. Aby faktycznie wypełnić bazę danych uruchom serwer następującą komendą:
> POPULATE_DB=1 node index.jsMożesz wyłączyć serwer, program powinien był wypełnić bazę danych, ale na chwilę obecną nasza aplikacja korzysta nadal z danych w lokalnych zmiennych. Teraz możemy zmodyfikować nasze funkcje zwracające i dodające dane tak, aby korzystały z bazy danych.
Uruchamianie przygotowanych wyrażeń SQL
W naszym kodzie testującym, czy poprawnie stworzyliśmy tabele uruchamialiśmy wyrażenia metodą run(). To metoda, która domyślnie służy do modyfikacji danych w bazie danych i zwraca obiekt, w którym pole lastInsertRowid zawiera primary key ostatniego zmodyfikowanego wpisu w bazie danych. Istnieją inne sposoby uruchamiania przygotowanych wyrażeń, my wykorzystamy dwie inne:
- metoda
get()zwracająca pojedynczy obiekt zawierający dane zwrócone przez ostatnie uruchomione wyrażenie - metoda
all()zwracająca tablicę obiektów ze wszystkimi rzędami zwróconymi przez ostatnie wyrażenie
Jeżeli jesteś ciekawy pozostałych opcji, zajrzyj do dokumentacji Node.js.
Dlaczego w powyższym przykładzie dla wyrażeń “INSERT” korzystamy z metody get()? Ponieważ nasze wyrażenia kończymy wyrażeniem “RETURNING”, co pozwala nam przy okazji wydobyć z powrotem dane umieszczone w bazie danych. Dzięki temu możemy je np. wydrukować na konsolę i upewnić, że wszystko przebiegło zgodnie z planem.
Korzystanie z bazy danych w aplikacji
Aby móc korzystać z danych w bazie, musimy na pewno dopisać odpowiednie zapytania SQL wyciągające odpowiednie dane oraz podmienić implementację naszych funkcji. Przeanalizuj na spokojnie zawartość poniższego pliku i upewnij się, że rozumiesz jak działa.
| |
Uruchom serwer i sprawdź, czy aplikacja zachowuje się tak, jak wcześniej. Dodaj nową fiszkę albo kilka, a następnie zrestartuj serwer i upewnij się, że dane przetrwają restart.
Jeżeli wszystko poszło zgodnie z planem, to możemy sobie pogratulować. Mamy teraz aplikację, która przechowuje trwale informacje w bazie danych!
Przywrócenie możliwości populacji bazy danymi testowymi
Gdyby w trakcie developmentu w naszej bazie znalazło się zbyt wiele danych, albo gdybyśmy je wszystkie usunęli, warto mieć jakiś sposób powrotu do znanego stanu. Stwórzmy sobie narzędzie, które nam to umożliwi. Stwórz w swoim projekcie następujący plik:
| |
Aby ten plik zadziałał, potrzebujemy w modelu models/flashcards.js dodać jeszcze jedną metodę, addCategory().
| |
Po tych modyfikacjach możemy nawet usunąć nasz plik z bazą danych i uruchomić nowe narzędzie:
> rm db.sqlite
> node utils/populate_db.jsW ten sposób zawsze będziemy mogli wrócić do dobrze ustalonego stanu z danymi testowymi zamiast pustej bazy.
Podsumowanie
Udało nam się zintegrować naszą aplikację z bazą danych, mamy narzędzie pozwalające nam na wypełnienie bazy danymi testowymi i do tego wszystkiego dołożyliśmy jeszcze logowanie żądań i odpowiedzi HTTP dla naszego serwera.
Wszystko to stanowi bardzo dobrą bazę do tego, żeby naszą aplikację doszlifować zanim przejdziemy do kolejnych dużych tematów.