Parte II — Il back-end JavaScript · Capitolo 4

Node.js e lo stack MEAN

~45 min di lettura5 widget interattivi4 tavole

In questo capitolo

  1. Dai problemi di LAMP ai requisiti del Web moderno
  2. Che cos'è Node.js (e che cosa non è)
  3. Il motore V8 e la compilazione just-in-time
  4. Thread-based contro event-driven
  5. Quando usare Node.js, e quando no
  6. Primi passi: installazione, REPL, script, stile
  7. I moduli built-in
  8. Moduli custom in CommonJS
  9. I moduli ECMAScript
  10. npm, npx e i gestori di pacchetti alternativi
  11. Non solo Node.js: Deno e Bun
  12. I template engine e EJS
  13. Il deploy: NGINX davanti a Node.js
  14. Express: un'anteprima
  15. Gli esercizi di laboratorio
  16. Verifica le tue conoscenze

1. Dai problemi di LAMP ai requisiti del Web moderno

Il capitolo 1 ha già messo LAMP e MEAN uno accanto all'altro, elemento per elemento, e ha già discusso i signature stack delle grandi aziende. Qui non ripetiamo quel confronto: ci serve soltanto ricordarne la conclusione, perché è la premessa di tutto ciò che segue. LAMP e WAMP fanno riferimento a uno specifico sistema operativo, usano un database relazionale e definiscono soltanto lo stack lato server; MEAN nasce multipiattaforma, adotta un database documentale e fornisce due supporti di programmazione — uno client e uno server — basati sullo stesso linguaggio. Da questa differenza discende il cambiamento di modello architetturale: dai database relazionali a quelli documentali, e dal pattern MVC lato server alla single web application lato client.

Le slide elencano cinque problemi che hanno spinto la comunità a cercare qualcosa di diverso da LAMP. Apache non è il web server più veloce. È difficile scrivere buon codice PHP che sia insieme riusabile e veloce da eseguire. Si usano linguaggi diversi per front-end e back-end, con tutto il costo cognitivo e organizzativo che questo comporta. Ci sono molte conversioni dei dati lungo la catena, per esempio da XML o JSON alle strutture PHP e da PHP a HTML. E infine non c'è una netta separazione, nel processo di sviluppo, tra la parte client e quella server.

A questi problemi si aggiungono i requisiti di un Web moderno, che sono cinque e vale la pena leggerli come una specifica tecnica, non come uno slogan pubblicitario. Il primo è velocità e scalabilità: siti Web veloci, per risposte veloci. Il secondo è che non ci devono essere pagine da ricaricare: l'utente si aspetta che l'interfaccia si aggiorni sul posto, non che l'intero documento venga ricostruito e rispedito. Il terzo è la capacità di reggere tante richieste concorrenti quante sono le richieste, senza degradare quando il numero cresce. Il quarto è il caricamento condizionato, cioè il caricamento delle risorse solo quando sono effettivamente richieste. Il quinto è una UI mobile e responsive.

RequisitoCosa implica sul lato server
Velocità e scalabilitàIl costo per connessione deve restare basso: non ci si può permettere una struttura pesante per ogni client collegato
Nessuna pagina da ricaricareIl server smette di spedire documenti completi e comincia a spedire dati; la composizione della vista si sposta altrove
Tante richieste concorrentiLa concorrenza va gestita senza far crescere linearmente le risorse di sistema impegnate
Caricamento condizionatoLe risorse vengono richieste a pezzi, quindi il numero di richieste cresce e ciascuna deve costare poco
Mobile/responsive UILo stesso backend serve device molto diversi, quindi conviene che produca dati e non presentazione

La risposta proposta dalle slide è lo stack MEAN, definito come un full-stack JavaScript framework ottimizzato che permette di velocizzare e rendere più semplice — più user-friendly — la creazione di applicazioni Web robuste e facilmente mantenibili. L'acronimo è composto da MongoDB, Express.js, Angular e Node.js. Questo capitolo si occupa della N, cioè della base su cui tutto il resto poggia; la E di Express viene solo introdotta qui e approfondita nel capitolo 5; la M di MongoDB, database non relazionale, document-oriented, cross-platform, schema-less, scalabile e con supporto alle transazioni, appartiene alla parte sui database documentali; la A di Angular — framework JavaScript open source mantenuto da Google, basato su TypeScript, RxJS e una potente CLI, pensato per applicazioni single-page complesse — appartiene alla parte sui framework client-side, dove si vedrà anche perché il corso adotta la variante MEVN con VueJS.

Idea chiave

I cinque requisiti del Web moderno non chiedono un linguaggio nuovo: chiedono un modello di concorrenza nuovo. "Tante richieste concorrenti quante richieste" e "costo basso per connessione" sono in tensione con l'idea di dedicare un thread del sistema operativo a ogni client. È da questa tensione che nasce Node.js, e infatti la parte centrale del capitolo è tutta dedicata al confronto tra il modello thread-based e quello event-driven.

2. Che cos'è Node.js (e che cosa non è)

La definizione che le slide ripetono, sia nella lezione teorica sia nell'esercitazione, è volutamente prudente: Node.js è una piattaforma software cross-platform, e non è né un web server né un linguaggio. È una precisazione che vale la pena prendere sul serio, perché quasi tutti gli equivoci sul suo conto nascono da lì. Non è un linguaggio, perché il linguaggio è JavaScript e non cambia; non è un web server, perché non c'è nulla, in Node.js, che ascolti su una porta finché non siete voi a scrivere il codice che lo fa.

Il modo più preciso di dirlo è che Node.js è un JavaScript runtime environment: un ambiente che permette di eseguire codice JavaScript fuori dal browser. Non funziona come, per esempio, Apache HTTP Server: non ha un file di configurazione che descrive host virtuali, directory e moduli. Voi non configurate Node.js, voi ci scrivete dentro un programma. Quel programma può essere un web server, e in effetti Node.js permette di creare il proprio Web server: si scrive un server HTTP usando le librerie fornite dalla piattaforma — il modulo built-in http che incontreremo tra poco e che il capitolo 3 ha già inquadrato dal punto di vista del protocollo. Sopra quel server si costruiscono poi le applicazioni Web.

Attenzione

"Node.js non è un web server" è una delle formulazioni più facili da chiedere all'orale e più facili da sbagliare. La differenza operativa è netta: con Apache si installa e si configura un server già scritto da altri, e i vostri file finiscono in una directory che quel server espone; con Node.js si scrive il server, riga per riga, e il risultato è un normale programma JavaScript che, tra le altre cose, si mette in ascolto su una porta. Il codice sorgente della piattaforma è pubblico su github.com/nodejs/node.

Le caratteristiche tecniche elencate dalle slide sono cinque e formano la spina dorsale del capitolo. Node.js è realizzato sul motore JavaScript V8 di Google Chrome, che è ciò che gli permette di eseguire JavaScript server-side. È single-threaded. Ha una architettura event-driven. L'esecuzione è asincrona. Il modello di I/O è non bloccante. Le sezioni 3 e 4 si occupano esattamente di sciogliere queste cinque parole.

C'è poi una conseguenza architetturale che le slide sottolineano in modo esplicito: Node.js permette di avere applicazioni Web con connessioni real-time e two-way, in cui non solo il client, ma anche il server può iniziare una connessione, permettendo di trasferire dati liberamente. Questo è dichiarato come "completamente in contrasto con le applicazioni Web tradizionali", dove il flusso è sempre e solo domanda-risposta iniziata dal browser. È la stessa proprietà che il corso chiede di sfruttare nell'elaborato d'esame, dove almeno una funzionalità deve basarsi su uno scambio di dati iniziato dal server.

I benefici dichiarati

L'elenco dei benefici, così come compare nelle slide, è breve e concreto: permette di scrivere codice in JavaScript; è estremamente veloce e leggero; fa un uso efficiente delle risorse; non c'è bisogno di eseguire un web server separato; permette di avere un forte controllo della logica dell'applicazione e dell'ambiente; permette di gestire diversi utenti con poche risorse. Notate come quattro voci su sei siano riformulazioni della stessa proprietà — il basso costo per connessione — vista da angolature diverse.

Il sito ufficiale riassume la piattaforma in tre affermazioni che le slide riportano testualmente e a cui tornano più volte, usandole come indice:

«Node.js è un runtime Javascript costruito sul motore JavaScript V8 di Chrome»
«Node.js usa un modello I/O non bloccante e ad eventi, che lo rende un framework leggero ed efficiente»
«L'ecosistema dei pacchetti di Node.js, npm, è il più grande ecosistema di librerie open source al mondo»

