Express + TypeScript - konfiguracja MongoDB

Wstęp

Instancja bazy danych

Aby nie robić budować aplikacji na "mockowanych" danych w postaci nietrwałej listy elementów, kolejnym krokiem jest podpięcie bazy danych MongoDB. W warunkach prostego developmentu wykonać to można na przynajmniej dwa sposoby. Pierwszy z nich to wykorzystanie cloud'owego klient MongoDB czyli Mongo Atlas. Drugi natomiast, to użycie dokera i, na czas developmentu, postawienie lokalnie kontenera z lokalną bazą Mongo.

Skożystam z drugiego rozwiązania. Wszystkie niezbędne informacje potrzebne do uruchomienia kontenerea znalazłem na strnie oficjalnego image mongo. Wystarczy odpalić w terminalu poniższą komendę i cieszyć się kontenerem.

terminal
docker run -d -p 27017:27017 --name mongo-books \
  -e MONGO_INITDB_ROOT_USERNAME=user \
  -e MONGO_INITDB_ROOT_PASSWORD=password \
  mongo

Podczas tworzenia przekazuje kontenerowi trzy zmienne środowiskowe, które będą miały wpływ później na to jak podłączam się wewnątrz kodu do kontenera. MONGO_INITDB_ROOT_USERNAME i MONGO_INITDB_ROOT_PASSWORD warunkują login i hasło, które będzie trzeba przekazać aby połączyć się z instancją mongo w kontenerze.

Po stworzeniu instancji w kontenerze powstaje defaultowa baza test, w niej będą zapisywane moje kolekcje. Można zmieniić to zachowanie, ale wykracza to poza zakres tego prostego przykładu.

Połączenie z bazą

Gdy instancja bazy danych jest już uruchomiona, kolejnym krokiem jest zainstalowanie paczki Mongoose, która jest ODM-em dla Mongo (Object Data Modeling), czyli pakietem ułatwiającym pracę z Mongo dla aplikacji pisanych w Node. O mongoose można myślec jak o ORM-ach dla baz danych SQL. Pomaga budować scheme dokumentów i ułatwia validacię danych.

terminal
npm i mongoose

Mogę teraz ustanowić podstawowe połączeni do bazy danych dodają metodę connect() pakietu mongoose do pliku src/index.ts. Metoda connect przyjmuje connection string, oraz opcjonalnie obiekt ustawień połączenia i callback, który odpali się po nawiązaniu połącznia. Ja pomijam obiekt ustawień, gdyż mongoose ma bardzo dobre i w zupełności wystarczające w takiej szkoleniowej aplikacji defaulty.

src/index.ts
import { connect } from "mongoose";
import app from "./app";

const PORT = process.env.PORT || 5000;

// mongodb://<userName>:<password>@localhost:27017
connect("mongodb://user:password@localhost:27017", () =>
  console.log("Connected to MongoDB")
);

app.listen(PORT, () => {
  console.log(`Server started on port ${PORT}!`);
});

console.log w callback'u powie nam o powodzeniu połączenia z bazą danych. Zobaczymy go w terminalu zaraz pod informacją, że nasz serwer wystartował na porcie 5000. Wewnątrz connection string w metodzie connect() odpowiednie miejsca trzeba podmienić swoimi danymi przekazanymi kontenerowi dockera .

Schema i Model danych

Podstawową cechą odrózniającą MongoDB, od baz SQL jest to, że baza przyjmie różne struktury danych. Dla developera jednak, za duża dowolność może być zgubna, z tego też powodu używam mongoose, który pozwala budować scheme obiektu danych, czyli taki jakby wzorzec tego co będzie w niej przetrzymywane.

Na potrzeby samej schemy i modelu danych, który zostanie na jej podstawie zbudowany tworzę nowy plik src/books/books.model.ts. W nim powstaje interfejs IBook opisujący typ obiektu danych, który będzie przechowywany w bazie. Na jego podstawie tworzę schemę obiektu, w której zaznaczam między innymi typ danych (musi odpowiadać temu z interfejsu, ale przekazany w postaci konstruktorów), oraz czy pole jest wymagane. W schemie można również przkazać parametry walidacyjne czy np. zawrzeć defaultowe wartości.

Na koniec eksportuję zbudowany na podstawie interfejsu i schemy mongoose model, który zaimportowany w innych plikach pozwoli na zapisy i odczyty danych w nim zdefiniowanych.

Do modelu danych opisywanego w poprzednich artykułach dodałem jeszcze enum bookCover. W schemie podaję, że jest on type: String, ale również, że ma przyjmować wartość enuma.

src/books/books.model.ts
import { Schema, model } from "mongoose";

