kleindan.dev

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.

> index.js
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:

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 morgan

A następnie użyjmy go w naszej aplikacji.

> index.js
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.

> models/flashcards.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { DatabaseSync } from "node:sqlite";

const db_path = "./db.sqlite";
const db = new DatabaseSync(db_path);

console.log("Creating database tables");
db.exec(
  `CREATE TABLE IF NOT EXISTS fc_categories (
    category_id   INTEGER PRIMARY KEY,
    id            TEXT UNIQUE NOT NULL,
    name          TEXT NOT NULL
  ) STRICT;
  CREATE TABLE IF NOT EXISTS fc_cards (
    id            INTEGER PRIMARY KEY,
    category_id   INTEGER NOT NULL REFERENCES fc_categories(category_id) ON DELETE NO ACTION,
    front         TEXT NOT NULL,
    back          TEXT NOT NULL
  ) STRICT;`
);

const card_categories = {
  "j-angielski-food": {
    name: "j. angielski - food",
    cards: [
      { front: "truskawka", back: "strawberry" },
      { front: "gałka muszkatołowa", back: "nutmeg" },
  // ... itd., itd.

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 >> .gitignore

Tekst 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:

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ą?

> models/flashcards.js
// ... 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.

> models/flashcards.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import { DatabaseSync } from "node:sqlite";

const db_path = "./db.sqlite";
const db = new DatabaseSync(db_path);

db.exec(
  `CREATE TABLE IF NOT EXISTS fc_categories (
    category_id   INTEGER PRIMARY KEY,
    id            TEXT UNIQUE NOT NULL,
    name          TEXT NOT NULL
  ) STRICT;
  CREATE TABLE IF NOT EXISTS fc_cards (
    id            INTEGER PRIMARY KEY,
    category_id   INTEGER NOT NULL REFERENCES fc_categories(category_id) ON DELETE NO ACTION,
    front         TEXT NOT NULL,
    back          TEXT NOT NULL
  ) STRICT;`
);

const db_ops = {
  insert_category: db.prepare(
    `INSERT INTO fc_categories (id, name)
        VALUES (?, ?) RETURNING category_id, id, name;`
  ),
  insert_card: db.prepare(
    `INSERT INTO fc_cards (category_id, front, back) 
        VALUES (?, ?, ?) RETURNING id, front, back;`
  ),
};

const card_categories = {
  "j-angielski-food": {
    name: "j. angielski - food",
    cards: [
      { front: "truskawka", back: "strawberry" },
      { front: "gałka muszkatołowa", back: "nutmeg" },
      { front: "jabłko", back: "apple" },
      { front: "karczoch", back: "artichoke" },
      { front: "cielęcina", back: "veal" },
    ],
  },
  "stolice-europejskie": {
    name: "stolice europejskie",
    cards: [
      { front: "Holandia", back: "Amsterdam" },
      { front: "Włochy", back: "Rzym" },
      { front: "Niemcy", back: "Berlin" },
      { front: "Węgry", back: "Budapeszt" },
      { front: "Rumunia", back: "Bukareszt" },
    ],
  },
};

if (process.env.POPULATE_DB) {
  console.log("Populating db...");
  Object.entries(card_categories).map(([id, data]) => {
    let category = db_ops.insert_category.get(id, data.name);
    console.log("Created category:", category);
    for (let card of data.cards) {
      let c = db_ops.insert_card.get(
        category.category_id,
        card.front,
        card.back
      );
      console.log("Created card:", c);
    }
  });
}

// ...

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.js

Moż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:

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.

> models/flashcards.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import { DatabaseSync } from "node:sqlite";

const db_path = "./db.sqlite";
const db = new DatabaseSync(db_path);

db.exec(
  `CREATE TABLE IF NOT EXISTS fc_categories (
    category_id   INTEGER PRIMARY KEY,
    id            TEXT UNIQUE NOT NULL,
    name          TEXT NOT NULL
  ) STRICT;
  CREATE TABLE IF NOT EXISTS fc_cards (
    id            INTEGER PRIMARY KEY,
    category_id   INTEGER NOT NULL REFERENCES fc_categories(category_id) ON DELETE NO ACTION,
    front         TEXT NOT NULL,
    back          TEXT NOT NULL
  ) STRICT;`
);

const db_ops = {
  insert_category: db.prepare(
    `INSERT INTO fc_categories (id, name)
        VALUES (?, ?) RETURNING category_id, id, name;`
  ),
  insert_card: db.prepare(
    `INSERT INTO fc_cards (category_id, front, back) 
        VALUES (?, ?, ?) RETURNING id, front, back;`
  ),
  insert_card_by_id: db.prepare(
    `INSERT INTO fc_cards (category_id, front, back) VALUES (
      (SELECT category_id FROM fc_categories WHERE id = ?),
      ?, 
      ?
    ) 
    RETURNING id, front, back;`
  ),
  get_categories: db.prepare("SELECT id, name FROM fc_categories;"),
  get_category_by_id: db.prepare(
    "SELECT category_id, id, name FROM fc_categories WHERE id = ?;"
  ),
  get_cards_by_category_id: db.prepare(
    "SELECT id, front, back FROM fc_cards WHERE category_id = ?;"
  ),
};

export function getCategorySummaries() {
  var categories = db_ops.get_categories.all();
  return categories;
}

export function hasCategory(categoryId) {
  let category = db_ops.get_category_by_id.get(categoryId);
  return category != null;
}

export function getCategory(categoryId) {
  let category = db_ops.get_category_by_id.get(categoryId);
  if (category != null) {
    category.cards = db_ops.get_cards_by_category_id.all(category.category_id);
    return category;
  }
  return null;
}

export function addCard(categoryId, card) {
  return db_ops.insert_card_by_id.get(categoryId, card.front, card.back);
}

export function validateCardData(card) {
  var errors = [];
  var fields = ["front", "back"];
  for (let field of fields) {
    if (!card.hasOwnProperty(field)) errors.push(`Missing field '${field}'`);
    else {
      if (typeof card[field] != "string")
        errors.push(`'${field}' expected to be string`);
      else {
        if (card[field].length < 1 || card[field].length > 500)
          errors.push(`'${field}' expected length: 1-500`);
      }
    }
  }
  return errors;
}

export default {
  getCategorySummaries,
  hasCategory,
  getCategory,
  addCard,
  validateCardData,
};

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:

> utils/populate_db.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import flashcards from "../models/flashcards.js";

const card_categories = {
  "j-angielski-food": {
    name: "j. angielski - food",
    cards: [
      { front: "truskawka", back: "strawberry" },
      { front: "gałka muszkatołowa", back: "nutmeg" },
      { front: "jabłko", back: "apple" },
      { front: "karczoch", back: "artichoke" },
      { front: "cielęcina", back: "veal" },
    ],
  },
  "stolice-europejskie": {
    name: "stolice europejskie",
    cards: [
      { front: "Holandia", back: "Amsterdam" },
      { front: "Włochy", back: "Rzym" },
      { front: "Niemcy", back: "Berlin" },
      { front: "Węgry", back: "Budapeszt" },
      { front: "Rumunia", back: "Bukareszt" },
    ],
  },
};

console.log("Populating db...");

Object.entries(card_categories).map(([id, data]) => {
  let category = flashcards.addCategory(id, data.name);
  console.log("Created category:", category);
  for (let card of data.cards) {
    let c = flashcards.addCard(category.id, card);
    console.log("Created card:", c);
  }
});

Aby ten plik zadziałał, potrzebujemy w modelu models/flashcards.js dodać jeszcze jedną metodę, addCategory().

> models/flashcards.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// ...

export function addCategory(categoryId, name) {
  return db_ops.insert_category.get(categoryId, name);
}

// ...

export default {
  getCategorySummaries,
  hasCategory,
  getCategory,
  addCard,
  addCategory,
  validateCardData,
};

Po tych modyfikacjach możemy nawet usunąć nasz plik z bazą danych i uruchomić nowe narzędzie:

> rm db.sqlite
> node utils/populate_db.js

W 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.