Le tre frasi corrispondono ai tre elementi principali in cui le slide scompongono Node.js: il motore JavaScript V8, il modello di Node.js (single-threaded, event-driven, non bloccante) e NPM. Il resto di questo capitolo segue esattamente quest'ordine.

3. Il motore V8 e la compilazione just-in-time

V8 è un motore JavaScript open source ad alte prestazioni, sviluppato da Google — ed è per questo che lo si ritrova sia in Chrome sia in Node.js. È scritto in C++, è multipiattaforma e supporta ECMAScript, cioè lo standard di cui JavaScript è l'implementazione. Il punto tecnicamente più rilevante è però un altro: V8 effettua la compilazione "just-in-time" di JavaScript in linguaggio macchina, senza codice intermedio. Converte cioè il codice JavaScript direttamente in linguaggio macchina di basso livello, il codice che i microprocessori sono in grado di capire ed eseguire.

Caratteristica di V8Conseguenza pratica
Open source e ad alte prestazioniLo stesso motore ottimizzato che gira nel browser gira anche sul server
Scritto in C++Node.js può esporre alle applicazioni JavaScript funzionalità implementate in codice nativo
MultipiattaformaÈ una delle ragioni per cui Node.js, e quindi MEAN, non fa riferimento a un sistema operativo specifico
Supporta ECMAScriptIl linguaggio che scrivete sul server è lo stesso, e alla stessa versione dello standard, di quello che scrivete nel browser
Compilazione JIT senza codice intermedioIl codice viene tradotto in istruzioni macchina, non interpretato riga per riga a ogni esecuzione
codice JavaScript ECMAScript V8 open source, C++ multipiattaforma compilazione JIT linguaggio macchina eseguito dal processore nessun codice intermedio: JS tradotto direttamente in codice macchina Google Chrome Node.js lo stesso motore, sviluppato da Google, dentro:
Tavola 4.1 — La pipeline di V8: compilazione just-in-time da JavaScript a linguaggio macchina, senza passaggi intermedi; lo stesso motore è il cuore sia di Chrome sia di Node.js.
Nota del redattore

Vale la pena capire perché la formula "just-in-time, senza codice intermedio" è significativa. Un interprete puro legge e riesegue la traduzione del sorgente a ogni passaggio, e paga quel costo ogni volta; un compilatore ahead-of-time traduce tutto prima dell'esecuzione, ma deve rinunciare a ogni informazione che si conosce solo mentre il programma gira. La compilazione JIT sta in mezzo: traduce il codice mentre il programma è in esecuzione e può quindi ottimizzarlo in base a come viene effettivamente usato — quali tipi passano davvero per una certa funzione, quali rami vengono percorsi più spesso. È questo che permette a un linguaggio dinamico come JavaScript di avvicinarsi, in molti carichi di lavoro, alle prestazioni di linguaggi compilati.

Tenete presente questa proprietà quando, più avanti, incontrerete l'indicazione di non usare Node.js per applicazioni CPU-intensive. Il limite non è la lentezza del motore — V8 è veloce — ma il fatto che un calcolo lungo occupa l'unico thread che sta servendo tutti i client. È la sezione successiva a spiegare perché.

4. Thread-based contro event-driven

Questa è la sezione centrale del capitolo, e anche quella che le slide sviluppano con più cura: il confronto fra il modello tradizionale, thread-based e il modello a thread singolo, event-driven. È un confronto che riguarda un'unica domanda: cosa succede dentro il server quando arrivano cento richieste insieme.

Il modello thread-based

Nel modello tradizionale un dispatcher thread assegna ogni richiesta entrante a un worker thread che si occupa dell'elaborazione. Ne discende una proprietà semplicissima e pesante di conseguenze: in un dato momento il numero di thread è uguale al numero di richieste. Le operazioni di I/O, poi, sono bloccanti: quando il worker chiede un file al disco o una riga al database, resta fermo ad aspettare la risposta, e nel frattempo non fa nient'altro.

Gli svantaggi si vedono al crescere del carico. Man mano che le richieste aumentano cresce il numero di thread attivi, e si incontrano due limitazioni: l'overhead del sistema operativo dovuto al context switching, cioè al continuo salvataggio e ripristino dello stato dei thread che si alternano sulla CPU, e il fatto che i sistemi operativi hanno un limite massimo di thread. Le slide traggono la conclusione senza mezzi termini: questo limita notevolmente la scalabilità del server.

Il modello event-driven di Node.js

Node.js ribalta l'impostazione. Le richieste vengono tradotte in eventi, ognuno dei quali è associato a un event handler, e vengono inserite in una coda. Esiste un event loop single-threaded nel quale, a ogni iterazione, si controlla se ci sono nuovi eventi nella coda e si esegue il relativo handler. Per le operazioni di I/O si utilizzano thread aggiuntivi, che le eseguono in maniera asincrona.

Quest'ultimo punto è quello che viene frainteso più spesso. "Single-threaded" non significa che in un processo Node.js esista un solo thread in assoluto: significa che il vostro codice JavaScript gira tutto su un thread solo, quello dell'event loop. I thread aggiuntivi esistono, ma servono all'I/O e non eseguono la vostra logica applicativa: quando finiscono il loro lavoro non fanno altro che rimettere un evento nella coda, perché sia l'event loop, a tempo debito, a eseguire la callback corrispondente.

Modello thread-based Modello event-driven richieste concorrenti dispatcher thread worker 1 worker 2 worker N operazioni di I/O bloccanti il thread resta fermo ad attendere numero di thread = numero di richieste overhead di context switching del SO limite massimo di thread imposto dal SO scalabilita del server limitata richieste concorrenti coda di eventi (evento + handler) event loop single-threaded thread pool I/O asincrono risposta al client callback rimessa in coda un solo thread serve tutte le richieste, senza attendere i thread aggiuntivi servono solo per le operazioni di I/O meno memoria, molte piu connessioni simultanee
Tavola 4.2 — I due modelli a confronto: a sinistra ogni richiesta si porta dietro un thread che prima o poi si blocca, a destra il thread è uno solo e non si blocca mai, perché delega l'I/O e riprende dalla coda.

Il ciclo di vita di una richiesta

Il percorso che una singola richiesta compie dentro un processo Node.js si può riassumere in cinque momenti. La richiesta HTTP arriva e viene tradotta in un evento, associato al suo handler, e messa in coda. L'event loop, alla sua prossima iterazione, preleva l'evento ed esegue l'handler. Se l'handler ha bisogno di un'operazione di I/O — leggere un file, interrogare un database, chiamare un altro servizio — non la esegue lui: la delega, e ritorna immediatamente. Quando l'operazione di I/O termina, il suo esito produce un nuovo evento: la callback viene rimessa nella coda. L'event loop, prima o poi, la preleva ed esegue il codice che scrive la risposta. Nel frattempo — ed è tutto il punto — il thread ha potuto servire altri eventi.

richiesta HTTP dal client evento in coda + handler associato event loop preleva ed esegue I/O delegato thread separato risposta res.end() handler senza I/O: la risposta parte subito al termine dell I/O la callback rientra nella coda come nuovo evento fra la delega e la callback il thread non attende: preleva ed esegue altri eventi i risultati non si aspettano: quando sono pronti entrano nell event loop
Tavola 4.3 — Il ciclo di vita di una richiesta: la freccia vermiglia è ciò che distingue Node.js, perché è il ritorno differito del risultato dentro la stessa coda da cui era partito tutto.

Logica asincrona: la controindicazione

Tutte le richieste vengono dunque servite da un singolo thread, in modo concorrente e con logica asincrona. Le slide accompagnano questa frase con un avvertimento in maiuscolo, ed è giusto riportarlo con la stessa enfasi: occorre fare attenzione a come quel singolo thread viene gestito, perché una cattiva gestione può causare problemi a tutti i client. L'esempio scelto è il più chiaro possibile: uno sleep si ripercuote sull'intero sistema.

Il ragionamento è immediato una volta accettato il modello. In un server thread-based, se un handler si addormenta per tre secondi, si addormenta un worker: gli altri client vengono serviti da altri thread e non se ne accorgono. In Node.js quel thread è l'unico che esegue JavaScript: se lo bloccate, si blocca la coda, e con essa ogni altra richiesta in attesa — comprese quelle che non hanno nulla a che vedere con l'operazione lenta.

Attenzione

