06 - MongoDb
Struttura di un progetto
Fino a questo momento abbiamo implementato tutta la nostra logica all'interno di quello che viene definito controller, registrando le varie chiamate direttamente nell'app.
Andiamo a ristrutturare la nostra app secondo questa struttura, e vediamone i benefici:
- src
- api
- entity1
- entity1.controller.ts
- entity1.service.ts
- entity1.entity.ts
- entity1.router.ts
- entity2
- entity2.controller.ts
- entity2.service.ts
- ...
- routes.ts
- entity1
- app.ts
- index.ts
- api
index.ts
Rappresenta il punto di avvio della nostra app. Non configura nulla dell'applicazione, si occupa di importare un'app di express già configurata, andare ad avviare alcuni servizi globali (ad esempio connessione al db) e avviare il server.
app.ts
Contiene tutta la configurazione relativa ad express: middleware globali, gestione degli errori, definizioni delle route radice del server, ecc.
cartella api
Contiene il codice relativo alle singole api che compongono la nostra applicazione.
Ogni tipo di entità rappresentata da una API corrisponde a una sottocartella.
E' inoltre presente un file routes.ts
controller
Definisce le business logic dei singoli entrypoint. Non si occupa delle logiche a basso livello, utilizza le logiche di altri provider (services) e le compone fino a ottenere il risultato desiderato dalle api. E' la parte dell'applicazione che è a conoscenza dell'esistenza di request e response, potenzialmente le logiche degli altri tipi di file potrebbero essere anche usate per comporre servizi con GraphQL, websocket o renderizzare pagine.
Nei controller deve vivere quindi solo la logiche che è relativa all'elaborazione della richiesta e la composizione della risposta.
service
Contiene la logica più a basso livello: interrogazione del database, elaborazione di dati, ecc.
Il service dovrebbe essere strutturato come se non sapesse da chi viene utilizzato, in questo modo le funzioni che espone possono essere riutilizzate in diversi contesti per comporre logiche più complesse.
entity
Contiene interfacce e classi che rappresentano l'entità fornita dall'API. Questa interfaccia dovrebbe essere generica e slegata dallo specifico database utilizzato o più in generale dalla sorgente dei dati. Controller e service si scambiano dati che implementano questa interfaccia. E' compito del service quindi fare da "astrazione" tra il database e il dato grezzo che si aspetta il controller, in questo modo la sorgente dei dati potrebbe anche cambiare ma tutto funziona fintanto che il service ritorna sempre lo stesso tipo di dato.
router
Definisce gli entrypoint relativi all'entità della cartella, in questo modo ogni entità definisce le sue API in un file separato, vengono poi messe insieme dal file routes.ts nella cartella api e in seguito registrati in app.ts. Questo rende semplice cambiare gli url delle api o riutilizzarle in diversi contesti.
Esercizio
Ristrutturiamo la nostra app secondo la struttura mostrata:
- iniziamo con la definizione della entity
- dividiamo la logica tra servizi e controller
Per il momento teniamo la registrazione degli entrypoint in app.ts, e vediamo come funziona il concetto di router.
Tipizzazione dei controller
Sviluppando le API ci siamo resi conto che dobbiamo spesso andare a leggere dati da req.body, req.query e req.params, ma typescript al momento non ha idea di quali dati ci siano a disposizione in una specifica richiesta.
Se andiamo a vedere com'è definita l'interfaccia Request di express ci accorgiamo che è definita tramite dei generics, e ci permette di fornire i tipi di ReqBody, ReqQuery e P che sono i params.
Siccome non è comodo andare a definire tutti i parametri per tipizzare i body possiamo andare a creare noi una nuova interfaccia che estende quella base e ci semplifica l'utilizzo:
typed-request.interface.ts
import { Request } from 'express';
import { ParamsDictionary } from 'express-serve-static-core';
import { ParsedQs } from 'qs';
export interface TypedRequest<T = any, Q = ParsedQs, P = ParamsDictionary>
extends Request<P, any, T, Q> { };
export { ParsedQs, ParamsDictionary };
In questo modo nella maggior parte dei casi (quelli in cui voglio accedere esclusivamente al body), posso passargli solo il primo tipo, altrimenti posso andare a definire tutto.
Una funzione del controller tipizzata correttamente a questo punto diventa ad esempio:
export const add = async (
req: TypedRequest<AddCartItemDTO>,
res: Response,
next: NextFunction) => {
try {
const { productId, quantity } = req.body;
const product = await productService.getById(productId);
if (!product) {
throw new NotFoundError();
}
const newItem: CartItem = {
product: productId,
quantity
};
const saved = await cartItemService.add(newItem);
res.json(saved);
} catch(err) {
next(err);
}
}
Da notare che tutti i tipi del generics hanno un valore predefinito, questo ci permette di usare TypedRequest esattamente come usavamo Request (senza specificare i tipi), e accedere ad oggetti generici.
Se invece vogliamo obbligare l'utilizzatore a tipizzare correttamente le richieste possiamo forzare l'utilizzo modificando l'interfaccia in:
import { Request } from 'express';
import { ParamsDictionary } from 'express-serve-static-core';
import { ParsedQs } from 'qs';
export interface TypedRequest<T = unknown, Q = unknown, P = unknown>
extends Request<P, any, T, Q> { };
export { ParsedQs, ParamsDictionary };
In questo modo ho la possibilità di usare TypedRequest senza definire i tipi, ma nel momento in cui provo ad accedere a delle proprietà degli oggetti il compilatore mi da errore, obbligandomi di fatto a definire i tipi delle parti della richiesta che mi servono per la mia logica.
Router
In generale per routing si intende il modo in cui una applicazione risponde a una richiesta effettuata su un particalre endpoint (url) con un particolare method (GET, POST, PUT, ecc).
Abbiamo visto fin'ora che possiamo configurare il routing tramite:
app.METHOD('PATH', handler);
Invece di configurare tutto nell'oggetto app Express ci da la possibilità di creare altri tipi di oggetti che definiscono regole di indirizzamento intermedie.
import { Router } from 'express';
const router = Router();
router.get('/cart-items', handler);
...
app.use(router);
// oppure anche
app.use('/api', router);
Nel secondo caso sopra la funzione handler andrà a gestire tutte le chiamate all'url
GET /api/cart-items
La forza dei router è che sono componibili a diversi livelli, ad esempio:
// cart-item.router.ts
const cartItemRouter = Router();
cartItemRouter.get('/', handler1);
cartItemRouter.post('/', handler2);
cartItemRouter.put('/:id', handler2);
// routes.ts
const mainRouter = Router();
mainRouter.use('/cart-items', cartItemRouter);
// app.ts
app.use('/api', mainRouter);
Il fatto che possiamo usare sia gli handler che i router come argomento di use, get, post, ecc ci dice che anche Router (come la maggior parte dei componenti di express) si comporta come un middleware per la nostra applicazione. Non è altro che una funzione che legge la richiesta e passa la gestione a un passaggio successivo (altro router o l'handler) a seconda che l'url corrisponda alla path definita.
MongoDB
Mongo è un database non relazione (noSQL), i database noSQL si differenziano da quelli relazionali (ad esmpio mySQL, Postgres o SQL server) perché non strutturano i dati in tabelle messe in relazione tra di loro.
I dati in un database noSQL sono tendenzialmente a schema libero, salvati in strutture chiamate documenti che possono quindi contenere qualsiasi tipo di informazione.
E' possibile comunque fare dei parallelismi con i database relazionali:
Lo possibilità di salvare dati complessi permette anche di semplificare il salvataggio di dati annidati/collegati, mantenendo però la possibilità di suddividerli in collection separate e metterli in relazione tramite gli id.
MongoDB salva i dati usando un JSON ampliato, ha ad esempio delle sintassi apposta per rappresentare un ObjectId (tipo di oggetto che usa per generare id univoci), o per le date, ecc.
Installazione
Mongoose
Mongoose è una libreria che andiamo ad utilizzare per eseguire query e operazioni sul database.
npm i mongoose @types/mongoose
Costruita sopra i driver base di mongodb per node (che volendo possono essere usati direttamente), ci permette di definire degli schema per i nostri dati in modo da definire le strutture dati in modo rigido. Questo ci permette di avere regole di validazione, trigger pre/post salvataggio, proprietà calcolate a runtime e anche collegamenti tra dati in diverse collezioni.
Definizione di uno schema e di un model
Lo schema di permette di definire una struttura di un dato, il model invece di definire che tipo di dato è contenuto in una collezione, e quindi di cercarlo, aggiungerlo, modificarlo, ecc.
La distinzione è importante perché uno schema potrebbe anche non essere associato direttamente a una collezione, ma potrebbe invece essere usato per definire la struttura di una proprietà annidata: ad esempio uno schema address potrebbe essere utilizzato sia per il campo address di un utente sia per quello di un'azienda.
Nella maggior parte dei casi però a uno schema corrisponde un model, il file tipico ha questa forma:
import mongoose, { Schema } from "mongoose";
import { CartItem as iCartItem } from "./cart-item.entity";
const bookSchema = new Schema<Book>({
title: String,
authors: [String],
description: {type: String},
imageLinks: { // oppure potrei definire uno schema separato riutilizzabile
type: {
thumbnail: String,
full: String
}
},
publishDate: Date
})
export const BookModel = mongoose.model<Book>('Book', bookSchema);
La prima parte (schema) è la definizione del dato, il file poi crea ed esporta un model associato allo schema. Stiamo dicendo a mongoose di usare una collezione books (di default il nome della collezione è la forma plurale in minuscolo del nome del model) che contiene dati conformi allo schema definito, e questo model è esportato con il nome di BookModel e chiamato internamente Book (primo argomento passato a mongoose.model).
Il nome interno è necessario perché mongoose tiene un registro interno di tutti i model creati, questo permette ad esempio di creare collegamenti tra i dati usando quei nomi interni.
Da notare che nello schema i tipi di dati sono definiti con la maiuscola (String e non string), a differenza di quello che facciamo con la tipizzazione normale. Questo perché i tipi sono una funzionalità di typescript, cesserebbero di esistere una volta compilato il codice. In javascript esiste però la classe String (come Number, Date, ecc) e quella continua ad esistere, permettendo a allo schema di capire come trattare e convertire il dato.
Lettura e scrittura di dati
Tramite il model è possibile fare operazioni tra i dati, le operazioni più comuni sono:
import { BookModel } from './book.model.ts'
// LETTURA
// get in base all'id
BookModel.findById(bookId);
// è uguale a
BookModel.findOne({_id: bookId);
// è uguale a
BookModel.find({_id: bookId)
.then(books => books[0]);
// ricerca in base a proprietà
BookModel.find({authors: authorName, title: bookTitle})
// CREAZIONE
// a partire da un'istanza
const book = new Book();
// oppure
const book = await Book.findById(id);
// per crearlo o aggiornarlo
book.save();
// in alternativa, usando il model
// creazione
Book.create(bookData);
// UPDATE
// il primo argomento stabilisce a che documenti
// applicare la modifica, il secondo i dati da aggiornare
Book.update({_id: id}, bookData)
Connessione al db
Il codice tipico per connettersi al db è:
import mongoose from 'mongoose';
mongoose.set('debug', true);
mongoose.connect('mongodb://127.0.0.1:27017/sim-book')
set('debug', true) dice a mongoose di fare il log di ogni query eseguita, è molto comodo in fase di sviluppo.
connect ha diverse forme, ma la più comune è quella dell'esempio, in cui viene passata la stringa di connessione al db. La stringa contiene tutte le informazioni necessarie al driver per connettersi al server del database, è composta da:
<protocol>://<host>:<port>/<db-name>
E' opportuno fare la connessione prima di avviare il server, perché nel caso fallisca non ha senso che le nostre api siano accessibili (non riuscirebbero comunque a fare il loro lavoro).
connect è un metodo asincrono che torna una promise, per avviare la nostra app solo una volta stabilita la connessione possiamo quindi:
import app from './app';
import mongoose from 'mongoose';
mongoose.set('debug', true);
mongoose.connect('mongodb://127.0.0.1:27017/sim-todo')
.then(_ => {
console.log('Connected to db');
app.listen(3000, () => {
console.log('Server listening on port 3000');
});
})
.catch(err => {
console.error(err);
})
Esercizio
Modificare la logica dei services perché vadano a leggere e a scrivere su database invece che lavorare con dati in memory.

