Ultimo aggiornamento

Introduzione a NodeJS


In questa introduzione a NodeJS (24.11.0 LTS) si passerà dall’illustrazione del core tecnologico che sta dietro le quinte di questo runtime JavaScript, al motivo per cui viene utilizzato e alle situazioni in cui dà il meglio di sé. Si arriverà infine allo stato dell’arte della tecnologia, analizzando quali sono i framework e le librerie più utilizzate, in grado di offrire solidità e supporto nello sviluppo di un applicativo.

NodeJS Logo
Logo di NodeJS

1. NodeJS

Nasce nel 2009 e si presenta subito come una soluzione per costruire in pochissimo tempo web server leggeri e scalabili grazie alla sua architettura event-driven e all’I/O non bloccante.

Alcuni dei vantaggi di questa tecnologia sono:

  • la possibilità di muoversi su stack tecnologici che peremettono di usare un solo linguaggio (Javascript) sia per il frontend che per il backend.
  • npm, forse il package manager più grande del mondo. Se avete un certo problema, è molto probabile che qualcuno lo abbia già risolto per voi con una liberia apposita. Su npm potete trovare di tutto, anche lo stesso framework che userete per costruire il vostro software.
  • è ottimo per la containerizzazione, semplificando di conseguenza il DevOps.
  • si avvia in pocchissimo tempo, quindi perfetto per le logiche cloud-native (ottimizzazione delle risorse)

Quando usare NodeJS

NodeJS è perfetto per servizi dove c’è la necessità di gestire molte chiamate e smistare molti messaggi, in pratica delle API o dei servizi real-time. Al contrario, non risponde bene nel caso di operazioni CPU-heavy (Machine Learning, calcoli matematici, elaborazione di immagini, audio o video, parsing di grandi file, etc.). Node è single-thread e non è pensato per molte operazioni computazionalmente pesanti.

1.1. Microservizi in NodeJS

Le caratteristiche di Node lo rendono ideale in particolare per lo sviluppo di microservizi. Ma che cos’è un microservizio? Prendendo la definizione in prestito dal sito di IBM

Microservices, or microservices architecture, is a cloud-native architectural approach in which a single application is composed of many loosely coupled and independently deployable smaller components or services.

IBM

Per farla breve, un microservizio è un pezzo di codice che svolge un compito (abbastanza) specifico. Esso gode delle potenzialità del cloud per essere distribuito e gestito autonomamente dagli altri micorservizi, che però possono comunicare tra loro in qualsiasi momento.

L’approccio a microservizi è nato per semplificare la gestione di grosse codebase monolitiche. Risolvendo la gestione tecnica dei progetti e lasciando maggiore libertà:

  • nella scrittura e mantenibilità del codice
  • nella scelta delle tecnologie da utilizzare
  • nell’approccio al lavoro in team

Team più contenuti possono così concentrarsi su uno specifico microservizio.

Ovviamente questa metodologia non risolve tutti i problemi e non è sempre la soluzione perfetta. Presenta grandi complicazioni nella corretta gestione e coordinazione fra i vari microservizi, passando dalla comunicazione tra di essi e la gestione dei dati al semplice preparare il proprio team per lavorare con questa metodologia.

1.2. Come funziona NodeJS?

NodeJS nasce e si basa sull’engine JavaScript V8 (software open-source alla base di Google Chrome) e la libreria libuv.

Citando il sito ufficiale:

A NodeJS app runs in a single process, without creating a new thread for every request. NodeJS provides a set of asynchronous I/O primitives in its standard library that prevent JavaScript code from blocking.

NodeJS

I pregi principali di questo ambiente sono la capacità di gestire alti numeri di richieste/eventi in ingresso in modo asincrono.

NodeJS si basa su un singolo thread, dove viene eseguito l’event loop. L’event loop gestisce una coda di eventi (richieste in arrivo, callback in attesa e operazioini completate) e delega le operazioni pesanti (a livello di CPU) alla libreria libuv (scritta in C) che le esegue tramite un thread pool.

Perciò, elaborazioni come: lettura di un DB, operazioni di rete o su file vengono eseguite al di fuori dell’event loop, che invece andrà a gestire la risposta del thread pool e il callback associato.