Bloccare l'event loop è l'errore architetturale tipico di chi arriva a Node.js da un modello thread-based. Le due varianti sono un'attesa attiva o passiva (uno sleep, un ciclo che gira a vuoto) e un calcolo lungo che tiene occupata la CPU. In entrambi i casi il sintomo non è "quella richiesta è lenta", ma "il server intero non risponde". È esattamente per questo che le slide sconsigliano Node.js per le applicazioni CPU-intensive, e non per una presunta lentezza del linguaggio.

Il rovescio della medaglia è la regola positiva: non si aspettano i risultati. Quando saranno pronti verranno inseriti nell'event loop. Ricapitolando con le parole delle slide: Node.js non esegue un nuovo thread quando una richiesta arriva, ma inizia invece a eseguire in modo asincrono, ed è quindi sempre pronto a gestire nuove richieste; ciò permette di consumare meno memoria e permette al server di essere più veloce.

Sul piano del codice, la differenza fra bloccare e non bloccare passa spesso da una sola lettera nel nome della funzione, e conviene abituarsi a vederla subito:

const fs = require('fs');

// Bloccante: l'event loop resta fermo finche il disco non risponde.
const dati = fs.readFileSync('file.txt', 'utf8');
console.log(dati);

// Non bloccante: l'operazione viene delegata, la callback rientra
// nella coda quando il file e pronto. Il thread nel frattempo lavora.
fs.readFile('file.txt', 'utf8', (err, dati) => {
  if (err) { console.error(err); return; }
  console.log(dati);
});
Per l'esame

La domanda "spiegate il modello event-driven di Node.js" va risposta in quattro mosse, non in una. Primo: le richieste diventano eventi con un handler associato e finiscono in una coda. Secondo: un event loop single-threaded a ogni iterazione controlla la coda ed esegue l'handler. Terzo: le operazioni di I/O sono delegate a thread aggiuntivi ed eseguite in modo asincrono, e i loro risultati rientrano nella coda. Quarto: la conseguenza pratica — meno memoria, nessun limite di thread da rispettare, nessun context switching, ma un unico thread che va tenuto libero, perché uno sleep si ripercuote sull'intero sistema. Il confronto con il modello thread-based (dispatcher, un worker per richiesta, I/O bloccante) va portato per intero: è la metà della domanda.

5. Quando usare Node.js, e quando no

Le slide affrontano la questione con il titolo di un articolo diventato celebre, Why the Hell Would You Use Node.js, e danno una risposta circoscritta. Per le sue caratteristiche Node.js è una piattaforma altamente indicata per le applicazioni data-intensive e real-time che eseguono su diversi device distribuiti. Queste applicazioni hanno un nome e un acronimo: DIRT Application, cioè Data-Intensive Real-Time Application. La motivazione tecnica è quella che conosciamo già: Node.js è capace di gestire un elevato numero di connessioni simultanee, e quindi offre elevata scalabilità.

Gli esempi di DIRT Application elencati sono quattro, e hanno tutti la stessa forma — molte connessioni aperte a lungo, poco calcolo per connessione, dati che devono arrivare subito:

Esempio di DIRT ApplicationPerché è un buon caso per Node.js
Trasmissione di contenuti multimediali in streamingMolti flussi aperti contemporaneamente, ciascuno con costo di elaborazione modesto
Videogiochi onlineAggiornamenti frequenti e bidirezionali, dove la latenza conta più della potenza di calcolo
ChatIl caso da manuale della connessione two-way, con il server che inizia lo scambio
Sistemi di monitoraggio con IoT e smart objectMoltissimi device distribuiti che parlano poco ma di continuo

Altrettanto esplicito è l'elenco dei casi in cui non conviene usarlo. Il primo sono le applicazioni CPU-intensive, cioè quelle che richiedono computazioni lunghe e complesse: come abbiamo visto, un calcolo lungo occupa l'unico thread che serve tutti. Il secondo sono i server per la gestione di file statici: qui non è un difetto di Node.js in sé, ma la constatazione che esistono strumenti nati apposta per quel compito e più efficienti — ed è uno dei motivi per cui, alla sezione 13, troveremo NGINX davanti a Node.js.

Per l'esame

La coppia "quando sì / quando no" è una domanda ricorrente, e la risposta memorabile è una sola frase da cui si deriva tutto il resto: Node.js conviene quando il collo di bottiglia è l'I/O e non la CPU. Le DIRT Application sono per definizione I/O-bound e connection-bound, quindi guadagnano tutto; un'applicazione CPU-bound occupa l'unico thread e paralizza il server; il servizio di file statici è I/O-bound ma è già risolto meglio da un web server dedicato. Citate l'acronimo per esteso — Data-Intensive Real-Time Application — e almeno due dei quattro esempi.

Chi usa Node.js nel mondo reale

L'esercitazione dedica una slide intera a mostrare quanto l'adozione sia trasversale: da un lato aziende e prodotti che si appoggiano a Node.js, dall'altro gli strumenti dell'ecosistema che ci girano sopra. Vale la pena leggere le due tabelle insieme, perché raccontano due fenomeni distinti. Nella prima, Node.js compare due volte con ruoli diversi: come runtime di applicazioni desktop (via Electron, che permette di distribuire una stessa base di codice su più piattaforme) e come backend di servizi ad alta concorrenza. Nella seconda si vede invece come Node.js sia diventato la piattaforma su cui è scritto il tooling dell'intero mondo front-end, anche quando l'applicazione finale non ha nulla a che fare con JavaScript lato server.

NomeCategoriaDove si usa / beneficio principale
SlackComunicazioneApp desktop in Electron/Node.js: cross-platform, un unico codice
Visual Studio CodeEditor di codiceScritto in TypeScript, gira su Electron/Node: estendibilità e portabilità
DiscordChat/VoIPClient desktop in Electron, backend real-time con Node: bassa latenza
FigmaDesignApp desktop Electron/Node: multipiattaforma
PostmanAPI testingDesktop client Node/Electron: strumento per sviluppatori
NotionProduttivitàApp desktop in Node/Electron: cross-platform
NetflixStreamingNode.js nel backend: riduzione della latenza, scalabilità
PayPalFintechNode.js nel backend: riduzione della latenza, scalabilità
UberRide sharingGestione richieste real-time con Node.js: scalabilità
LinkedInSocialBackend mobile migrato a Node: più veloce di Java
WalmarteCommerceNode.js per i picchi di traffico (per esempio il Black Friday)
eBayMarketplaceAste e notifiche in tempo reale con Node.js
MicrosoftSoftware/CloudVS Code, Azure CLI, dev tools in Node/TS: ecosistema unificato
GoogleCloud/DevFirebase CLI, Angular CLI e altri strumenti scritti in Node.js
StrumentoCategoriaA cosa serve
npmPackage managerGestione delle dipendenze Node.js
yarnPackage managerAlternativa più veloce e deterministica a npm
pnpmPackage managerInstallazione efficiente con link simbolici
webpackBundlerImpacchetta e ottimizza JS, CSS e asset
viteBundler / dev serverAvvio molto veloce, hot reload
rollupBundlerOttimizzato per librerie JS/TS
jestTestingFramework di test moderno per JS/TS
mochaTestingTest runner flessibile e semplice
cypressTesting E2ETest end-to-end per applicazioni web
playwrightTesting E2EAutomazione browser multipiattaforma
expressFramework webFramework backend più diffuso su Node.js
fastifyFramework webFramework veloce e leggero per API
nestjsFramework webFramework full-stack in TypeScript, in stile Angular
eslintLintingControllo della qualità del codice
prettierFormatterFormattazione automatica del codice
ts-nodeRuntimeEsegue TypeScript direttamente su Node
nodemonDev utilityRestart automatico del server in sviluppo
prismaDatabase ORMORM moderno per SQL e NoSQL in TypeScript
mongooseODMGestione di MongoDB con Node.js
electronFramework desktopPermette di creare applicazioni desktop
pm2Process managerGestisce e monitora processi Node.js in produzione

Di questi strumenti ne incontrerete parecchi in laboratorio: express dal prossimo capitolo, mongoose quando arriverà MongoDB, vite con VueJS, eslint e prettier non appena il progetto d'esame comincerà ad avere più di un autore.

6. Primi passi: installazione, REPL, script, stile

L'installazione consiste nello scaricare ed eseguire l'installer dal sito ufficiale, nodejs.org. Alla data delle slide la versione LTS indicata è la 22.19.0, che include npm 11.6.0: il dettaglio importante non è il numero, che invecchia in fretta, ma il fatto che npm venga installato direttamente insieme a Node, senza passaggi ulteriori. Sono disponibili i codici sorgente e installer pre-costruiti per Windows, macOS e Linux, oltre ad altre piattaforme (SunOS, immagini Docker e così via); esiste anche una pagina dedicata all'installazione tramite i package manager di sistema. Per verificare che tutto sia a posto si usa un comando solo:

