Stack MEAN con Docker: sviluppare una webapp da zero in meno di un'ora
Containerizzare un singolo servizio è relativamente facile… ma quando è necessario containerizzare più servizi in container separati, si può avere qualche difficoltà. Uno dei casi classici è avere a che fare con un’applicazione basata su uno stack MEAN.
Esatto, un’applicazione full-stack.
MEAN infatti sta per:
- MongoDB: database noSQL;
- Express.js: framework per applicazioni web basato su Node.js minimale e flessibile, utilissima per creare REST API;
- Angular: piattaforma per la creazione di applicazioni web mobili e desktop;
- NodeJs: ambiente server open source per eseguire JavaScript.
Tramite questa combinazione di tecnologie è possibile infatti definire un ciclo di vita completo di un’applicazione web: tramite MongoDB diamo alla nostra applicazione la persistenza di cui ha bisogno; con Node.js e Express.js andiamo a creare dei servizi che ci mettono a disposizione un back-end per gestire la comunicazione con il database; infine, con Angular, è possibile creare un front-end che renda fruibile quello che c’è sotto al coperchio.
Non c’è bisogno di spaventarsi di fronte alla moltitudine di queste tecnologie o alla difficoltà di avere più servizi che dovranno comunicare tra loro: Docker Compose può essere utilizzato per creare container separati per ciascuno degli oggetti che saranno in gioco nell’applicazione, permettendogli di “parlare” tranquillamente.
Chiaramente questo approccio non è l’unico: la combo di queste tecnologie è di per sé conveniente per chi è particolarmente familiare con tecnologie affini a Typescript, ma in realtà si tratta di uno stack piuttosto diffuso come schema implementativo; questo vuol dire che è sufficiente sostituire ogni mattoncino (o servizio) con la tecnologia più congeniale allo sviluppatore per avere lo stesso risultato.
Una cosa che infatti non si insegna mai (o si insegna troppo tardi) è che il linguaggio di programmazione con cui si decide di sviluppare una soluzione non dev’essere posto in cima alla lista delle cose da fare: questo deve essere scelto secondo diversi criteri, che dipendono anche dalla familiarità che si ha con uno di questi.
In questo caso, si andrà ad utilizzare un approccio bottom-up: partendo infatti dalla definizione degli oggetti che andremo a modellare, andremo via via verso il generale, ossia verso la visualizzazione del nostro progetto.
Questo è l’approccio che preferisco: concentrarsi sui dati che devono essere rappresentati permette di ridurre la complessità nella gestione, ed avere più tempo per i dettagli dell’interfaccia. Non a caso una buona base dati è alla base di molte applicazioni!
Una premessa è doverosa: siccome lo scopo di questo tutorial è quello di vedere come impacchettare i componenti di uno stack MEAN e renderli dei servizi pronti per essere utilizzati con Docker Compose: nel descrivere la creazione di una REST API con Express.js oppure il front-end con Angular potrebbero sfuggire alcuni dettagli implementativi.
Detto ciò, una breve considerazione, perché repetita iuvant: questo non è l’unico modo per portare a termine questo progetto e ci sono e saranno sicuramente modi anche più intelligenti o produttivi… vi sfido a provarli tutti!
Repository
https://github.com/serenasensini/MEAN-boilerplate
Pre-requisiti
- Installazione Node.js;
- Installazione Angular;
- Installazione Express.js;
- Installazione MongoDB;
- Installazione Docker e Docker Compose.
Step-by-step
0: Definizione del progetto
Creiamo una cartella “MEAN-app” (o con un qualunque altro nome), dove andremo ad impostare tutto il nostro lavoro.
Per lavorare, io utilizzo WebStorm, il che rende più semplice la creazione di progetti (con pochi click, viene generato un template pronto all’uso), ma riporto di seguito i comandi che vengono utilizzati per la creazione dei file che ci serviranno per le diverse applicazioni: come prima cosa, andremo a creare un’applicazione per il back-end, quindi andremo ad eseguire i seguenti comandi:
$ npm i express-generator
$ express backend
Verrà creata una cartella con il seguente contenuto:
create : backend/
create : backend/public/
create : backend/public/javascripts/
create : backend/public//images/blog/
create : backend/public/stylesheets/
create : backend/public/stylesheets/style.css
create : backend/routes/
create : backend/routes/index.js
create : backend/routes/users.js
create : backend/views/
create : backend/views/error.jade
create : backend/views/index.jade
create : backend/views/layout.jade
create : backend/app.js
create : backend/package.json
create : backend/bin/
create : backend/bin/www
All’interno di questa cartella, andremo a definire una serie di cartelle che permettono di organizzare il lavoro: controllers, model, data e routes, dove andremo a mettere rispettivamente la logica, i modelli, i dati di test (opzionale) e le routes.
Per il momento, è tutto. Passiamo al front-end: in questo caso, eseguiamo i seguenti comandi:
$ ng new frontend --style=scss --routing
Anche in questo caso, non appena il processo è terminato, andiamo a creare una serie di cartelle per organizzare il lavoro; questi passaggi non sono obbligatori, ma dipendono dal programmatore.
Ognuno di essi è come uno scrittore: ha il suo stile, il suo modo di lavorare e di gestire lo scaffolding del progetto! Certo, ci sono delle best practices, ma ne parleremo un’altra volta…
In questo caso, sotto la cartella src/app/, andiamo a creare una cartella components, all’interno della quale inseriremo i componenti principali, una layout, che definirà gli oggetti che compongono la struttura della nostra webapp, e infine services, dove inseriremo i servizi che parleranno con il back-end.
Fatto! Il lavoro è stato impostato. Next step:
1: definizione del JSON
Qui possiamo lavorare di fantasia. Supponiamo ad esempio di voler creare una webapp per la gestione delle richieste che riceviamo, e salviamo proprietà come il nome e la descrizione: il nostro JSON avrà un aspetto come quello riportato di seguito.
[ { "name": "John", "description": "Lorem ipsum" } ]
Questo è solo un esempio, ma il tutto può essere modificato a piacimento.
Next!
2: sviluppare e testare il back-end con Express.js
Ora riprendiamo la cartella backend e iniziamo a popolare la cartella model: creiamo un file che chiamo requestModel.js, all’interno del quale definisco lo schema che dovrà assumere il mio oggetto: imposto il nome come campo di tipo stringa e obbligatorio, mentre la descrizione sarà solo una stringa opzionale:
var mongoose = require('mongoose');
var requestSchema = mongoose.Schema({ name: { type: String, required: true }, description: String });
var Request = module.exports = mongoose.model('request', requestSchema);
module.exports.get = function (callback, limit) { Request.find(callback).limit(limit); }
Andremo ad utilizzare Mongoose come libreria per la creazione della struttura del nostro oggetto: ne esistono molte altre, quindi vi sfido a provarle!
Nelle ultime tre righe, vado a definire il modello che verrà esportato e che permetterà di eseguire le operazioni CRUD sugli oggetti di questa tipologia.
Passiamo ora alla gestione del controller: nell’omonima cartella, creo un file requestController.js, dove importo il modello che ho appena creato e definisco i metodi CRUD per l’oggetto Request: come riportato di seguito, vengono definiti diversi metodi, tra cui index, new, view, update, delete.
Ognuno di questi compie un’operazione diversa, ossia il recupero di tutti gli oggetti di questa tipologia, la creazione, il recupero di un oggetto a partire dall’id, l’aggiornamento e la cancellazione:
Request = require('../model/requestModel');
exports.index = function (req, res) {
Request.get(function (err, requests) {
if (err) {
res.json({
status: "error",
message: err,
});
}
res.json({
status: "success",
message: "Requests retrieved successfully",
data: requests
});
});
};
exports.new = async function (req, res) {
var request = new Request();
request.name = req.body.name ? req.body.name : request.name;
request.description = req.body.description;
request.save(function (err) {
if (err)
res.json(err);
else
res.json({
message: 'New request created!',
data: request
});
});
};
exports.view = function (req, res) {
Request.findById(req.params.request_id, function (err, contact) {
if (err)
res.send(err);
res.json({
message: 'Request details loading..',
data: contact
});
});
};
exports.update = function (req, res) {
Request.findById(req.params.request_id, function (err, request) {
if (err)
res.send(err);
request.name = req.body.name ? req.body.name : request.name;
request.save(function (err) {
if (err)
res.json(err);
res.json({
message: 'Request Info updated',
data: request
});
});
});
};
exports.delete = function (req, res) {
Request.remove({
_id: req.params.request_id
}, function (err, request) {
if (err)
res.send(err);
res.json({
status: "success",
message: 'Request deleted'
});
});
};
Prendendo di esempio il metodo che inserisce una nuova richiesta, vediamo cosa succede: nella prima riga, definisco il tipo di operazione che vado a compiere.
Chiaramente si tratta di un’operazione asincrona, in quanto ha bisogno del suo tempo per portare a termine il suo compito!
Nelle righe successive, creiamo un oggetto di tipo Request e, sfruttando il parametro req (lo so, sembra una ridondanza, ma i due oggetti sono molto diversi) per recuperare le informazioni che vengono passate come parametri all’interno del body della request: in questo caso, name e description.
Infine, avviene il salvataggio dell’oggetto nel database: se la richiesta va a buon fine, viene restituito un messaggio che riporta il buon esito dell’operazione e anche l’oggetto appena creato:
exports.new = async function (req, res) {
var request = new Request();
request.name = req.body.name ? req.body.name : request.name;
request.description = req.body.description;
request.save(function (err) {
if (err)
res.json(err);
else
res.json({
message: 'New request created!',
data: request
});
});
};
Repetita iuvant: questo non è l’unico modo di farlo, ma si tratta di un esempio volutamente semplice, perché l’idea è quella di fornire delle linee guida che mettano in condizione chi utilizza questo caso d’uso di avere una base pronta su cui lavorare!
Prossimo passo: andiamo a modificare il file che gestisce le routes e aggiungiamo una route che punti al controller che abbiamo appena definito, come vediamo di seguito:
In questo caso, siccome le operazioni di recupero delle richieste e la creazione non hanno bisogno di alcun parametro, vengono riportate in un’unica route che ha come contesto /requests; nel caso delle altre operazioni, è fondamentale avere l’ID dell’oggetto, quindi il contesto avrà come parametro request_id:
var requestController = require('../controllers/requestController');
router.route('/requests')
.get(requestController.index)
.post(requestController.new);
router.route('/requests/:request_id')
.get(requestController.view)
.put(requestController.update)
.delete(requestController.delete);
Ci siamo quasi: andiamo nel file principale del progetto, ossia app.js, e inseriamo le informazioni per la connessione al database e per l’uso delle routes:
mongoose.connect('mongodb://localhost:27017/mydb', { useNewUrlParser: true});
var db = mongoose.connection;
if(!db)
console.log("Error connecting db")
else
console.log("Db connected successfully")
app.get('/', (req, res) => res.send('Hello World!'));
// STEP 5: definire contesto (opzionale)
app.use('/api', apiRoutes);
app.listen(port, function () {
console.log("Running webapp on port " + port);
});
In questo caso, usiamo la porta 8081 per esporre il nostro back-end, ma può essere usata una porta qualsiasi:
var port = process.env.PORT || 8081;
Ci siamo! Che fatica, vero?
Per testarla, eseguiamo un semplice comando da terminale:
$ node index.js
Se tutto va a buon fine, possiamo aprire un qualsiasi client che ci permetta di eseguire delle requests (ad esempio, Postman) e chiamare l’indirizzo localhost:8081/api/requests/:
Per testare che il funzionamento sia corretto, proviamo a creare una nuova request effettuando una POST sempre tramite Postman e passando come body il seguente JSON:
Alla grande!
L’ultimo step è quello di preparare il Dockerfile che permetterà al nostro back-end di funzionare: in questo caso, utilizzeremo come immagine base l’ultima disponibile di Node; andremo poi a creare una cartella sotto /usr/src/app per copiare il nostro lavoro -partendo dal package.json- e installare tutte le dipendenze.
Dopo questo passaggio, sarà sufficiente copiare il codice sorgente e inserire come istruzione di avvio del nostro container npm start:
FROM node:16.0.0
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
COPY package.json /usr/src/app/
RUN npm install
COPY . /usr/src/app
CMD [ "npm", "start" ]
Finito!
Passiamo al front-end.
3: sviluppare e testare il front-end con Angular
Partiamo dalla creazione del servizio: questo ci metterà a disposizione un punto di comunicazione con il back-end per recuperare le richieste e inserirne di nuove; vediamo intanto come recuperare l’elenco completo.
Dopo esserci posizionati tramite terminale all’interno della cartella /src/app/services, eseguiamo il seguente comando per generare un servizio:
$ ng generate service requests
Verranno creati due file: noi andremo ad utilizzare il file requests.service.ts, all’interno del quale andremo a creare un’interfaccia che descrive il modello del nostro oggetto e poi, dopo aver importato all’interno della funzione constructor l’oggetto HttpClient, creiamo la funzione getRequests che ci permetterà di sfruttare il modulo importato per recuperare tutte le richieste:
import { Injectable } from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {environment} from '../../environments/environment';
export interface RequestData {
id: string;
name: string;
description: string;
}
@Injectable({
providedIn: 'root'
})
export class RequestsService {
constructor(private http: HttpClient) {
}
// tslint:disable-next-line:typedef
getRequests() {
return this.http.get<RequestData[]>(environment.baseURL + '/requests');
}
}
A questo punto, possiamo passare al componente: tramite terminale, ci spostiamo nella cartella /src/app/components ed eseguiamo il comando seguente:
$ ng generate component requests
Andiamo a modificare il file requests.component.ts e inseriamo la logica che ci permette di recuperare e visualizzare l’elenco degli oggetti. Anche qui è possibile adottare la strategia che più ci piace: in questo caso, andiamo ad utilizzare AngularMaterial come framework e quindi, all’interno del metodo ngOnInit, andiamo a richiamare il servizio precedentemente creato e andiamo ad assegnare al datasource del componente MatTableDataSource l’elenco delle richieste:
import { Injectable } from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {environment} from '../../environments/environment';
export interface RequestData {
id: string;
name: string;
description: string;
}
@Injectable({
providedIn: 'root'
})
export class RequestsService {
constructor(private http: HttpClient) {
}
// tslint:disable-next-line:typedef
getRequests() {
return this.http.get<RequestData[]>(environment.baseURL + '/requests');
}
}
Nel file requests.component.html definiamo la tabella che andrà a riepilogare gli oggetti: come input prende il parametro dataSource e come colonne avremo name e description, ossia le due proprietà delle nostre richieste:
Ci siamo! Per testare la nostra app, assicuriamoci di avviare il nostro back-end tramite terminale come visto in precedenza, mentre eseguiamo il comando ng serve per visualizzare l’applicazione Angular:
Ottimo. Anche in questo caso, è necessario definire un Dockerfile: può avere dell’incredibile, ma è pressoché identico a quello precedente. In questo caso, andiamo a copiare il contenuto della nostra webapp all’interno di /app, ma il tutto è assolutamente opzionale:
FROM node:16.0.0
WORKDIR '/app'
COPY package.json .
RUN npm install
COPY . .
EXPOSE 4200
CMD ["npm", "start"]
Siamo finalmente pronti per impacchettare il nostro lavoro e utilizzare Docker Compose!
4: da single-container a servizi con Docker Compose
Se sei arrivato fin qui, intanto complimenti: il lavoro fatto non è da poco!
La nostra cartella principale dovrebbe avere questo aspetto:
Eccetto la cartella docker-entrypoint-initdb.d (spoiler prossimo articolo!), dobbiamo passare alla creazione del file docker-compose.yml: lo creiamo nella cartella principale, così da poter poi fare riferimento alle due componenti che abbiamo lavorato finora in maniera efficiente.
All’interno di questo file, andiamo a definire 3 servizi: uno per il front-end, uno per il back-end, e naturalmente uno per Mongo: finora abbiamo utilizzato un’istanza locale, ma il nostro obiettivo è quello di rendere tutto il nostro lavoro pronto all’uso!
La strategia è quella di definire i diversi servizi, partendo dal front-end: specifichiamo il nome del container che verrà assegnato, le porte (di default Angular espone sulla 4200) e soprattutto indichiamo che il front-end dipende dal back-end: fintanto che questo non è stato avviato e non è pronto per lavorare, non avviamo il nostro front-end:
frontend:
container_name: frontend
restart: always
build: frontend
ports:
- "4200:4200"
depends_on:
backend:
condition: service_healthy
Prossimo: il back-end.
L’idea è la stessa, ma in questo caso definiamo anche il cosiddetto healthcheck: questo ci permette di essere certi che il back-end sia operativo.
Naturalmente, il back-end dipende da MongoDB: dobbiamo quindi aggiungere una verifica, di modo che siamo sicuri che l’istanza del DB sia stato avviato correttamente (se non sai cos’è l’HEALTHCHECK, guarda qui!).
backend:
container_name: backend
restart: always
build: backend
ports:
- "8081:8081"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8081"]
interval: 20s
timeout: 200s
retries: 5
depends_on:
mongo:
condition: service_healthy
Vediamo l’ultimo servizio, ossia quello per Mongo: in questo caso, usiamo l’immagine di Mongo e definiamo, oltre al binding delle porte, la definizione dell’environment (ossia il nome del database) ed anche l’healthcheck: anche in questo caso, dobbiamo verificare che l’istanza sia in esecuzoine e pronta per lavorare.
Per farlo, utilizziamo un semplice comando che pinga il servizio di Mongo e diamo al test 5 tentativi (o retries) prima di dichiarare che non riesce a verificarne il funzionamento, con un ritardo di 40 secondi (così che il container abbia effettivamente il tempo di avviarsi) e un timeout di 10s (ossia il tempo che il test attende prima di concludere con un esito positivo o negativo).
Ci siamo: il risultato finale dovrebbe essere il seguente:
version: "3.5"
services:
frontend:
container_name: frontend
restart: always
build: frontend
ports:
- "4200:4200"
depends_on:
backend:
condition: service_healthy
backend:
container_name: backend
restart: always
build: backend
ports:
- "8081:8081"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8081"]
interval: 20s
timeout: 200s
retries: 5
depends_on:
mongo:
condition: service_healthy
mongo:
container_name: mongo
image: mongo
command: mongod --port 27017
volumes:
- ./docker-entrypoint-initdb.d/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
ports:
- "27017:27017"
environment:
MONGO_INITDB_DATABASE: mydb
healthcheck:
test: echo 'db.runCommand("ping").ok' | mongo mongo:27017/mydb --quiet
interval: 10s
timeout: 10s
retries: 5
start_period: 40s
Manca solo un ultimo dettaglio: all’interno del backend, nel file index.js, abbiamo definito la stringa di connessione al database, che finora puntava a localhost; ora dobbiamo modificare il puntamento di modo che punti al nome del servizio, e quindi dovrà essere come la seguente:
mongoose.connect('mongodb://mongo:27019/mydb', { useNewUrlParser: true});
Lo so, è stato faticoso, ma ci siamo: possiamo eseguire il comando docker-compose build e docker-compose up e finalmente vedere che il nostro stack è funzionante!
That’s all, folks!