In questo modo l’event loop non viene mai messo sotto stress e non deve mai restare in attesa che un operazione lenta finisca, lavorando di conseguenza in modo asincrono. E nel caso non ci sia nessun evento in coda, l’event loop, quindi il main thread di NodeJS, non rimane attivo. libuv si mette in “sleep”, impostando il thread in attesa efficiente.

NodeJS Event Loop
Schema di funzionamento dell'Event Loop di NodeJS

Nel caso ci fosse la necessità di eseguire operazioni pesanti a livello di CPU è sempre possibile sfruttare un Worker thread a partire dal main thread di Node. Il worker thread possiede il suo event loop e il suo heap di memoria e può comunicare con il main thread attraverso dei messaggi. Ovviamente non è pensato e ottimizzato per gestire sempre operazioni pesanti, in tal caso è meglio affidarsi ad un altro linguaggio di programmazione.

In queste situazioni parliamo di approccio ibrido. La parte di API viene gestita su Node e i task più pesanti invece vengono gestiti esternamente. Cioè, su un altro microservizio con un altro stack tecnologico (Go, Rust, Python, Java) più appropriato. Semplicemente si fanno comunicare i due tramite un message borker.

Per comunicare con altri servizi si possono usare REST API, semplici e ampiamente supportate, gRPC, servizi interni ad alte performance, o Message Queue (MQ).

1.3. Confronto con altri runtime

Qui di seguito ho aggiunto alcune tabelle che confrontano vari aspetti dei più diffusi linguaggi di programmazione backend rispetto a NodeJS.

Dando un’occhiata a queste tabelle, si ribadisce e si da prova del fatto che NodeJS è una valida soluzione in tutti quei casi in cui vi è necessità di gestire tantissime connessioni con un basso consumo di risorse. In sintesi, NodeJS è perfetto quando la CPU non è il collo di bottiglia.

Le situazioni ideali per usare NodeJS possono essere le seguenti:

  • API Gateway e microservizi
  • Realtime e Websocket (chat, notifche push, dashboard live, giochi multiplayer)
  • Backend leggeri e scalabili
  • Proxy / Edge Layer
  • Automazione e scripting

Modello di esecuzione

LinguaggioModelloFilosofia
NodeJSEvent-driven, single-thread (libuv thread pool)Massimo throughput per I/O
PythonInterpreter con GILSemplicità e produttività
GoCompilato, goroutine concorrentiPrestazioni + concorrenza
JavaThread pool + JVM JITStabilità e maturità enterprise
RustCompilato, zero-cost abstractionsPerformance + sicurezza
C++Compilato, controllo manualeMassimo controllo e velocità

Prestazioni tipiche

ScenarioNodeJSGoJavaPythonRust
I/O-bound (API, DB, rete)🟢 Eccellente🟢 Eccellente🟡 Buono🟠 Discreto🟢 Ottimo
CPU-bound (calcoli intensivi)🔴 Scarso🟢 Ottimo🟢 Ottimo🟠 Medio🟢 Eccellente
Startup time🟢 Rapidissimo🟢 Veloce🔴 Lento🟢 Rapido🟢 Rapido
Memory usage🟢 Leggero🟢 Leggero🔴 Alto🟡 Medio🟢 Efficiente

Concorrenza

LinguaggioModelloFacilitàScalabilità
NodeJSEvent loop + async/await🟢 Facile🟢 Alta
GoGoroutine + channel🟢 Facile🟢 Alta
JavaThread pool + Executor🟠 Media🟢 Alta
PythonAsyncio (limitato dal GIL)🟠 Complessa🔴 Limitata
RustThread + ownership model🔴 Difficile🟢 Molto alta

Ecosistema e produttività

LinguaggioLibrerieSetupEcosistema web
NodeJS🟢 Enorme (npm)🟢 Facile🟢 Dominante
Go🟢 Grande🟢 Facile🟠 In crescita
Java🟢 Maturo🟠 Medio🟡 Enterprise
Python🟢 Vastissimo🟢 Facile🟠 Non ottimizzato per web
Rust🟡 In crescita🔴 Complesso🟠 Limitato

Quando usare cosa