node -v

Il REPL

REPL sta per Read-Eval-Print Loop ed è un ambiente interattivo per eseguire codice JavaScript: legge quello che scrivete, lo valuta, ne stampa il risultato e ricomincia. Si avvia lanciando node senza argomenti — la modalità interattiva di cui parlano anche le slide della lezione teorica. È lo strumento giusto per verificare al volo il comportamento di un'espressione, senza creare un file.

> 1 + 1
2
> const msg = "Hello World"
undefined
> console.log(msg)
Hello World
undefined
> .exit

Questo esempio, apparentemente banale, contiene una lezione che vale la pena esplicitare: il REPL stampa il valore dell'espressione valutata, non ciò che il codice "fa". Per questo const msg = "Hello World" risponde undefined — una dichiarazione non produce un valore — e per questo console.log(msg) stampa prima Hello World (l'effetto) e poi undefined (il valore di ritorno di console.log). Il comando .exit chiude la sessione.

Eseguire uno script

La forma generale del comando è node <flag> <script> <argomenti script>. Nel caso più semplice, dato un file index.js che contiene una sola riga:

/* example-00/index.js */

console.log("Hello World!");

lo si esegue da terminale con node index.js. C'è però una nota che conviene ricordare, perché spiega una convenzione che ritroverete ovunque nell'ecosistema: se l'argomento passato a node è una directory, verrà eseguito il file index.js contenuto in quella directory. È lo stesso motivo per cui npm init, come vedremo, propone index.js come entry point predefinito del pacchetto.

Regole di stile

Le slide sono oneste su un punto che il capitolo 1 aveva già segnalato tra gli svantaggi dello stack MEAN: JavaScript non ha regole di stile globalmente accettate. Da qui due buone prassi. La prima è essere coerenti: qualunque convenzione scegliate, applicatela in tutto il progetto — e in un elaborato di gruppo questo significa decidere insieme, prima di scrivere. La seconda è seguire delle linee guida già scritte invece di inventarne di proprie. Le tre citate sono quelle di W3Schools (w3schools.com/js/js_conventions.asp), di Google (google.github.io/styleguide/jsguide.html) e di Airbnb (github.com/airbnb/javascript).

Nota del redattore

La scelta fra queste tre guide conta molto meno del fatto di sceglierne una e renderla automatica. Nell'ecosistema Node la coerenza non si ottiene con la buona volontà ma con gli strumenti: un linter come eslint segnala le violazioni, un formatter come prettier le corregge da solo al salvataggio. Entrambi compaiono nella tabella degli strumenti dell'ecosistema della sezione precedente, e vedremo alla sezione 11 che Deno li integra addirittura nella piattaforma.

7. I moduli built-in

Node.js offre una serie di moduli built-in per le funzioni comunemente utilizzate: vengono distribuiti con la piattaforma, non c'è nulla da installare, e si caricano con require indicando il solo nome del modulo. Sono la dimostrazione pratica del beneficio "forte controllo della logica dell'app e dell'ambiente" visto alla sezione 2: file system, rete, processi ed eventi sono a portata di una riga di codice, senza dipendenze esterne.

Il primo è fs (File System), per la gestione dei file:

const fs = require('fs');
fs.readFileSync('file.txt', 'utf8');

Il secondo è http, per la gestione delle richieste HTTP, sia lato server sia lato client. È il modulo con cui si costruisce il server HTTP già presentato nel capitolo 3 dal punto di vista del protocollo, e che qui rileggiamo dal punto di vista della piattaforma: createServer riceve una callback con i due parametri req (la richiesta) e res (la risposta), e listen mette il server in ascolto su una porta.

const http = require('http');
const server = http.createServer((req, res) => {
  res.end('Hello World');
});
server.listen(3000);

Il terzo è path, con le utilità per la manipolazione dei percorsi — qui __dirname è la directory del file corrente e join compone i segmenti usando il separatore giusto per il sistema operativo:

const path = require('path');
const fullPath = path.join(__dirname, 'file.txt');

Il quarto è os, per le informazioni sul sistema operativo:

const os = require('os');
console.log(os.platform());

Il quinto è events, per la gestione degli eventi. Merita un momento di attenzione in più, perché espone esattamente il meccanismo su cui si regge l'intera piattaforma: si crea un EventEmitter, si registra un handler con on e si scatena l'evento con emit. La coppia evento-handler della sezione 4 non era una metafora: è un'API che potete usare voi stessi.

const EventEmitter = require('events');
const myEmitter = new EventEmitter();
myEmitter.on('event', () => {
  console.log('An event occurred!');
});
myEmitter.emit('event');

Il sesto è crypto, con le funzioni crittografiche — nell'esempio, il calcolo dell'hash SHA-256 di una stringa:

const crypto = require('crypto');
const hash = crypto.createHash('sha256');
hash.update('Hello, World!');
console.log(hash.digest('hex'));

L'elenco non finisce qui: le slide citano anche util, zlib, stream, dns e altri. Per i dettagli il riferimento è la documentazione ufficiale su nodejs.org/api.

8. Moduli custom in CommonJS

Un modulo custom è un file JavaScript che implementa alcune funzioni e le espone agli utilizzatori. La regola di visibilità è una sola e va imparata alla lettera: le funzioni assegnate all'oggetto exports diventano pubbliche, tutte le altre restano private. Il modulo è quindi anche un meccanismo di incapsulamento: ciò che non esportate esplicitamente semplicemente non esiste, per chi importa.

Per l'importazione la regola è doppia, e distingue i moduli installati dai vostri. Specificando solo il nome del modulo da importare, questo verrà cercato nella cartella node_modules, dove finiscono i moduli installati tramite package manager. Per importare un modulo custom bisogna invece specificare il path relativo (o assoluto): è il prefisso ./ a dire a Node "questo file è mio, non cercarlo altrove". Attenzione a un dettaglio: il path di import è relativo al file che esegue require(), non alla directory da cui lanciate il programma.

/* example-01/my-module.js */
const add = (a, b) => a + b;
const sub = (a, b) => a - b;

module.exports = { add };
/* example-01/index.js */
const myModule = require('./my-module');

console.log(myModule.add(1, 2)); // 3
console.log(myModule.sub(1, 2)); // TypeError: myModule.sub is not a function

Notate cosa dimostra l'esempio: sub esiste nel file del modulo, è definita e funzionante, ma non essendo stata inserita in module.exports è privata. Chi importa il modulo riceve un oggetto che contiene solo add; il tentativo di chiamare myModule.sub fallisce con un TypeError, perché quella proprietà, dall'esterno, non c'è mai stata.

exports contro module.exports

Qui le slide si fermano su una sottigliezza che genera bug reali: exports è un riferimento a module.exports, cioè un'abbreviazione. Finché lo usate per aggiungere proprietà, i due nomi puntano allo stesso oggetto e tutto fila. Ma se riassegnate exports — cioè scrivete exports = qualcosa — perdete il riferimento: da quel momento exports punta a un oggetto nuovo che nessuno guarderà mai, perché ciò che viene esportato è sempre e solo module.exports.

/* example-01/export-test.js */

exports.a = "A";
module.exports.b = "B";

console.log(exports === module.exports); // true
console.log(module.exports); // { a: 'A', b: 'B' }
console.log(exports); // { a: 'A', b: 'B' }

exports = { c: "C" };

console.log(exports === module.exports); // false
console.log(module.exports); // { a: 'A', b: 'B' }
console.log(exports); // { c: 'C' }

Rileggete le due metà dell'esempio come un prima e un dopo. Prima della riassegnazione, aggiungere a tramite exports e b tramite module.exports produce lo stesso oggetto { a, b }, e il confronto con === conferma che i due nomi sono la stessa cosa. Dopo exports = { c: "C" } il confronto diventa false: module.exports vale ancora { a, b } — ed è quello che chi fa require riceverà — mentre c è finita in un oggetto orfano.

Le due forme di scrittura corrette, mostrate fianco a fianco nelle slide, sono quindi: assegnare in blocco a module.exports un oggetto con tutte le funzioni pubbliche, oppure aggiungere le funzioni una per una come proprietà di exports. Sono equivalenti; ciò che non potete fare è riassegnare exports.

