03 - node.js

05 - node.js

Node.js permette di eseguire codice JavaScript al di fuori del browser. Usa come runtime l'engine JavaScript V8 di Chrome.
Fornisce una serie di API per interagire con il sistema operativo:

  • file system
  • http
  • child processes
  • os
  • process
  • net
  • ecc

Node.js viene definito come asynchronous event-driven JavaScript runtime.

runtime significa che è un software che vive al di sopra del sistema operativo e che permette l'esecuzione del codice. A seconda del linguaggio utilizzato le caratteristiche di questo codice possono cambiare, ad esempio in Java e in C# sono necessari dei software appositi per la compilazione, e in fase di esecuzione è sufficiente avere la runtime installata nel sistema. In node.js il codice non è compilato ma interpretato, in questo caso la runtime contiene sia la parte di interprete che la parte di esecuzione.
Riassumendo possiamo vedere la runtime come quel layer che fa da tramite tra il nostro codice e il sistema operativo, occupandosi delle specifiche implementazioni necessarie.

asynchronous event-driven è la caratteristica che ha reso node.js una delle scelte preferite per lo sviluppo di server web. Le API per interagire con il sistema operativo sono implementate seguendo la logica di non-blocking I/O, questo significa che ogni volta che il nostro software esegue delle operazioni di input/output (operazioni su file, pacchetti rete, subprocesses, ecc) il programma non rimane bloccato in attesa del completamento delle operazioni.
La runtime gestisce queste operazioni associando un evento a ogni richiesta, quando l'operazione ha finito l'evento viene lanciato e il programma può continuare con le operazioni previste.

Vediamo qualche esempio:

import fs from 'fs';

console.log('requesting test1');
const content1 = fs.readFileSync('./test-files/test1.txt', {encoding: 'utf-8'});
console.log(content1);

console.log('requesting test2');
fs.readFile('./test-files/test2.txt', {encoding: 'utf-8'}, (err, content2) => {
  if (err) {
    console.error(err);
  } else {
    console.log(content2);
  }
})

In questo esempio il programma eseguirà prima la richiesta sincrona al file test1 e solo quando questa è terminata andrà avanti leggendo il file test2.

node index.js
requesting test1
test1 content
requesting test2
test2 content
import fs from 'fs';

console.log('requesting test2');
fs.readFile('./test-files/test2.txt', {encoding: 'utf-8'}, (err, content2) => {
  if (err) {
    console.error(err);
  } else {
    console.log(content2);
  }
})

console.log('requesting test1');
const content1 = fs.readFileSync('./test-files/test1.txt', {encoding: 'utf-8'});
console.log(content1);

Se invece eseguo la prima richiesta in modo asincrono possiamo vedere che il programma avvia la lettura del file e poi prosegue con il codice successivo andando a richiedere l'altro file.
Quando il contenuto di test2 è stato letto e il programma non sta eseguendo altre operazioni viene eseguito il codice del callback.

node index.js
requesting test2
requesting test1
test1 content
test2 content

La cosa a cui prestare attenzione è che il programma gira comunque in un singolo thread, quindi perché esegua il codice abbinato ad un evento è necessario che non stia eseguendo altre operazioni. Questo significa che ogni operazione sincrona di lunga durata blocca comunque anche le operazioni asincrone, quindi bisogna prestare attenzione.
Il codice sopra, gestito correttamente non dovrebbe andare ad utilizzare readFileSync:

import fs from 'fs';

console.log('requesting test1');
fs.readFile('./test-files/test1.txt', {encoding: 'utf-8'}, (err, content1) => {
  if (err) {
    console.error(err);
  } else {
    console.log(content1);
  }
});

console.log('requesting test2');
fs.readFile('./test-files/test2.txt', {encoding: 'utf-8'}, (err, content2) => {
  if (err) {
    console.error(err);
  } else {
    console.log(content2);
  }
})

In questo modo possiamo vedere che entrambe le letture vengono avviate una dopo l'altra, e il primo file che finisce è il primo ad essere stampato. (Non è quindi detto che il log di test1 avvenga prima di quello di test2).

node index.js
requesting test1
requesting test2
test2 content
test1 content

node index.js
requesting test1
requesting test2
test1 content
test2 content

Vediamo come questi principi influiscono particolarmente nello viluppo di webservers:

versione sincrona:

import http from 'http';
import bcrypt from 'bcrypt';

const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  let hashed = bcrypt.hash('secret', 10);
  res.end(hashed);
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

versione asincrona:

import http from 'http';
import bcrypt from 'bcrypt';

const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  bcrypt.hash('secret', 10)
  .then(hashed => res.end(hashed));
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});
artillery quick --count 100 --num 10 http://localhost:300

Gestire codice asincrono

Callback

La prima versione è quella che abbiamo visto con la lettura di un file:

import fs from 'fs';
// fs.readFile(path, options, callbackFn)

fs.readFile('test.txt', {encoding: 'utf-8'}, (err, content) => {
	// codice da eseguire
});

questa modalità utilizza i callback, sono funzioni che vengono passate al metodo chiamato e vengono eseguite quando l'operazioni è conclusa, sia con successo sia quando lancia errore.