export enum bookCover {
  paperback = "paperback",
  hardcover = "hardcover",
}

interface IBook {
  title: string;
  author: string;
  published: number;
  cover: bookCover;
}

export type CreateBookInput = IBook;
export type UpdateBookInput = Partial<IBook>;

const bookSchema = new Schema<IBook>({
  title: { type: String, required: true },
  author: { type: String, required: true },
  published: Number,
  cover: { type: String, enum: bookCover, required: true },
});

export const Book = model<IBook>("Book", bookSchema);

Z pliku src/books/books.model.ts eksportuję również dwa typy CreateBookInput i UpdateBookInput. Będą one typami, ltóre oczekuje, ze otrzymam w req.body requestów do tworzenie i updatowania rekordów. Dzięki dziedziczeniu po tym samym interfejsie co schema zachowuję bardzo czytelną spójność danych.

TypeScript utility types

Globalne typy pomocnicze typescript ułatwiają najczęstsze transformacje typów. Majczęściej używane preze mnie to:

  • Partial<Type> - tworzy typ, dla którego wszystkie pola typu transformowanego Type stają się opcjonalne;
  • Pick<Type, Keys> - tworzy typ zbudowany na bazie transformowanego typu Type, lecz wybiera tylko jego pola przekazane w drugim argumencie. Gdy pól jest więcej niż jednio, należy je przekazać jako stringi oddzielone znakiem | (pionow kreska), np. Pick<Todo, "title" | "completed">;
  • Omit<Type, Keys> - podobnt do Pick, lecz buerze wszystie pola typu Type, poza tymi przekazanymi w drugim argumencie.

Wykorzystanie modelu

Gdy model jest już gotowy, pora wykorzystać go w serwisach. W bardziej rozbudowanych systemach zazwyczaj wydziela się dodatkową warstwę aplikacji, mianowicie repozytorim opisujące typowo metody do zapisu i odczytu danych. Ja dla uproszczenia pomijam tutaj ten krok i operacje na modelu Book będą wykonywane w src/books/books.service.ts.

src/books/books.service.ts
import { Book, CreateBookInput, UpdateBookInput } from "./books.model";

const getAllBooks = async () => await Book.find({});

const getBookById = async (id: string) => await Book.findById(id);

const createBook = async (input: CreateBookInput) => {
  const { title, author, published, cover } = input;
  return await Book.create({ title, author, published, cover });
};

const deleteBookById = async (id: string) => {
  await Book.findByIdAndDelete(id);
};

const updateBookById = async (id: string, input: UpdateBookInput) => {
  const { title, author, published, cover } = input;
  return await Book.findByIdAndUpdate(
    id,
    { title, author, published, cover },
    { new: true }
  );
};

export default {
  getAllBooks,
  getBookById,
  createBook,
  updateBookById,
  deleteBookById,
};

Nie potrzebuję już teraz listy const books: Book[] = [], która służyła jako storage, oraz metody randomId. Jedyne co jest potrzebne poza samymi metodami to import modelu Book (oraz typu BookInput do przekazywania danych metodom je wprowadzajacym).

GET all books

Metoda .find({}) modelu Book zwraca wszystkie rekordy znalezione w kolekcji. W przypadku potrzeby ograniczenia ilości pobieranych danych lub jakiejś filtracji, metodzie .find() można przekazać dodatkowe parametry. Tak wywołana metoda modelu zwraca wszystkie znalezione rekordy jako listę.

src/books/books.service.ts
const getAllBooks = async () => await Book.find({});

Metodę getAllBooks() servisu wywołuję w kontrolerze getAllBooksHandler(), a jej response zwracam w postaci JSON standardowym res.json(), które było używane dotąd.

Główna różnica w src/books/books.controller.ts polega na tym, że obie metody: getAllBooks() serwisu oraz getAllBooksHandler() kontrolera, są teraz metodami asynchronicznymi. Z tego powodu, aby zapobiec błędom całej aplikacji przy problemach z pobieraniem danych, kod związany z użyciem serwisu zamykam w try/catch. Wtedy w przypadku błędu zwracam jego treść oraz HTTP status 500 - Internal Server Error, świadczący o problemie aplikacji.

src/books/books.controller.ts
const getAllBooksHandler = async (req: Request, res: Response) => {
  try {
    const response = await bookService.getAllBooks()
    res.json({ data: response });
  } catch (error) {
    res.status(500).json({ error });
  }
};

W kolejnych wpisach opiszę bardziej generyczne i poprawne sposoby error handlingu.

POST book