/* example-01/module.exports.js */
module.exports = {
  greet: function(name) {
    console.log(`Hello, ${name}!`);
  },
  farewell: function(name) {
    console.log(`Goodbye, ${name}!`);
  }
}

/* example-01/exports.js — equivalente */
exports.greet = function (name) {
  console.log(`Hello, ${name}!`);
};
exports.farewell = function (name) {
  console.log(`Goodbye, ${name}!`);
};
Per l'esame

"Che differenza c'è tra exports e module.exports?" ha una risposta in tre frasi. Uno: exports è un riferimento a module.exports, un'abbreviazione comoda. Due: riassegnando exports si perde il riferimento, quindi exports = {...} non esporta nulla. Tre: ciò che il modulo esporta è sempre e solo module.exports. Se sapete anche riprodurre l'esempio di export-test.js con i valori di a, b e c, la domanda è chiusa.

9. I moduli ECMAScript

CommonJS è il sistema di moduli storico di Node.js ed è tuttora la tipologia predefinita. Ma Node.js supporta anche i moduli ECMAScript, che sono il formato standard ufficiale per impacchettare il codice JavaScript — lo stesso che usano i browser. In questo formato i moduli si gestiscono con le istruzioni import ed export, che sono sintassi del linguaggio e non funzioni della piattaforma come require.

Poiché il default resta CommonJS, i moduli ECMAScript vanno abilitati esplicitamente, in uno di tre modi: dichiarando "type": "module" nel package.json del progetto; usando l'estensione .mjs per segnalare l'uso di moduli ECMAScript nei singoli file; oppure eseguendo node con l'opzione --input-type=module o --experimental-default-type=module.

Il modulo di esempio delle slide esporta due funzioni con nome e una funzione di default:

/* example-03/my-module.js */

const add = (a, b) => a + b;
const sub = (a, b) => a - b;

export default (a, b) => { return a * b; };
export { add, sub };

E l'importazione può avvenire in due stili. Con il namespace import si importa tutto il modulo sotto un nome unico, e il default export compare come proprietà default; con il named import si scelgono i singoli export per nome, e al default export si dà il nome che si vuole:

/* example-03/index-namespace-import.js */
import * as myModule from './my-module.js';

console.log(myModule.add(1, 2)); // 3
console.log(myModule.sub(1, 2)); // -1
console.log(myModule.default(1, 2)); // 2

/* example-03/index-named-import.js */
import multiply, { add, sub } from './my-module.js';

console.log(add(1, 2)); // 3
console.log(sub(1, 2)); // -1
console.log(multiply(1, 2)); // 2

Le varianti della sintassi di importazione sono numerose, e le slide le elencano tutte rimandando a MDN per i dettagli:

/* example-03/imports.js */

import defaultExport from "module-name";
import * as name from "module-name";
import { export1 } from "module-name";
import { export1 as alias1 } from "module-name";
import { default as alias } from "module-name";
import { export1, export2 } from "module-name";
import { export1, export2 as alias2 /* … */ } from "module-name";
import { "string name" as alias } from "module-name";
import defaultExport, { export1 /* … */ } from "module-name";
import defaultExport, * as name from "module-name";
import "module-name";

Il confronto fra i due sistemi, così come lo sintetizzano le slide, tiene conto di quattro assi: la compatibilità con Node.js, la compatibilità con i browser, la possibilità di import dinamico (decidere a runtime se e cosa importare) e la possibilità di import asincrono (importare senza bloccare, ricevendo una promise).

Compatibilità con Node.jsCompatibilità con browserImport dinamicoImport asincrono
CommonJSnono
ECMAScriptparziale (da v12+)

I due esempi che accompagnano la tabella mostrano le due forme di import dinamico: in CommonJS si può chiamare require dentro un if, in modo sincrono; in ECMAScript la funzione import() restituisce una promise su cui agganciare un then:

// CommonJS: import dinamico, sincrono
if (condition) {
  const moduleA = require('./moduleA');
}

// ECMAScript: import dinamico e asincrono
import('./moduleA').then((moduleA) => {
  moduleA.doSomething();
});
Nota del redattore

Perché due sistemi di moduli? Per ragioni storiche: quando Node.js è nato, JavaScript non aveva un sistema di moduli nel linguaggio, e CommonJS è stato inventato per colmare la lacuna. Lo standard ECMAScript ha aggiunto import/export solo in seguito, e Node li supporta dalla v12 in poi. Nei prossimi capitoli vedrete entrambe le sintassi: CommonJS negli esempi con require delle esercitazioni Node, ECMAScript nel mondo Vue e nel tooling moderno. Saperle leggere entrambe non è opzionale.

10. npm, npx e i gestori di pacchetti alternativi

NPM, acronimo di Node Package Manager, è il gestore di pacchetti per JavaScript, predefinito per Node.js, e viene installato direttamente con Node. Consiste di due cose distinte che portano lo stesso nome: un client da linea di comando (il package manager vero e proprio) e un registry di pacchetti pubblici e privati, npmjs.com, che ospita migliaia di pacchetti gratuiti da scaricare e usare. La terminologia delle slide distingue con cura: un modulo è una libreria che può essere inclusa nel progetto, un pacchetto contiene tutti i file necessari per utilizzare un modulo, e npm permette agli utenti di "consumare" e distribuire pacchetti. La citazione dal sito ufficiale vista alla sezione 2 dà la misura del fenomeno: npm è il più grande ecosistema di librerie open source al mondo.

I comandi principali sono pochi: npm init crea un nuovo pacchetto; npm install installa le dipendenze del pacchetto corrente; npm install <nome-pacchetto> installa un nuovo pacchetto (con le opzioni storiche @versione, --save, -dev, -optional, -exact, --global); npm run <comando> esegue uno script definito nel progetto; npm update aggiorna i pacchetti installati; e poi npm list, npm uninstall e npm publish per pubblicare un proprio pacchetto sul registry.

npm init e il package.json

npm init inizializza un pacchetto creando il file package.json, che contiene i metadati e le configurazioni del progetto: versione, dipendenze e così via. Il comando è interattivo e propone un default per ogni campo — notate l'entry point proposto, index.js, coerente con la convenzione vista alla sezione 6, e il campo type con default commonjs, che è esattamente l'interruttore dei moduli ECMAScript visto alla sezione 9:

npm init

package name: (tmp)
version: (1.0.0)
description:
entry point: (index.js)
test command:
git repository:
keywords:
author:
license: (ISC)
type: (commonjs)
{
  "name": "tmp",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "commonjs"
}

npm install, node_modules e il lockfile

Installare un pacchetto — per esempio npm install lodash — produce tre effetti. Primo: la dipendenza viene aggiunta al package.json, nella sezione dependencies, con la sua versione. Secondo: viene creata la cartella node_modules, dove vengono salvati i pacchetti installati — ed è qui che require('lodash') andrà a cercare, secondo la regola di risoluzione della sezione 8. Terzo: viene aggiunto il file package-lock.json, che blocca le versioni delle dipendenze installate, comprese quelle transitive, così che un'installazione successiva riproduca esattamente lo stesso albero.

{
  ...
  "dependencies": {
    "lodash": "<version>"
  }
}

La domanda con cui le slide chiudono il discorso è pratica e arriva dritta al progetto d'esame: cosa facciamo tracciare al sistema di controllo della versione? Guardate l'albero del progetto:

.
├── index.js
├── node_modules
│   └── lodash
│       ├── ...
│       ...
│       └── zipWith.js
├── package-lock.json
└── package.json

La risposta è: index.js, package.json e package-lock.json sì; node_modules no. La cartella dei pacchetti è interamente ricostruibile: chiunque cloni il repository ottiene, con un semplice npm install, esattamente le versioni bloccate dal lockfile. Versionarla significherebbe trascinarsi dietro migliaia di file generati che non avete scritto voi.

Attenzione

Il riflesso condizionato da acquisire subito: node_modules va nel .gitignore, package-lock.json no. L'errore inverso — committare node_modules o ignorare il lockfile — è tra i più comuni nei primi progetti di gruppo: nel primo caso il repository esplode di file generati, nel secondo ogni membro del gruppo rischia di installare versioni diverse delle stesse dipendenze, e i bug diventano irriproducibili.

npx

Oltre a npm, che si occupa della gestione dei pacchetti, la distribuzione di Node.js include anche npx (Node Package eXecute), pensato per eseguire direttamente pacchetti Node.js senza doverli installare in modo globale nel sistema. In questo modo è possibile provare pacchetti velocemente senza aggiungere la dipendenza al progetto, lanciare programmi e strumenti CLI nella versione locale o in una versione specificata, ed evitare conflitti fra versioni diverse di uno stesso pacchetto.