LinguaggioQuando sceglierlo
NodeJSMicroservizi, API REST, real-time, I/O massivo
GoBackend concorrenti, infrastrutture, DevOps tools
JavaSistemi enterprise complessi
PythonData processing, scripting, ML
Rust / C++Engine, calcoli ad alte prestazioni, embedded

1.4. ECMAScript/ES6

NodeJS si basa su un runtime Javascript, ma non esegue lo stesso linguaggio Javascript che solitamente associamo ai browser. Ma una versione più sofisticata definita dallo standard ECMAScript.

(Nota bene che NodeJS supporta la maggior parte delle funzionalità di ECMAScript, ma non tutte. I mantainer di NodeJS aggiungono le funzionalità poco alla volta con nuove release. Quindi potresti vedere, molto spesso, alcune funzionalità nuove di ECMAScript non ancora supportate in NodeJS.)

Ad esempio si aggiungo funzionalità come async/await o le Promises. Primitive del linguaggio, introdotte per semplificare tutta la gestione delle callback per il codice asincrono.

// math.mjs
export const double = x => x * 2;

// app.mjs
import { double } from "./math.mjs";

try {
  const response = await fetch("https://api.example.com");
  
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  const data = await response.json();
  console.log(double(data.value));
} catch (err) {
  console.error("❌ Errore durante il fetch o l'elaborazione dei dati:", err);
}

O anche classi, nate sopra la primitiva prototype. Sono monche di funzionalità e sicurezza rispetto alle classi di linguaggi OOP. Ma danno comunque una grande mano per strutturare il proprio codice.