Aby dodać rekord do kolekcji będę używał metody Book.create(). Jako parametry przekazujemy argumenty zgodne ze schema. Metoda create() tworzy obiekt Book i zapisuje go w bazie, zwracając nowozapisany obiekt.

src/books/books.service.ts
const createBook = async (input: CreateBookInput) => {
  const { title, author, published, cover } = input;
  return await Book.create({ title, author, published, cover });
};

Funkcja createBook() serwisu zwracanowozapisany obiekt, więc w kontrolerze zwracam go w przypadku powodzenia akcji. Dodatkowo ustawiam HTTP status na 201 - Created, mówiący o powodzeniu akcji dodania zasobu.

src/books/books.controller.ts
const createBookHandler = async (req: Request, res: Response) => {
  const { title, author, published, cover } = req.body;
  try {
    const response = await bookService.createBook({ title, author, published, cover });
    res.status(201).json({ data: response });
  } catch (error) {
    res.status(500).json({ error });
  }
};

Podobnie jak we wcześniejszej metodzie, w razie niepowodzenia zwracamy error i HTTP status 500 - Internal Server Error

Możemy teraz za pomocą konsolowego pakietu cURL wysłać pierwszy rekord do bazy.

terminal
curl -X POST 'localhost:5000/api/books' -H 'Content-Type: application/json' \
-d '{
  "title": "Lód",
  "author": "Dukaj",
  "published": 2001,
  "cover": "hardcover"
}'
{
  "data": {
    "title": "Lód",
    "author": "Dukaj",
    "published": 2001,
    "cover": "hardcover"
    "_id": "63ac4658f8e95c6765f38fbe",
    "__v": 0
  }
}

No to sukces! Dodam jeszcze jeden rekord, do bazy danych i pobiorę wszystkie rekordy (GET all books).

terminal
curl 'localhost:5000/api/books'
{
  "data": [
    {
      "_id": "63ac4658f8e95c6765f38fbe",
      "title": "Lód",
      "author": "Dukaj",
      "published": 2001,
      "cover": "hardcover"
      "__v": 0
    },
    {
      "_id": "63ac46ee80982730241acf60",
      "title": "Dzieci Diuny",
      "author": "Dukaj",
      "published": 2003,
      "cover": "hardcover"
      "__v": 0
    }
  ]
}

GET single book

Ponownie budowa metod w serwisie i kontrolerze będzie bardzo zbliżona do poprzednich.

src/books/books.service.ts
const getBookById = async (id: string) => await Book.findById(id);

W serwisie używamy Book.findById(id), która zwraca rekord zgodny z przekazanym id, lub null, gdy nic nie znajdzie. Praktycznie tak samo działało by wywołanie findOne({ _id: id }), dokumentacja jednak zaleca, by wyszukując po id, używać bezpośrednio przeznaczonej do tego metody.

Z poziomu kontrolera, różnica pomiędzy getBookByIdHandler() a getAllBooksHandler() polega jedynie na obsłudze przypadku, gdy service nie znalazł rekordu z podanym id. Muszę wtedy zwrócić informacje o tym wraz z HTTP status 404 - Not Found. Ważny jest return przed tą odpowiedzią, aby metoda zakończyła się w tym miejscu i nie starała się zrobić nic po if.

src/books/books.controller.ts
const getBookByIdHandler = async (req: Request, res: Response) => {
  const { id } = req.params;
  try {
    const response = await bookService.getBookById(id);
    if (!response) {
      return res.status(404).json({ error: "Book not found" });
    }
    res.json({ data: response });
  } catch (error) {
    res.status(500).json({ error });
  }
};

Mogę przetestować pobranie pojedyńczego rekordu.

terminal
curl localhost:5000/api/books/63ac4658f8e95c6765f38fbe
{
  "data": {
    "_id": "63ac4658f8e95c6765f38fbe",
    "title": "Lód",
    "author": "Dukaj",
    "published": 2001,
    "cover": "hardcover"
    "__v": 0
  }
}

PUT (update) book record

Aby znaleźć i zmienić rekord w bazie tym razem skorzystam z metody Book.findByIdAndUpdate(), która jako argumenty przyjmuje id rekordu, który chcę zmienić oraz zmieniane pola.

Jako zmieniane pola przekazuję wszystkie, które mogę zmienić. Nic nie stoi na przeszkodzie aby w requeście przekazać tylko wybrane. Spowoduje to, że podmienione zostaną tylko te przekazane.

src/books/books.service.ts
const updateBookById = async (id: string, input: UpdateBookInput) => {
  const { title, author, published, cover } = input;
  return await Book.findByIdAndUpdate(
    id,
    { title, author, published, cover },
    { new: true }
  );
};