Il problema che risolve è concreto. Quando si installa un pacchetto che contiene un eseguibile — per esempio eslint, typescript o jest — questo finisce nella cartella node_modules/.bin: per eseguirlo bisognerebbe usare il percorso completo all'eseguibile, oppure installarlo globalmente con npm install -g. Con npx si lancia direttamente il comando, ed è lo strumento stesso a cercare, in ordine: prima le dipendenze locali del progetto, poi — se non trova nulla — scaricando il pacchetto da internet e salvandolo in cache per gli usi futuri.

# Eseguire un pacchetto senza installarlo
npx cowsay "Hello World"

# Creare un nuovo progetto con un tool "usa e getta"
npx create-react-app my-app

# Lanciare una versione specifica, senza modificare il progetto
npx -p [email protected] cowsay "Version 1.5.0"

Yarn e pnpm

npm non è l'unico gestore di pacchetti dell'ecosistema. Yarn, introdotto da Facebook (oggi Meta) nel 2016, nasce come alternativa a npm con focus su velocità e affidabilità: usa un proprio lockfile, yarn.lock, per garantire versioni consistenti delle dipendenze, e ha un'interfaccia simile a npm con alcune differenze nei comandi. A partire dalla versione 2 supporta Plug'n'Play (PnP), che elimina del tutto la cartella node_modules: migliora le prestazioni e riduce lo spazio su disco, gestendo le dipendenze tramite un file .pnp.cjs.

# Inizializzare un progetto
yarn init

# Installare tutte le dipendenze
yarn install

# Aggiungere un pacchetto
yarn add express

# Eseguire uno script
yarn run start

# Eseguire uno script con node
yarn node index.js

pnpmperformant npm — è stato creato nel 2017 e attacca il problema dello spazio: riduce l'occupazione su disco tramite symlink e uno store globale, in cui ogni versione di ogni pacchetto è memorizzata una volta sola e collegata nei progetti che la usano. È compatibile con l'ecosistema npm ed è molto usato nei progetti monorepo.

# Inizializzare un progetto
pnpm init

# Installare tutte le dipendenze
pnpm install

# Aggiungere un pacchetto
pnpm add express

# Rimuovere un pacchetto
pnpm remove express
CaratteristicanpmYarnpnpm
Anno di rilascio201020162017
Registrynpm registrynpm registrynpm registry
Lockfilepackage-lock.jsonyarn.lockpnpm-lock.yaml
Velocitàbassamediaalta
Uso del discoaltomediobasso (symlink e store)

Notate la seconda riga della tabella: tutti e tre parlano con lo stesso registry. La concorrenza fra i tre strumenti è tutta sul come — velocità di installazione, formato del lockfile, strategia di memorizzazione su disco — mai sul cosa: i pacchetti sono gli stessi.

11. Non solo Node.js: Deno e Bun

Node.js non è più l'unico modo di eseguire JavaScript fuori dal browser. Le slide presentano due alternative, ed entrambe meritano attenzione — la prima anche per una ragione storica curiosa: Deno è stato creato nel 2018 da Ryan Dahl, cioè lo stesso autore di Node.js. È in buona parte una risposta ai limiti di progetto che l'autore stesso riconosceva alla sua prima creatura.

Deno

Deno è scritto in Rust ed è sicuro di default: l'accesso a file, rete e variabili d'ambiente richiede permessi espliciti. È il rovesciamento del modello di Node, dove un qualunque pacchetto installato da npm può leggere il vostro disco e aprire connessioni senza chiedere nulla. Supporta TypeScript nativamente, senza bisogno di strumenti come ts-node; i moduli sono importabili direttamente da URL, senza passare da un registry centralizzato; e linter e formatter sono integrati nella piattaforma — quelle regole di stile che in Node richiedono di installare eslint e prettier, qui arrivano di serie. Il sito è deno.com.

L'esecuzione di uno script è parallela a quella di Node, con in più i sottocomandi della toolchain:

# Eseguire il codice JavaScript
deno run index.js

# Eseguire il formatting del file
deno fmt index.js

Il modello dei permessi si vede alla prima operazione "pericolosa". Prendete uno script che scrive un file su disco:

/* example-00/fs-writing.js */

const fs = require('fs');

fs.writeFile("./output.txt", "Hello, world!", (err) => {
  if (err) {
    console.error("Error writing file:", err);
    return;
  }
  console.log("File written successfully.");
});

Con Node parte e scrive. Con Deno, per farlo funzionare bisogna concedere esplicitamente il permesso di scrittura:

deno run --allow-write fs-writing.js

Bun

Bun, rilasciato nel 2021, è scritto in Zig e ha un obiettivo dichiarato: le alte prestazioni — più veloce di Node.js e di Deno in molti casi. Come Deno supporta TypeScript nativamente, e include un'intera toolchain: package manager, bundler, test runner e altro ancora. A differenza di Deno, punta sulla compatibilità con i moduli npm: l'ecosistema esistente funziona. Il sito è bun.sh. L'esecuzione è, di nuovo, la stessa forma:

bun run index.js
CaratteristicaNode.jsDenoBun
Linguaggio baseC++RustZig
TypeScript nativoparziale (sperimentale)
Sandbox (permessi espliciti)nono
Gestore pacchettinpmintegratointegrato
Nota del redattore

Leggete la tabella come una mappa dei compromessi, non come una classifica. Deno scommette sulla sicurezza (sandbox, permessi) e sulla pulizia del progetto, al prezzo di un ecosistema meno immediato; Bun scommette sulle prestazioni e sulla compatibilità npm, rinunciando alla sandbox; Node.js resta lo standard de facto, con l'ecosistema più vasto — ed è la piattaforma che questo corso usa in laboratorio. Le idee però migrano: il supporto TypeScript sperimentale in Node è una risposta diretta alla pressione dei due concorrenti.

12. I template engine e EJS

Torniamo al nostro server HTTP. Finché risponde "Hello World" va tutto bene; il problema nasce quando deve rispondere con una pagina HTML che contiene dati dinamici. La soluzione ingenua è l'interpolazione manuale: costruire la pagina come un'unica template literal JavaScript, infilando le variabili con ${...}:

const name = "Mario Rossi";
const age = 30;
const html = `
  <html>
    <head><title>My Page</title></head>
    <body>
      <h1>Hello, ${name}!</h1>
      <p>Age: ${age}</p>
    </body>
  </html>
`;
res.write(html);

Funziona, ma non scala: l'HTML vive dentro le stringhe del codice, senza evidenziazione né controllo sintattico, e ogni modifica alla pagina passa per il file JavaScript. È lo stesso vizio strutturale — la commistione fra logica e presentazione — che il capitolo 1 aveva individuato nell'approccio embedded code del modello a tre livelli.

La soluzione è un template engine (motore di template). Un template engine consente di utilizzare template statici nell'applicazione: in fase di esecuzione, l'engine sostituisce le variabili con i valori attuali e trasforma il template in un file HTML da inviare al client. Il template è un file a sé, scritto quasi tutto in HTML normale; i dati arrivano dal codice, ma la struttura della pagina vive fuori dal codice.

Di template engine ne esistono molti — la pagina delle risorse di Express ne elenca una lista intera — ma il corso usa EJS (Embedded JavaScript, ejs.co). L'uso senza framework, con il solo modulo http, richiede tre passi: aggiungere la dipendenza, leggere il file di template, fare il rendering passando i dati.

npm install ejs
const ejs = require('ejs');

// 1. Leggere il template che si vuole utilizzare (page.ejs)
fs.readFile("./views/page.ejs", "utf-8", (err, template) => {...});

// 2. Preparare i dati
const data = { name: "Manuel", date: new Date().toDateString() };

// 3. Rendering: il risultato e la stringa HTML da inviare al client
const html = ejs.render(template, data);

Il template è HTML con delimitatori <% ... %>. La variante <%= espressione %> stampa il valore dell'espressione nel punto in cui compare; la variante <% codice %> esegue codice JavaScript senza stampare nulla, ed è quella che si usa per il controllo di flusso — cicli e condizioni — che abbraccia il markup:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>EJS Example</title>
</head>
<body>
  <h1>Hello <%= name %>!</h1>
  <p>Today is <%= date %></p>
</body>
</html>
<!DOCTYPE html>
<html>
  <head>
    <title>Hello World</title>
  </head>
  <body>
    <ul>
      <% numeri.forEach(function(val) { %>
        <li><%= val %></li>
      <% }); %>
    </ul>
  </body>