Questa è la modalità originale con cui venivano gestite le operazioni asincrone, le API di node funzionano ancora con questa modalità in modo da mantenere la compatibilità. Questo significa che le altre tecniche che andremo a vedere sono in qualche modo tutte derivate da questa.

Sintassi

Per convenzione le funzioni di callback vengono passate come ultimo argomento della funzione chiamata e hanno due argomenti: il primo è l'eventuale errore lanciato, il secondo è il risultato.
Se si verifica un errore il primo argomento è popolato con un oggetto di tipo Error, il secondo generalmente è null.
In caso contrario il primo argomento è null e il secondo contiene il risultato dell'operazione.
Per questo motivo solitamente il codice doveva contenere dei controlli per verificare se l'esecuzione era andata o meno a buon fine.

fs.readFile('test2.txt', {encoding: 'utf-8'}, (err, content2) => {
  if (err) {
    console.error(err);
    return;
  }
  // codice da eseguir con il risultato
})

Creare funzioni

Per creare una funzione che incapsula del codice asincrono e chiama un callback a termine dell'esecuzione devo accettare la funzione di callback come argomento e richiamarla quando ho terminato le operazioni.

function getConfig(env, callback) {
	fs.readFile(`${env}.json`, {encoding: 'utf-8'}, (err, content) => {
		if (err) {
			callback(err, null);
			return;
		}
		callback(null, content);
	})
}

e in utilizzo:

getConfig('prod', (err, config) => {
	if (err) {
		console.error(err);
		return;
	}
	console.log(config);
})

Problemi

Questa modalità creava dei grossi problemi di leggibilità del codice e di confusione tra le variabili.
Immaginiamo questo scenario:

getUser(email, (err, user) => {
	if (err) {
		console.error(err);
		return;
	}
	getPreferences(user.id, (err1, pref) => {
		if (err1) {
			console.error(err1);
		return;
		}
		if (pref.newsLetter) {
			getLatestNewsletter((err2, content) => {
				if (err2) {
					console.error(err2);
					return;
				}
				sendMail(user.email, content, (err3) => {
					if (err3) {
						console.error(err3);
						return;
					}
					// altro eventuale codice
				})
			});
		}
	});
});

questa viene chiamata cascata di callback, e in applicazioni complesse era inevitabile trovarsi in questa situazione, nonostante tutti gli sforzi per organizzare il codice nel migliore dei modi.
I problemi che possiamo vedere nel codice sopra sono:

  • difficolta nel seguire visivamente le operazioni eseguite dal codice
  • gestione ripetitiva degli errori
  • problemi con i nomi delle variabili (err, err1, err2, ecc)

Promises

Una promise è un oggetto che rappresenta una operazione che non è ancora conclusa, ma lo sarà in futuro.

import { readFile } from 'node:fs/promises';
readFile('test1.txt', {encoding: 'utf-8'})
	.then(content => {
		console.log(content);
	})
	.catch(err => {
		console.error(err);
	})

in questo esempio il metodo readFile torna un oggetto (una promise) che ha i metodi .then e .catch. Questi metodi vengono chiamati rispettivamente passando le funzioni da eseguire quando è disponibile il risultato e quando si verifica un errore.
Esiste anche il metodo .finally che esegue la funzione quando la promise è conclusa, sia con risultato che con errore, e può essere quindi utilizzato per eseguire del codice che è valido in entrambi i casi.

Concatenazione

Nei tre metodi delle promise è possibile tornare un valore o una nuova promise, creando così quella che viene comunemente definita una catena (chain) di promises.
E' così possibile avere più .then consecutivi, ognuno dei quali elabora il risultato che gli arriva dall'operazione precedente e torna una nuova promise, che avrà il suo then, che a sua volta potrà tornare una nuova promise, e via dicendo.

Questo permette di risolvere parte dei problemi presenti nella modalità a callback visti sopra.

getUser(email)
.then(user => {
	return user.id
})
.then(id => {
	return getPreferences(id);
})
.then(pref => {
	if (pref.newsletter) {
		return getLatestNewsletter();
	}
	...
})
...
.catch(err => {
	console.error(err);
})
Info

Da notare che anche se nel primo passaggio torno un valore e non una promise ho comunque a disposizione di nuovo il metodo .then, questo è possibile perché i risultati vengono trasformati implicitamente in nuove promise.

Info

Da notare che nel codice sopra il metodo .catch viene chiamato sul risultato del metodo .then. Questo indica che, anche se non esplicitamente, ogni metodo delle promise torna a sua volta una promise, se è esplicitata con il return della funzione viene usata quella, altrimenti viene tornata una nuova promise con risultato vuoto.

import { readFile } from 'node:fs/promises';
readFile('test1.txt', {encoding: 'utf-8'})
    .then(content => {
		console.log(content);
	}) // viene creata comunque una nuova promise
	.catch(err => {
		console.error(err);
	})

Solitamente si mette il .catch dopo tutti i .then in modo da andare a gestire qualsiasi errore si possa verificare, ma non è obbligatorio, vediamo l'esempio seguente:

readConfig()
	.catch(err => {
		console.error(err);
		return { port: 3000 };
	})
	.then(config => config.port)
	.then(port => {
		// qualsiasi cosa debba fare con quel valore
	})
	

In questo caso vado a intercettare subito se c'è un errore nel recuperare le impostazioni (magari manca il file di configurazione) e torno un valore. Da quel momento l'errore è stato gestito, e quindi la catena va avanti con il nuovo risultato che ho passato. Se invece readConfig() va buon fine il catch non viene mai chiamato e continuo con le mie operazioni.

Creare funzioni

Si può creare una promise a partire da un codice a callback facendo:

function readFilePromise(path, options) {
	return new Promise((resolve, reject) => {
		readFile(path, options, (err, content) => {
			if (err) {
				reject(err);
				return;
			}
			resolve(content);
		})
	});
}

Si torna quindi direttamente una nuova promise. Il costruttore di Promise accetta una funzione che ha come argomenti resolve e reject, e mi devo occupare di chiamarli quando è disponibile il risultato e quando si verifica un errore. Possiamo vederla come: esegui questo codice, ma invece ti tornarmi il risultato con un return passalo alla funzione resolve, se si verifica un errore invece passalo alla funzione reject.
Da tenere presente è che resolve e reject sono argomenti di una funzione che stiamo scrivendo noi, anche se solitamente si usano questi nomi sono variabili che potrebbero avere qualsiasi nome. Quindi se nel codice scritto da altri le trovate con nomi diversi non cambia nulla, il primo argomento è sempre la funzione da chiamare quando c'è un risultato e la seconda quella per lanciare l'errore.

Nodejs mette già a disposizione le versioni delle sue API compatibili con le promise, ad esempio nel modulo node:fs i metodi usano i callback mentre nel modulo node:fs/promises tornano direttamente promises.

Problemi

Nell'esempio sopra però ho incontrato un problema: per mandare la newsletter avrei bisogno di user.email. Ma non esiste dentro quel then, esiste solo come variabile locale nel primo. Per risolvere possiamo:

getUser(email)
	.then(user => {
		return getPreferences(user.id)
				.then(pref => {
					if (pref.newsLetter) {
						return getLatestNewslletter()
							.then(content => {
								return sendMail(user.email, content)
							})
					}
				})
	})
	.catch(err => {
		console.error(err);
	})

Oppure tornando oggetti dai vari .then:

getUser(email)
	.then(user => {
		return getPreferences(user.id)
			.then(pref => ({user, pref}));
				
	})
	.then(({user, pref}) => {
		if (pref.newsLetter) {
			return getLatestNewslletter()
				.then(content => {
					return sendMail(user.email, content)
				})
		}
	})
	.catch(err => {
		console.error(err);
	})

In entrambi i casi ho ancora dei problemi, nel primo sono tornato ad avere codice indentato, nel secondo ho un problema con l'if, perché se pref.newsletter è impostato allora devo andare avanti, altrimenti no, quindi anche in questo caso non posso avere un .then nella catena principale ma devo gestirlo all'interno.

async/await

La modalità async/await nasce per risolvere i problemi visti sopra e permettere di richiamare funzioni asincrone come se fossero sincrone.

import { readFile } from 'node:fs/promises';
try {
	const content = await readFile('test1.txt');
	console.log(content);
} catch (err) {
	console.error(err);
}
async function getUser(email) {
	const user = await db.find(...);
	if (user && user.active) {
		return user;
	}
	throw new Error('User not found');
}

try {
	const user = await getUser('test@test.com');
	console.log(user);
} catch (err) {
	console.error(err);
}

Bisogna tenere presente che stiamo sempre lavorando con delle promise, è cambiata solo la sintassi. Lo si può vedere dal fatto che il metodo readFile del primo esempio è lo stesso che abbiamo usato in precedenza.
Ogni funzione definita come async torna una promise, viene fatto implicitamente sul valore tornato tramite il return.
E' quindi possibile usare await con qualsiasi funzione che torna una promise, e allo stesso tempo è possibile usare .then e .catch sul risultato delle funzioni definite async.

getUser('test@test.com')
	.then(user => {
		...
	})
	.catch(err => {
		console.error(err);
	})

Esempi

Vediamo come questa modalità ci permette di risolvere i problemi visti in precedenza:

try {
	const user = await getUser(email);
	const pref = await getPreferences(user.id);
	if (pref.newsLetter) {
		const content = await getLatestNewsletter();
		await sendMail(user.email, content);
	}
} catch (err) {
	console.error(err);
}

Come si può vedere il codice è molto più leggibile, le variabili sono tutte a disposizione e non ho problemi di indentazione.

Vediamo anche il caso dell'errore e valore di default visto in precedenza:

try {
	let config = null;
	try {
		config = await readConfig();
	} catch (configError) {
		config = { port: 3000 }
	}
	// qualsiasi cosa debba fare con config;
} catch (err)
	console.error(err);
}

L'unica cosa da tenere presente in questo caso è che le variabili vivono solo all'interno del blocco di codice in cui le ho definite. Se avessi dichiarato config dentro al try interno non l'avrei avuta a disposizione né dentro il catch né in seguito.