Ważne też aby jako opcje metody (trzeci parametr) przekazać { new: true } co spowoduje, że rekord zwrócony po poprawnym wykonaniu się metody, będzie rekordem zupdatowanym. Defaoultowo był by to rekord przed updatem.

src/books/books.controller.ts
const updateBookByIdHandler = async (req: Request, res: Response) => {
  const { id } = req.params;
  const { title, author, published, cover } = req.body;
  try {
    const response = await bookService.updateBookById(id, { title, author, published, cover });
    res.status(200).json({ data: response });
  } catch (error) {
    res.status(500).json({ error });
  }
};

Po poprawnym wykonaniu operacji zwracam HTTP status na 200 - OK, oraz zmieniony rekord, a przy niepowodzeniu standardowo error i HTTP status 500 - Internal Server Error

DELETE book

Tutaj nie ma już żadnej filozofii. Metoda service bardzo podobane do tej pobierajacej pojedynczy rekord, tym razem jednak wywołuję metodę Book.findByIdAndDelete() przekazując id rekordu, który chcę usunąć.

src/books/books.service.ts
const deleteBookById = async (id: string) => {
  await Book.findByIdAndDelete(id);
};

Metoda nie zwraca nic, więc w kontrolerze również nic nie przechwytuję, zwracam jedynie HTTP status 204 - No Content. Mówi on o powodzeniu operacji, przy czym nie ma żadnych danych do przekazania.

src/books/books.controller.ts
const deleteBookByIdHandler = async (req: Request, res: Response) => {
  const { id } = req.params;
  try {
    await bookService.deleteBookById(id);
    res.status(204);
  } catch (error) {
    res.status(500).json({ error });
  }
};

Lepszy connect

Ostatnim krokiem będzie zadbanie o wydzielenie kodu odpowiedzialnego za połączenie z bazą do oddzielnego pliku, oraz zadbanie o poprawne zarządzanie połączeniem. W pliku src/config/dbConnection.ts umieszczam wcześniej używaną metodę connect() tym jednak razem dochodzi kilka elementów.

src/config/dbConnection.ts
import { connect, connection } from "mongoose";

const DB_USER = process.env.DB_USER;
const DB_PASS = process.env.DB_PASS;
const DB_HOST = process.env.DB_HOST;
const DB_PORT = process.env.DB_PORT;

export const conncetDB = () => {
  connect(`mongodb://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}`);
};

connection.on("connecting", () =>
  console.log("connecting to mongoDB...")
);
connection.on("connected", () =>
  console.log("succesfully connected to mongoDB!")
);
connection.on("disconnected", () => {
  console.log("disconnected from mongoDB!");
  setTimeout(conncetDB, 1000);
});
connection.on("error", () =>
  console.log("could not connect to mongoDB!")
);

Po pierwsze, wszystkie elementy connecton string powinny zostać załadowane jako zmienne środowiskowe! Jest to dobra praktyka, która pozwala na pracę w lokalnym środowisku bez zmian w kodzie. Wystarczy wtedy w pliku .env zawrzeć niezbędne zmienne.

.env
PORT=5000
DB_USER=user
DB_PASS=password
DB_HOST=localhost
DB_PORT=27017

Drógim elementem, na który warto zwrócić uwagę, to nasłuch na zdarzenia, powiązane z połączeniem do bazy danych. Pozwala nam na to dodanie callbacków do tych zdarzeń. W tym przypadku są to proste console.log() z informacją. Jedynie w przypadku eventu "disconnected" próbuję po sekundzie ponownie się połączyć.

mongoose obsługuje wiele eventów związanych z połączeniem z bazą danych, podstawowe z nich to:

  • connecting - rozpoczyna połączeni;
  • connected - połączony;
  • disconnected - rozłączony (również w przypadku błędu);
  • reconnecting - ponawia próbę połączenia;
  • close - poprawnie zakończone połączenie;
  • error - błąd połączenia;
  • all - wszystkie eventy

Teraz tylko importuję i odpalam funkcję connectDB() w src/index.ts i wszystko działa tak samo jak przed zmianami, a połączenie z bazą jest oddzielone od kodu samego servera.

src/index.ts
import app from "./app";
import { conncetDB } from "./config/dbConnection";

const PORT = process.env.PORT || 5000;

conncetDB();

app.listen(PORT, () => {
  console.log(`Server started on port ${PORT}!`);
});

Podsumowanie

Podłączenie bazy danych, w tym wypadku MongoDB było kolejnym krokiem w budowaniu boilerplate REST-owej aplikacji pisanej przy użyciu frameworku Express i TypeScript.

Kompletny kod aplikacji z powyższego artykułu można znaleźć w repozytorium github.