</html>

"Vi ricorda qualcosa?", chiedono le slide. La risposta è: il PHP di Tecnologie Web. La stessa lista di numeri, in PHP classico, si scriverebbe con un blocco <?php ... ?> che calcola l'array e lo stampa con echo dentro il markup. La somiglianza è voluta — HTML con isole di codice delimitate — ma la differenza sta in dove vive la logica:

AspettoPHP classicoNode.js + EJS
Scrittura templateHTML con blocchi <?php ... ?>HTML con delimitatori <% ... %>
Curva di apprendimentoHTML + PHPHTML + EJS + JS
Pulizia del codiceMischia logica e vistaFavorisce la separazione chiara: logica in JS, vista in EJS
EsecuzioneWeb server Apache/NGINXWeb server Node.js

In PHP classico la pagina è il programma: dentro il template si fanno le query e si prendono le decisioni. Con EJS il template riceve dati già pronti — l'oggetto data del rendering — e si limita a disporli nel markup: la logica sta nel file JavaScript, la vista nel file .ejs. È il primo passo concreto, in laboratorio, verso la separazione fra Model e View che il pattern MVC del capitolo 3 predicava in teoria.

13. Il deploy: NGINX davanti a Node.js

Sulla messa in produzione le slide citano un tweet di Bryan Hughes diventato proverbiale:

«If #nginx isn't sitting in front of your node server, you're probably doing it wrong»

Se NGINX non sta davanti al vostro server Node, probabilmente state sbagliando qualcosa. Le slide rimandano anche a un caso reale su Stack Overflow di configurazione errata della coppia NGINX + Node.js: l'errore è abbastanza comune da meritare un monito.

NGINX è un web server, gratuito e open source, che può essere usato anche come reverse proxy, load balancer e HTTP cache. Nel deploy di applicazioni Node.js si affianca a Node come reverse proxy server: un proxy che solitamente si trova dietro il firewall in una rete privata e dirige le richieste dei client all'appropriato backend server. È un ulteriore livello di astrazione fra il client e l'applicazione, spesso usato per load balancing, come web accelerator e per sicurezza e anonimato.

client client client firewall / rete privata NGINX reverse proxy load balancer HTTP cache Node.js processo app Node.js processo app file statici serviti e cachati da NGINX richieste applicative senza passare da Node
Tavola 4.4 — NGINX come reverse proxy davanti a Node.js: i client vedono solo NGINX; le richieste applicative vengono smistate ai processi Node, i file statici non li toccano nemmeno.

Le ragioni per mettere un reverse proxy davanti a Node.js sono elencate una per una nelle slide, e ciascuna tocca un punto visto nel capitolo:

RagionePerché conta
Semplificare la gestione di privilegi e porteNGINX ascolta sulle porte privilegiate 80/443; i processi Node girano senza privilegi su porte alte
Servire in modo più efficiente i file staticiÈ esattamente il caso "quando NON usare Node.js" della sezione 5: il file statico non deve occupare l'event loop
Cache dei file staticiLa risposta già pronta parte da NGINX senza nemmeno toccare l'applicazione
Nascondere l'identità del server Node.jsMigliore gestione dei crash e mitigazione degli attacchi DoS: il client non parla mai direttamente con Node
Load balancingPiù processi Node dietro lo stesso NGINX: si aggira anche il limite del singolo thread, moltiplicando i processi
Per l'esame

La domanda "perché mettere NGINX davanti a Node.js?" si aggancia a due fili del capitolo e la risposta migliore li tira entrambi. Primo filo: Node.js non è un server per file statici — è nell'elenco esplicito dei casi in cui non usarlo — quindi statici e cache vanno a NGINX. Secondo filo: il reverse proxy aggiunge un livello di astrazione che dà load balancing, gestione di privilegi e porte, protezione dai crash e mitigazione DoS. Chiudete citando il ruolo triplo di NGINX — reverse proxy, load balancer, HTTP cache — e, se volete un tocco in più, il tweet di Hughes.

14. Express: un'anteprima

