Il mondo della programmazione web è pieno di “qualcosa.js“: nomi come Node.js ed Express.js non son certo hot topics, anzi sono ormai piuttosto consolidati, ma di fatto non ce ne eravamo mai occupati. Fortunatamente sto partecipando ad un progetto con e in cui abbiamo decido si usare un full-stack javascript basato su Node.js, Express.js, MongoDB e React.js, così da provare finalmente sul campo queste tecnologie per qualcosa di vero e non semplicemente per fare un “Hello World!”. Visto che mi sono occupato personalmente del backend, ecco com’è andata con Node ed Express.
Cos’è Node.js?
Prima di tutto, un po’ di teoria, come piace a me 🙂 . Node.js non credo che abbia bisogno di presentazioni, ma per chi non lo conoscesse, il sito nodejs.org recita:
Node.js è una piattaforma realizzata su V8, il motore Javascript di Chrome, che permette di realizzare applicazioni web veloci e scalabili. Node usa un modello ad eventi e un sistema di I/O non bloccante che lo rende leggero ed efficiente, perfetto per applicazioni real-time che elaborano dati in modo intensivo e che può essere distribuito su più sistemi.
Prima considerazione: fin qua nessuno ha parlato di server, ed in effetti è così! Di solito si liquida Node dicendo che è un “server in javascript“, ma la cosa è in parte vera: Node è più in generale “javascript lato server“, ovvero è un Motore Javascript, che, tra le altre cose, è capace di tirar su un server HTTP o TCP in pochissime righe di codice! Il server quindi non è qualcosa di scorrelato dall’applicativo web che vogliamo realizzare, ma è una caratteristica dell’applicativo stesso! La prima cosa da fare infatti è avviare il server in ascolto su una certa porta: è un punto di vista un po’ diverso per chi è abituato a realizzare invece applicativi che girano dentro ad un server!
Nell’home page del sito è riportato come creare un semplice server HTTP:
var http = require('http'); http.createServer(function (req, res) { res.writeHead(200, {'Content-Type': 'text/plain'}); res.end('Hello World\n'); }).listen(1337, '127.0.0.1');
che risponde sempre “Hello World” a tutte le chiamate all’indirizzo http://127.0.0.1:1337/.
Come si fa a farlo funzionare? Intato dobbiamo scaricare e installare Node per il nostro OS, creare un file .js (per esempio server.js), copiare il codice qui sopra e lanciarlo così da riga di comando:
node server.js
Ovviamente non si è certo obbligati a tirar su un server per lavorare con Node! JavaScript diventa così un linguaggio utilizzabile anche per eseguire degli script: per esempio, uno script echo.js che fa da echo alla riga di comando può essere:
#!/usr/bin/env node console.log('Echo ' + process.argv[2]);
eseguibile come se fosse uno script bash:
./echo.js cosenonjaviste
ovviamente il file deve essere eseguibile: da notare che con l’hashbang in testa “#!/usr/bin/env node” non è necessario chiamare esplicitamente node!!
Queste considerazioni sono sicuro che siano valide per Linux e Mac, per Windows onestamente non so se c’è qualche differenza, ho dubbi sul fatto che funzioni l’hashbang…
Programmazione asincrona, programmazione ad eventi
Come vediamo già dalla creazione di un server, la funzione http.createServer
accetta una callback per gestire gli eventi in arrivo. In generale, tutto il modello di programmazione di Node è pervaso da callback: si parla quindi di programmazione asincrona, governata da eventi.
Che significa? E’ un approccio molto diverso rispetto al classico sistema di thread concorrenti: nella programmazione sincrona, una certa operazione non viene eseguita finché la precedente non è terminata. In quella asincrona invece, per ogni operazione si definisce una callback da eseguire una volta che essa è terminata: una serie di operazioni non aspettano quindi che le precedenti vengano terminate prima di essere chiamate, ma se ne avvia l’esecuzione in sequenza non bloccante! Da qua il famoso “non-blocking I/O” di Node, secondo il quale le operazioni onerose di accesso al dato (su disco, network) sono eseguite in modo asincrono così da poter proseguire l’elaborazione su un dato una volta che è disponibile (tramite callback), e nell’attesa andare avanti nell’esecuzione e fare altro, o servire le richieste di altri utenti.
Un esempio è l’API di accesso ai file: Node mette a disposizione sia una modalità sincrona che asincrona:
var fs = require('fs'); // Lettura sincrona var file = fs.readFileSync('/path/to/file', 'utf8'); console.log(file); // Lettura asincrona fs.readFile('/path/to/file', 'utf8', function(err, file) { if (err) { throw err; } console.log(file); });
La prima blocca qualsiasi altra operazione che Node può eseguire (quindi da evitare per esempio in applicativi web), mentre la seconda no. Questo perché in Node si ha un Single Event Loop, che è quello che orchestra l’avvio di una operazione e l’esecuzione della sua callback, considerati tutti eventi prelevati da una coda.
In altre parole, significa che Node, dal suo unico thread, richiede al sistema operativo sottostante una certa operazione ed esegue la callback sempre sullo stesso thread. Le operazioni vere e proprie a basso livello, quelle eseguite in C per intendersi come l’apertura di un file o della connessione di rete, sono eseguite effettivamente su thread separati e paralleli, ma non accessibili alla programmazione: una volta terminati, essi tornano in coda all’Event Loop principale in Javascript sotto forma di eventi di callback, con i dati prodotti dai thread sottostanti. A livello di Event Loop quindi tutte le operazioni vengono sempre eseguite in sequenza, mai i parallelo! Occhio quindi a quando si eseguono una raffica di operazioni network per esempio (dell’ordine delle migliaia, come mi sono trovato a dover fare per questo progetto): potrebbero andare in timeout perché la coda è troppo lunga e l’event loop non ha spazio per eseguire la callback in tempo!!
Se Node è sostanzialmente single thread, come sfrutto un multicore? L’architettura di Node è per natura stateless, per cui è fortemente scalabile in modo orizzontale: basta istanziare un nuovo server su un’altra porta (eventualmente mettere un bilanciatore) e il gioco è fatto. Drastico e semplice!
Di fatto quindi i thread in pool per le operazioni I/O ci sono, ma non sono esposti alla programmazione: il vantaggio è uno sviluppo più semplice (almeno in teoria secondo me) e un consumo inferiore della memoria per via dell’assenza di overhead di processi e thread. Un problema evidente, a mio avviso, è che la cosiddetta Callback Hell o Pyramid of Doom è dietro l’angolo!! Per eseguire 4 step in sequenza infatti si rischia di addentrarsi in questo caos:
step1(function (value1) { step2(value1, function(value2) { step3(value2, function(value3) { step4(value3, function(value4) { // Do something with value4 }); }); }); });
Come se ne esce? Per fortuna esistono le Promises!
Node.js e Promises
Con l’adozione della programmazione asincrona, Node risolve il problema di eseguire tutte le operazioni lente in modo non bloccante. Per uscire dall’inferno di callback che deriva da questo approccio, le Promises sono una vera panacea. Come documenta Q, una delle librerie più famose che implementa la specifica Promises/A+, una piramide di callback si può “appiattire” così:
Q.fcall(promisedStep1) .then(promisedStep2) .then(promisedStep3) .then(promisedStep4) .then(function (value4) { // Do something with value4 }) .catch(function (error) { // Handle any error from all above steps }) .done();
Cos’è quindi una promessa? E’ un risultato che verrà reso disponibile (then
) non appena possibile, se è possibile mantenere al promessa (fulfilled), oppure è un errore (catch
) se non si può mantenere la promessa (rejected).
Seguendo questo semplice principio, la programmazione asincrona si semplifica notevolmente e si possono mettere in coda operazioni in modo sequenziale senza troppe difficoltà, anche se in modo un po’ verboso. Per snellire un po’ forse conviene non scrivere direttamente in JavaScript ma in CoffeScript, dove la sintassi è di gran lunga più compatta.
Node.js dalla versione 0.12, come gli ultimi browser, supportano nativamente le promises JavaScript. Molto interessanti però sono anche i progetti di then e Kris Kowal su GitHub, perché aggiungono le promesse ad alcune librerie core di Node come http e fs.
npm: Node Package Manager e package.json
Fatta chiarezza su alcuni aspetti teorici e sull’approccio orientato agli eventi, come si comincia un progetto? Abituato con Maven che gestisce dipendenze, build e struttura di un progetto, mi sento un po’ spaesato. Fortunatamente esistono dei corrispettivi anche nel mondo js! Ma andiamo per gradi.
L’installazione di Node porta con sé il suo Package Manager: npm. Con npm praticamente possiamo installare qualsiasi altro tipo di dipendenza che ci serve in modo semplice: per installare Q per esempio basta:
npm install q
a seconda di dove viene lanciato il comando, tutte le dipendenze vengono installate nella cartella node_modules. E’ possibile installare le dipendenze anche a livello globale
npm install -g q
in questo caso le dipendenze vengono salvate nella cartella /usr/local/lib/node_modules/ (almeno su Mac) e in ~/.npm dell’utente corrente vengono salvati i package.json delle varie versioni.
A questo punto manca qualcosa però: dov’è il descrittore delle dipendenze, simile al pom.xml? E’ il momento di crearlo:
npm init
avvia un mini wizard che alla fine produce il package.json: un descrittore json con le informazioni base del progetto e delle sue dipendenze (come quello di Q per esempio). Gli elementi fondamentali sono:
- name
- definisce il nome del progetto, più corto di 214 caratteri.
- version
- definisce la versione. Nome e versione sono gli unici obbligatori e definiscono un identificatore univoco.
- description
- descrizione facoltativa del progetto.
- keywords
- parole chiavi del progetto: utili perché indicizzate da npm search.
- homepage
- sito web del progetto, se esiste
- license
- id del tipo licenza.
- main
- entrypoiny principale al progetto. In applicativi web solitamente è lo script di avvio, in moduli veri e propri invece è ciò che viene incluso dal comando
require
. - scripts
- è un dizionario chiave-valore che elenca gli script da lanciare (valore) associati agli eventi del ciclo di vita del progetto (chiave). Un elenco completo degli eventi è disponibile sulla documentazione di npm.
- bin
- quando si vuole installare qualche script nel PATH di sistema
- dependencies
-
dipendenze necessarie a runtime. Solitamente una dipendenza è referenziata da:
- un nome del progetto (detto package)
- uno tra:
- versione: può essere esatta (es: 1.0.0), approssimativamente equivalente (es: ~1.0.0 comprende tutte le micro versions 1.0.x), compatibile (es: ^1.0.0 comprende tutte le minor versions 1.x.x)
- indirizzo http del tarball del progetto (es: http://asdf.com/asdf.tar.gz)
- indirizzo del repository git (volendo specificando anche il commit esatto aggiungendo #, es: git://github.com/user/project.git#commit-ish)
- indirizzo GitHub abbreviato (es: commit-non-javisti/sandbox)
- devDependency
- dipendenze utili allo sviluppo, come per esempio framework di test o generatori di documentazione.
Le dipendenze non si scrivono manualmente nel file, ma si salvano con npm:
npm install q --save npm install jsdoc --save-dev
Per ulteriori dettagli, la guida su npmjs.com è molto dettagliata.
E il resto che faceva Maven?
npm fa solo da Package Manager, ma ci permette di installare un’altra serie di tool interessanti che coprono tutto quello che serve per sviluppare in Full Stack JavaScript:
-
Scaffolding con Yeoman: permette di creare in pochi passi la struttura base di un progetto JavaScript, in base ai framework che si intende usare.
npm install -g yo
-
Task Runner con Gulp o Grunt: automatizzano i processi di build. Mentre il primo è di tipo programmatico, il secondo è dichiarativo (ricorda il ciclo di build di Maven). Personalmente preferisco Gulp perché è molto chiaro nella lettura.
npm install -g gulp npm install -g grunt
-
Package Manager Alternativi: Bower è il classico package manager per le dipendenze lato client, che genera il suo descrittore di dipendenze bower.json.
npm install -g bower
In alternativa, ultimamente sta prendendo piede jspm che tende a superare la distinzione tra package manager frontend e backend, permettendo di gestire in un unico punto dipendenze provenienti da jspm, npm e GitHub. Inoltre, ha anche un trans-compilatore da ES6 a ES5 per chi si vuole dilettare ad usare ECMAStript 6 già da subito!
-
Test Runner: Karma, ambiente di runtime per i test
npm install -g karma
Sono tanti strumenti, ma una volta capito a cosa servono, ognuno sa far bene il proprio compito.
Torniamo invece adesso allo sviluppo vero e proprio con Node, e in particolare vediamo di semplificarci la vita in una web application.
La vita è più semplice con Express.js
Come abbiamo visto all’inizio del post, creare un server è estremamente facile. Non lo è però dover cominciare a fare le cose sul serio: definire una API REST richiede autodisciplina e una buona idea su come organizzare le risorse (un dispatcher per esempio?). Fare infatti un bel pappone in cui tutta l’app è in una callback è un attimo e addio modularità e riusabilità! Il bello e il brutto di Node (o del mondo JavaScript in generale) è proprio questo: possiamo fare tutto come ci pare e piace, non ci sono grossi vincoli architetturali. Se vogliamo essere “quick and dirty” possiamo fare dei veri e propri porcai, possiamo sennò fare overdesign e costruire castelli di sabbia che nessun altro collega saprebbe leggere! Come trovare la giusta via? Fortuna che c’è Express.js!!
Express è un framework web veloce, non presuntuoso e minimalista per Node.js
Mi piace tradurre “unopinionated” con “non presuntuoso” perché il framework si presneta veramente così: semplice e immediato, come ti aspetteresti che fosse Node di suo!
Si parte quindi con installare Express tra le dipendenze e il suo generatore di scaffolding a livello globale:
npm install express --save npm install express-generator -g
Se vogliamo creare quindi un nuovo progetto web, chiamato per esempio “my-web”, basta:
express my-web
e la struttura base per un progetto Node+Express è pronta! Lanciamolo e poi vediamo a grandi linee come è organizzata, per capire quali sono le best practice.
Per prima cosa, essendo nuovo, si installano le dipendenze:
npm install
dopodiché si avvia con:
npm start
All’indirizzo http://localhost:3000 ecco il risultato:
Come fa npm a sapere cosa avviare all’evento start? Vediamo la struttura base del progetto:
my-web └── package.json └── app.js └── /bin └── www └── /views └── /routes └── index.js └── /public └── /images └── /javascripts └── /stylesheets
Il package.json conterrà non solo le informazioni e le dipendenze base del progetto, ma anche lo script principale da lanciare all’evento start, che è ./bin/www. Questo avvia fisicamente il listener sulla porta 3000, ma come è stato creato e configurato il server? In cima al file www troviamo l’inclusione di app.js:
var app = require('../app');
require
è il comando principale che serve per includere altri moduli javascript, che a sua volta devono esportare ciò che vogliono rendere pubblico tramite l’istruzione module.exports
(date un’occhiata a questo file di esempio).
Qualche considerazione su require
:
- i moduli inclusi non hanno mai l’estensione (possono essere file .js o anche .json per esempio)
- il percorso al file può essere assente, relativo o assoluto: se assente, Node cerca le dipendenze locali e globali; se relativo, il percorso base è la cartella dove è stato lanciato l’applicativo; se assoluto, solitamente si usa l’oggetto globale
__dirname
, che restituisce il percorso completo alla cartella dove risiede lo script corrente.
Occhio quindi che in caso di refactoring e di spostamento di file in cartelle, certi require
possono fallire, e spesso Node non notifica nessun errore (difficile da scovare), semplicemente non va!!!
Routers
Tornando all’app.js, vediamo che adesso è molto semplice definire le rotte a cui l’app risponde, ma non solo. Le righe fondamentali sono:
// risorse esterne da includere (modulo express e due file locali che contengono le rotte) var express = require('express'); var routes = require('./routes/index'); var users = require('./routes/users'); // creo l'app express var app = express(); // dico all'app dove sono le view app.set('views', path.join(__dirname, 'views')); // e in che formato sono app.set('view engine', 'jade'); // dico all'app di servire tutto il contenuto della cartella 'public' come statico app.use(express.static(path.join(__dirname, 'public'))); // definisco i namespace base per due tipologie di rotte: routes e users sono 'router' di rotte app.use('/', routes); app.use('/users', users);
Andiamo adesso all’aspetto più interessante: le rotte! Con Express riusciamo quindi ad organizzare tutte le rotte pertinenti tra loro in file separati con poco sforzo. Prendiamo per esempio il file routes/index.js:
var express = require('express'); var router = express.Router(); /* GET home page. */ router.get('/', function(req, res) { res.render('index', { title: 'Express' }); }); module.exports = router;
Express mette a disposizione dei Routers, sulle quali posso definire le mie rotte, i miei endpoint dei servizi. La loro struttura è molto semplice:
router.METHOD(PATH, HANDLER)
dove:
- METHOD
- è il metodo http a cui rispondere.
- PATH
- definisce la rotta. Può contenere parametri ed espressioni regolari.
- HANDLER
- funzione eseguita al match della rotta. In ingresso arrivano gli oggetti che rappresentano la request e la response.
In questo caso, l’handler costruisce come risposta la view index.jade in cui imposta la variabile title
e trasforma tutto in html. Jade è un motore di template molto semplice da usare: è come scrivere un html molto più pulito (senza tag di chiusura), in stile Python però, dove l’indentatura definisce l’annidamento tra tag.
E se volevo inviare un JSON? Niente di più semplice:
router.get('/:echo', function (req, res) { res.json({message: req.params.echo}); });
Addio quindi marshaller e unmarshaller! Questo servizio definisce un wrapper JSON al messaggio che arriva come path param “:echo” inviando in risposta semplicemente un oggetto JavaScript (non a caso JSON sta per JavaScript Object Notation…).
Le rotte possono essere definite sia a livello di router
che a livello di app
principale: il primo approccio lo trovo molto pulito ed efficiente, perché permette di modularizzare gli endpoint dei servizi che si definiscono per una certa app in file separati tra loro. A seconda poi di come vengono montati, si creano le rotte finali: riprendendo il codice di app.js
var routes = require('./routes/index'); app.use('/', routes);
dal momento che le rotte vengono montate su '/', i servizi definiti in index.js risponderanno agli indirizzi http://localhost:3000/ e http://localhost:3000/messaggio%20di%20echo.
Middleware
Quando mi trovo a sviluppare servizi REST con JAX-RS, per esempio, posso far affidamento su una serie di annotations o sviluppare degli interceptors per separare logiche ortogonali a quelle applicative in classi a sé stanti, come per esempio la validazione dei dati in ingresso ai servizi piuttosto che la gestione degli errori o la regolamentazione dei diritti/permessi di accesso ad un servizio.
Con Express è possibile fare la stessa cosa attraverso i Middleware! Un middleware è definito come una funzione che ha accesso all’oggetto request, response e al middleware successivo. Possiamo immaginarli come una sorta di interceptors sulle richieste web, nei quali posso decidere se andare al middleware successivo, modificare richiesta e/o risposta o rispondere direttamente al client interrompendo il flusso! A ben guardare quindi, una applicazione Express non è altro che una serie di chiamate a middleware che possono essere:
- a livello applicativo
- a livello di rotta
- per la gestione degli errori
- nativi
- di terze parti
Riprendiamo parte del codice di app.js finora trascurato:
var logger = require('morgan'); var cookieParser = require('cookie-parser'); var bodyParser = require('body-parser'); app.use(logger('dev')); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: false })); app.use(cookieParser()); app.use(function(req, res, next) { var err = new Error('Not Found'); err.status = 404; next(err); }); app.use(function(err, req, res, next) { res.status(err.status || 500); res.render('error', { message: err.message, error: err }); });
Nella prima parte si includono e si associano all’app i middleware standard per la gestione dei log, del parsing della request e dei cookies. Gli ultimi due invece sono una sorta di interceptors a livello applicativo per la gestione degli errori, in particolare:
app.use(function(req, res, next) { var err = new Error('Not Found'); err.status = 404; next(err); });
Quando non si specifica nessuna rotta (riga 1, ma si potrebbe specificare un verbo HTTP al posto della funzione .use
), l’handler è a livello applicativo e viene chiamato sempre: in questo caso, il terzo parametro next
è una funzione che mi permette di andare all’handler successivo, a cui viene passato l’errore. Quando next
passa un parametro (ad eccezione della stringa 'route'), è considerato il passaggio ad un handler di errore, dichiarato con 4 parametri:
app.use(function(err, req, res, next) { res.status(err.status || 500); res.render('error', { message: err.message, error: err }); });
Quando quindi un handler è di 4 parametri, viene considerato come handler di errore, altrimenti, con 2 o 3 di livello applicativo o di rotta.
Occhio all’ordine: questo handler deve essere registrato dopo le rotte, altrimenti vincerebbe sempre lui e nessuna rotta verrebbe chiamata!! L’ordine delle chiamate dei middleware applicativi dipende dall’ordine in cui sono dichiarati, quindi c’è da fare attenzione.
Tutti i dettagli su come gestire i middleware sono chiari anche solo leggendo il codice della documentazione di Express.
Ambiente di sviluppo
Per lavorare in JavaScript non servono grandi IDE, di fatto basterebbe anche il notepad, anche se nel 2015 mi sembrerebbe assurdo consigliare una cosa del genere. Gli ambienti più usati sono nei notepad più smart, come Sublime Text, che ha molte caratteristiche utili o Brackets, che ha interessanti funzionalità di editing in-line. Personalmente però preferisco IntelliJ (Community) o WebStorm (che costa poco per quel che fa!), che per lavorare in Node sono perfetti, permettendo addirittura di fare il debug del codice o il refactoring quasi come se stessi lavorando in Java!
Conclusioni
Che dire ancora? Posso aggiungere che lavorare con Node, ma soprattutto con Express è stata una sorpresa! Il fatto di poter usare Node non solo per applicazioni web, ma anche per creare degli script la trovo una cosa interessante. Lavorare con Express poi semplifica notevolmente la realizzazione di applicazioni web, che con Router e Middleware permette di strutturare bene le risorse. Lo sviluppo è molto immediato: npm install, trovi del codice di esempio e boom, funziona!
Cosa non mi piace? Il fatto che per grandi progetti richiede molta disciplina secondo me, perché il rischio è di lavorare in modalità “quick and dirty”, il problema è che il “dirty” rimane molto tempo dopo che tutti si sono scordati del “quick” ().
Come ultima considerazione da non trascurare, non avendo un compilatore, i test diventano fondamentali perché non c’è altro modo di sapere se la build si è rotta per qualche motivo! Questo argomento però è tutta un’altra storia…
Pingback: CodingJamA Ferrara per il primo Universal JS Day - CodingJam()