class Counter {
  #n = 0; // campo privato
  static from(n) { const c = new Counter(); c.#n = n; return c; } // metodo statico
  inc() { this.#n++; return this.#n; } // metodo dell'istanza
}

1.4.1. Le funzionalità aggiuntive

Alcune delle funzionalità aggiunte da ES6:

  • arrow function const a = () => { return "arrow function!" }
  • let e const a supporto di var
  • template literal con i backtick (```)
  • import e export per la gestione dei moduli
  • Nuove strutture dati (Map, Set, WeakMap e WeakSet)
  • Operatore spread per gli Iterables
  • for … of

Si tenga presente che durante gli anni sono uscite altre versioni di ECMAScript. In base alla versione di Node, queste nuove funzionalità sono state aggiunte una per una. Quindi questa è solo una lista di alcune delle molte funzionalità di ECMAScript che negli anni hanno permesso di avere finalmente un Javascript molto più solido e strutturato.

1.4.2. EventEmitter / Buffer / Stream

Risorse per comprendere meglio queste funzionalità:

1.4.2.1. Event Emitter

EventEmitter è una classe nativa di NodeJS ed è perfetta per gestire la nativa asincrona di questa tecnologia.

Questo metodo permette a funzioni nel codice di emettere un evento. E ad altri punti del codice di restare in ascolto ed eseguire della logica al verificarsi dello specifico evento. Qui sotto un esempio:

const { EventEmitter } = require('events');
const bus = new EventEmitter();

bus.on('ordine-creato', (ordine) => {
  console.log('Spedisci email a', ordine.email);
});

function creaOrdine(email) {
  const ordine = { id: Date.now(), email };
  bus.emit('ordine-creato', ordine);
}

creaOrdine('alice@example.com');

1.4.2.2. Buffer & Streams

Gli Stream sono pensati per gestire file di grandi dimensioni, senza dover caricare l’intero file in memoria RAM. Con gli Stream il file viene gestito in chunck di una dimensione massima (in pratica dei Buffer). I tipi Streams sono: Readable, Writable, Transform, Duplex, PassThrough.

Esempio di Stream:

const fs = require('fs');
const { Transform } = require('stream');

const csvToJsonl = new Transform({
  readableObjectMode: false,
  writableObjectMode: false,
  transform(chunk, enc, cb) {
    // (demo) split rozzo per righe; in reale usa un parser CSV
    const lines = chunk.toString('utf8').split('\n').filter(Boolean);
    for (const line of lines) {
      const [id, name] = line.split(',');
      this.push(JSON.stringify({ id, name }) + '\n');
    }
    cb();
  }
});

fs.createReadStream('users.csv')
  .pipe(csvToJsonl)
  .pipe(fs.createWriteStream('users.jsonl'));

I Buffer invece sono semplicemente un insieme di dati binari (come detto prima i chunck sono Buffer). E’ una classe comoda su NodeJS quando si deve lavorare con i file, soprattutto di grandi dimensioni o c’è bisogno di fare manipolazione binaria, modficando byte in modo preciso.

2. Architettura di un’applicazione NodeJS

NodeJS non forza ad avere alcun tipo di struttura specifica. Quindi, in base al framework scelto o alle proprie necessità, si può strutturare il proprio progetto come si preferisce.

Di seguito però viene proposta una possibilie struttura e vengono elencati tutti quei file che invece sono necessari per la costruzione di un progetto con Node.

2.1. Struttura base di un progetto

NodeJS Architecture Structure
Dipendenza tra i vari componenti di un progetto NodeJS

Di seguito una possibile struttura base di un progetto con NodeJS usando Typescript e Fastify:

booking-service/
├─ src/
│  ├─ index.ts           # Entrypoint
│  ├─ routes/            # Definizione API endpoints
│  │  └─ bookingRoutes.ts
│  ├─ controllers/       # Gestione logica request/response
│  │  └─ bookingController.ts
│  ├─ services/          # Logica di business
│  │  └─ bookingService.ts
│  ├─ models/            # Struttura dei dati
│  │  └─ bookingModel.ts
│  ├─ db/                # Connessione database
│  │  └─ connection.ts
│  └─ utils/             # Funzioni helper
├─ config/               # Configurazioni e environment variables
│  └─ default.ts
├─ package.json
├─ tsconfig.json
└─ .env

2.2. Gli elementi principali

File di configurazione per Typescript — tsconfig.json

Usando Typescript va intergrato questo file di configurazione così che NodeJS capisca come integrare e configurare Typescript secondo le proprie necessità.

Variabili d’ambiente — .env

Usando il file .env è possible registrare le variabili d’ambiente senza hardcodare chiavi private nel codice.

package.json

Il package.json è un file di configurazione dove vengono salvati tutti i meta-data del progetto e vengono definite le librerie (con le loro versioni) che il progetto utilizza.

index.ts

Ci sarà sempre un file dal quale parte l’esecuzione di NodeJS. Può essere chiamato con il nome che preferite. Nel caso di Typescript avrà l’estensione .ts, invece della solita .js.

Utilizzo di Typescript

L’utilizzo di Typescript è diventato imprescindibile nei progetti NodeJS. La sua integrazione non comporta grandi complessità e permette grandi vantaggi. In pratica l’utilizzo di Typescript nei progetti NodeJS permette di introdurre la tipizzazione statica per portare più solidità.

I principali framework

  • express.js: il più diffuso e utilizzato (più lento rispetto agli altri e meno aggiornato)
  • fastify: veloce da sviluppare e il migliori per le performance (più giovane rispetto ad express e una comunità e utilizzo più contenuto rispetto al primo)
  • NestJS: la soluzione più strutturata dei 3, per chi vuole costruire un microservizio con una struttura architetturale solida che segue principi di altri linguaggi OOP come Java. Sotto NestJS possono girare expressjs o fastify.

3. Demo time

Qui il link alla repo Github di test per vedere una demo di un server con NodeJS e tutta la parte architetturale in pratica.

giovannitranquillini / dailymessage-backend

Demo di un server NodeJS con Fastify, Prisma e architettura a microservizi

4. Database e Data layer

Come ormai negli ultimi capita spesso, la necessità di memorizzare i dati del proprio sistema passa dalla scelta di un DB SQL relazionale o NoSQL.

Ma qualsiasi sia la vostra scelta finale, NodeJS possiede una vasta possibilità di integrazioni per gestire al meglio ogni tipo di database. Passando da i driver nativi tramite le librerie npm degli specifici database selezionati a delle soluzioni ORM (ad esempio Prisma (SQL dbs) o Mongoose (specializzato per MongoDB) che permettono di astrarre le complessità di gestione del DB tramite una sintassi preconfezionata.

Di seguito mettiamo a confronto due degli ORM più utilizzati su NodeJS, Prisma e Mongoose.

4.1. Gli ORM di NodeJS: Prisma vs Mongoose

Prisma ORM versatile, molto ben strutturato. Supporta praticamente tutti i DB sul mercato. Da SQL a noSQL.

// install package in the termail using the command pnpm
pnpm add prisma @prisma/client
pnpm prisma init

-----------------------------------------------------------------------------------------

// prisma/schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model Booking {
  id        Int      @id @default(autoincrement())
  guestName String
  room      String
  checkIn   DateTime
  checkOut  DateTime
  createdAt DateTime @default(now())
}

-----------------------------------------------------------------------------------------

// src/db/client.ts
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();

-----------------------------------------------------------------------------------------

// src/services/bookingService.ts
import { prisma } from '../db/client';

export async function createBooking(data: {
  guestName: string;
  room: string;
  checkIn: string;
  checkOut: string;
}) {
  return await prisma.booking.create({ data });
}

export async function getAllBookings() {
  return await prisma.booking.findMany();
}

Mongoose ORM specializzato per MongoDB. E’ uno dei primi che usci per NodeJS ed è anche il più completo.

// bash

pnpm add mongoose

-----------------------------------------------------------------------------------------

// src/models/bookingModel.ts
import { Schema, model } from 'mongoose';

const bookingSchema = new Schema({
  guestName: { type: String, required: true },
  room: { type: String, required: true },
  checkIn: { type: Date, required: true },
  checkOut: { type: Date, required: true }
});

export const Booking = model('Booking', bookingSchema);

-----------------------------------------------------------------------------------------

// src/services/bookingService.ts
import { Booking } from '../models/bookingModel';

export async function createBooking(data: any) {
  const booking = new Booking(data);
  return await booking.save();
}

export async function getAllBookings() {
  return await Booking.find();
}

4.2. Caching

Per quanto riguarda il caching per ottimizzare il microservizio è possibile utilizzare la cache in-memory gestita da NodeJS. Però nel caso di più istanze dello stesso microservizio o se si vuole mantenere memoria del contenuto della cache in caso di riavvio di NodeJS è meglio affidarsi ad un servizio ad-hoc (e.g. Redis).

Ricordate che la cache in-memory dipende dalla quantità di RAM della macchina su cui gira il microservizio e che soprà un certo tot di dati salvati in memoria, si potrebbero avere dei problemi con il garbage collector.

5. Best Practices Tools e Scalabilità

Monitoring tools: pino e Grafana

Sicurezza e error handling:

  • middleware personalizzati (gestione errori)
  • zod (validazioni input)
  • jwt, passport.js, Auth0 (authentication)
  • helmet, cors (CORS e headers)

Testing & Code Quality:

  • Jest, Vitest, Mocha (unit test)
  • ESLint, Prettier, Husky (Linting / Code quality)

Deployment e Scalabilità

  • pm2 o Docker

Documentazione API: OpenAPI/Swagger

6. React e frontend integration (accenno)

Frontend e backend devono comunicare, non dipendere l’uno dall’altro.

Negli ultimi anni, anche grazie a Node, sono diventati molto diffusi approcci di costruzione del frontend con framework come ReactJS. React permette la gestione della parte frontend di un progetto su una porta separata. E’ possibile concentrarsi solo sulla riutilizzabilità del codice frontend e consumare i dati dal backend tramite delle API. Tutto ciò favorisce indipendenza, versioning e scalabilità.

6.1. Basi di React

React è una libreria JavaScript per costruire interfacce utente basate su componenti. Ogni componente rappresenta una parte autonoma dell’interfaccia (es. lista prenotazioni, form, bottone).

I React Hooks come useState e useEffect servono per:

  • Gestire lo stato locale (useState → variabili che cambiano nel tempo)
  • Gestire effetti collaterali (useEffect → chiamate API, aggiornamenti, timers)

In pratica: Il componente React mostra i dati ricevuti dal backend e li aggiorna dinamicamente quando cambiano.

6.2. Javascript API

Nel frontend usiamo le API del browser per comunicare col backend, soprattutto:

  • fetch() → per fare richieste HTTP
  • Promise → per gestire operazioni asincrone
  • async/await → per scrivere codice asincrono in modo leggibile
  • JSON.parse() / JSON.stringify() → per convertire i dati da/verso il formato JSON

Ad esempio:

const res = await fetch("/api/bookings");
const data = await res.json();

7. Risorse aggiuntive