Il capitolo 5 è interamente dedicato a Express; qui ci limitiamo alla presentazione che ne fa la lezione sullo stack MEAN, perché serve a chiudere il quadro. Express è un framework server-side per applicazioni web Node.js, minimale e flessibile. Fornisce un insieme di funzionalità per costruire applicazioni web e mobile: facilita l'uso di Node.js, facilita l'implementazione di API REST, supporta diversi template engine (tra cui l'EJS appena visto), fornisce routing, middleware e un sistema modulare. Due note delle slide meritano il corsivo: il layer di funzionalità fornito non "oscura" le funzionalità di Node.js, e Express è spesso la base per architetture più complesse. Si installa, avendo già Node, con npm install express --save; le sue funzionalità principali sono la definizione di middleware per rispondere alle richieste HTTP e di una tabella di routing che esegue azioni differenti in base al metodo HTTP e all'URL. Esiste anche uno strumento di scaffolding, express-generator, che crea velocemente la struttura di un'applicazione (per esempio express --view=pug myapp).

Il confronto fra lo Hello World in Node puro e in Express, messo su due colonne dalle slide, è il modo più rapido di capire cosa il framework aggiunge — il routing dichiarativo per metodo e percorso al posto di un'unica callback per tutte le richieste:

// Node puro: una callback per TUTTE le richieste
var http = require('http');

http.createServer(function (req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('Hello World!');
    console.log("Response sent");
}).listen(8080);
console.log("A node web server is running!");

// Express: routing per metodo e URL
var express = require('express');
var app = express();

app.get('/', function (req, res) {
  res.send('Hello World!');
});

app.listen(3000, function () {
  console.log('Example app listening on port 3000!');
});

Tutto il resto — middleware in dettaglio, REST, OpenAPI — è materia del prossimo capitolo.

15. Gli esercizi di laboratorio

L'esercitazione si chiude con due esercizi che mettono insieme tutti i pezzi del capitolo: moduli custom, server HTTP, parsing del body, EJS. Conviene farli davvero, non solo leggerli, perché sono il primo mattone della catena che porta all'elaborato.

Esercizio 1 — il modulo routes

Il primo esercizio chiede di scrivere un modulo routes che possa essere utilizzato così:

const http = require('http');
const routes = require('./routes');
const server = http.createServer(routes.handler);

Notate il disegno: il modulo esporta una funzione handler — con la solita firma (req, res) — che viene passata a http.createServer. La logica di routing vive in un file suo, importato con il path relativo ./routes, e il file principale si riduce a tre righe: è la regola di visibilità della sezione 8 messa al lavoro. I requisiti sono due: sulla rotta /, con metodo GET, il modulo deve servire una pagina HTML (con un form di input); sulla rotta /message, con metodo POST, deve ricevere il contenuto dell'input e stamparlo nella console. Per distinguere rotte e metodi avete a disposizione le proprietà di req viste nel capitolo 3, req.url e req.method.

Il punto delicato è il parsing del body: il body viene inviato in chunk, quindi bisogna assemblare ciò che si riceve. Il pattern, basato sugli eventi data ed end della richiesta, è questo:

let body = '';
req.on('data', chunk => {
  body += chunk
});
req.on('end', () => {
  ...
});

Riconoscete il meccanismo? È l'EventEmitter della sezione 7: req emette un evento data per ogni chunk che arriva dalla rete e un evento end quando il body è completo. Non c'è nessuna attesa bloccante: tra un chunk e l'altro l'event loop è libero di servire altre richieste. Solo dentro l'handler di end il body è completo e si può usare.

Esercizio 2 — il catalogo prodotti con EJS

Il secondo esercizio chiede di creare un'applicazione web con Node.js, il modulo http e il motore di templating EJS. Il progetto prevede tre pagine — Homepage, Lista utenti, Catalogo prodotti — servite in base alla rotta richiesta dal client; i dati da mostrare sono definiti in index.js.

Per la lista utenti va creato un file users.ejs che mostri un elenco di utenti, ciascuno con nome ed età; se la lista è vuota deve comparire il messaggio "No users found". Per il catalogo va visualizzata una tabella di prodotti, dove ogni prodotto ha name (nome), price (prezzo in euro) e available (booleano disponibile/esaurito), su tre colonne: Nome, Prezzo, Disponibilità. Le regole di formattazione richieste sono precise: prezzo in rosso se maggiore di 100, in verde se minore o uguale a 100; e se non ci sono prodotti va mostrato il messaggio "Nessun prodotto disponibile".

Tutti gli ingredienti sono nella sezione 12: il ciclo <% array.forEach(...) { %> per righe e voci, <%= ... %> per stampare i valori, un if dentro i delimitatori <% %> per il caso vuoto e per scegliere la classe di colore del prezzo. La condizione sul prezzo è logica di presentazione, e infatti sta nel template; i dati stanno in index.js: è la separazione EJS/PHP della tabella di confronto, applicata.

Verifica le tue conoscenze

Perché le slide insistono sul fatto che Node.js non è né un web server né un linguaggio?

Perché Node.js è una piattaforma software cross-platform, più precisamente un runtime environment che permette di eseguire codice JavaScript fuori dal browser. Il linguaggio resta JavaScript, eseguito dal motore V8 su cui Node è costruito. E non è un web server perché non funziona come Apache: non c'è alcun file di configurazione e nulla si mette in ascolto finché non lo scrivete voi. Node permette di creare il proprio web server usando le librerie fornite — il modulo built-in http con createServer — e sopra di esso si costruiscono le web application. Tra i benefici c'è proprio questo: non serve eseguire un web server separato.

Confrontate il modello thread-based tradizionale con il modello event-driven di Node.js.

Nel modello thread-based un dispatcher thread assegna ogni richiesta entrante a un worker thread: in un dato momento il numero di thread è uguale al numero di richieste, e le operazioni di I/O sono bloccanti. Al crescere delle richieste si incontrano due limiti — l'overhead di context switching del sistema operativo e il limite massimo di thread — che limitano notevolmente la scalabilità. Nel modello event-driven le richieste vengono tradotte in eventi, ognuno associato a un event handler, e inserite in una coda; un event loop single-threaded, a ogni iterazione, controlla la coda ed esegue l'handler, mentre le operazioni di I/O sono delegate a thread aggiuntivi che le eseguono in maniera asincrona: i risultati non si aspettano — quando sono pronti vengono inseriti nell'event loop. Node non crea un thread per richiesta, resta sempre pronto a gestirne di nuove, consuma meno memoria e il server è più veloce.

Cosa succede se un handler esegue uno sleep in Node.js, e perché?

Si blocca l'intero sistema, non la sola richiesta. Tutte le richieste sono servite da un singolo thread con logica asincrona: se quel thread viene occupato — con uno sleep, un'attesa attiva o un calcolo lungo — l'event loop smette di prelevare eventi dalla coda e tutti i client restano in attesa, anche quelli le cui richieste non c'entrano nulla. È per questo che le slide avvertono di fare attenzione a come il singolo thread viene gestito, ed è la radice della regola "niente applicazioni CPU-intensive su Node.js".

Cosa sono le DIRT Application e perché Node.js è adatto a loro? Quando invece non va usato?

DIRT sta per Data-Intensive Real-Time Application: applicazioni data-intensive e real-time che eseguono su diversi device distribuiti. Node.js vi si è affermato come principale soluzione perché è capace di gestire un elevato numero di connessioni simultanee, quindi offre elevata scalabilità. Esempi: streaming multimediale, videogiochi online, chat, sistemi di monitoraggio con IoT e smart object. Non va usato per le applicazioni CPU-intensive (computazioni lunghe occupano l'unico thread) né come server di file statici, compito per cui esistono strumenti migliori come NGINX.

Spiegate la differenza tra exports e module.exports in CommonJS.

exports è un riferimento a module.exports, un'abbreviazione: finché si aggiungono proprietà (exports.a = "A") i due nomi puntano allo stesso oggetto ed è indifferente usare l'uno o l'altro. Ma riassegnando exports (exports = { c: "C" }) si perde il riferimento: da quel momento exports punta a un altro oggetto, e le sue proprietà non vengono esportate, perché ciò che viene esportato è sempre e solo module.exports. Nell'esempio delle slide, dopo la riassegnazione exports === module.exports vale false, il modulo esporta ancora { a, b } e c resta invisibile a chi importa.

Dove cerca Node.js un modulo importato con require?

Dipende da come è scritto l'argomento. Con il solo nome (require('lodash')) il modulo viene cercato nella cartella node_modules, dove il package manager salva i pacchetti installati; i nomi dei moduli built-in (fs, http, path, os, events, crypto…) sono risolti dalla piattaforma stessa. Per un modulo custom bisogna specificare il path relativo o assoluto (require('./my-module')), e il path è relativo al file che esegue require(), non alla directory di lancio.

Come si abilitano i moduli ECMAScript in Node.js, e quali differenze hanno rispetto a CommonJS?

I moduli ECMAScript — il formato standard ufficiale, con import ed export — non sono il default (che è CommonJS) e si abilitano in tre modi: "type": "module" nel package.json, l'estensione .mjs, oppure le opzioni --input-type=module / --experimental-default-type=module. Nel confronto: CommonJS è pienamente compatibile con Node ma non con i browser e non ha import asincrono; ECMAScript è compatibile con i browser, supportato da Node dalla v12+, e ha import sia dinamico sia asincrono — la funzione import() restituisce una promise. I moduli ES supportano inoltre il default export, importabile con il nome che si preferisce.

Cosa producono npm init e npm install, e cosa va messo sotto controllo di versione?

npm init crea il package.json, con i metadati e le configurazioni del progetto (nome, versione, entry point index.js, script, licenza, type). npm install <pacchetto> aggiunge la dipendenza al package.json, crea la cartella node_modules con i pacchetti installati e genera il package-lock.json, che blocca le versioni delle dipendenze installate. Sotto controllo di versione vanno il codice, package.json e package-lock.json; node_modules no, perché è interamente ricostruibile con npm install a partire dal lockfile.

A cosa serve npx e in che ordine cerca i comandi da eseguire?

npx (Node Package eXecute) esegue direttamente pacchetti Node.js senza installarli globalmente: permette di provare pacchetti al volo senza aggiungere dipendenze, lanciare strumenti CLI nella versione locale o in una versione specifica, ed evitare conflitti fra versioni. Il problema che risolve: gli eseguibili dei pacchetti finiscono in node_modules/.bin, e senza npx bisognerebbe usare il percorso completo o installare globalmente. L'ordine di ricerca è: prima le dipendenze locali del progetto, poi il download da internet con salvataggio in cache. Esempi: npx cowsay "Hello World", npx create-react-app my-app, npx -p [email protected] cowsay "...".

Confrontate Node.js, Deno e Bun.

Deno (2018, di Ryan Dahl, lo stesso autore di Node) è scritto in Rust, è sicuro di default — l'accesso a file, rete e ambiente richiede permessi espliciti come --allow-write — supporta TypeScript nativamente, importa moduli direttamente da URL e integra linter e formatter. Bun (2021) è scritto in Zig, punta sulle alte prestazioni (più veloce di Node e Deno in molti casi), supporta TypeScript nativamente, include package manager, bundler e test runner, ed è compatibile con i moduli npm. Node.js è scritto in C++, ha TypeScript solo sperimentale, nessuna sandbox e usa npm come gestore; resta però lo standard con l'ecosistema più vasto.

Che problema risolve un template engine, e come si usa EJS con il modulo http?

Il problema è l'interpolazione dei dati in una pagina HTML: senza template engine l'HTML va costruito come stringa nel codice JavaScript, mescolando logica e presentazione. Un template engine consente di usare template statici: in fase di esecuzione sostituisce le variabili con i valori attuali e trasforma il template nell'HTML da inviare al client. Con EJS: si installa con npm install ejs, si legge il template (per esempio views/page.ejs) con fs.readFile, e si chiama ejs.render(template, data) con un oggetto di dati. Nel template, <%= name %> stampa un valore e <% ... %> esegue codice senza stampare (cicli, condizioni). Rispetto al PHP classico, che mischia logica e vista nei blocchi <?php ?>, EJS favorisce la separazione: logica in JS, vista in EJS.

Perché si mette NGINX davanti a un server Node.js in produzione?

NGINX — web server gratuito e open source, usabile anche come reverse proxy, load balancer e HTTP cache — si affianca a Node.js come reverse proxy server: sta dietro il firewall nella rete privata e dirige le richieste dei client all'appropriato backend server. Le ragioni: semplificare la gestione di privilegi e porte; servire e cachare i file statici in modo più efficiente (Node non è adatto a quel compito); nascondere l'identità del server Node, con migliore gestione dei crash e mitigazione degli attacchi DoS; load balancing su più processi Node. Come recita il tweet citato dalle slide: se NGINX non sta davanti al vostro server Node, probabilmente state sbagliando qualcosa.