Pompei Tech
Guide · NODEJS

I/O e Sistema Operativo

I/O e Sistema Operativo

Possiamo pensare a Node.js come C/C++ vestito da JavaScript. Proprio come il linguaggio C ed altri linguaggi di basso livello, Node.js è capace di interagire con il sistema operativo ad un livello base: input e output. In questo capitolo daremo uno sguardo alcune API core di Node.js, insieme ad alcune utility di terze parti che ci consentono di intreragire con l'I/O standard, il file system e lo stack di rete.

Standard Input

La capacità di interagire con l'input e l'output del terminale è nota come Standard I/O o STDIO. STDIN (standard input) si riferisce ad un flusso di input che un programma può utilizzare per leggere l'input da un prompt dei comandi o da un terminale. Allo stesso modo, STDOUT (standard output) si riferisce al flusso utilizzato per scrivere l'output. STDERR (errore standard) è un flusso separato per STDOUT che è in genere riservato per l'emissione di errori e dati di diagnostica. In Node.js l'oggetto globale process espone questi tre flussi:

  • process.stdin: Uno stream leggibile per il processo di input.
  • process.stdout: Uno stream scrivibile per il processo di output
  • process.stderr: Uno stream di output per gli errori di processo.

Per interfacciarsi con process.stdin è necessario un input. Per esempio inziamo creando uno script che legge dallo STDIN e scrive su STDOUT e STDERR tutto ciò che riceve in input ma in maiuscolo. Prendiamo in considerazione l'esempio seguente:

js
process.stdin.on('data', data => {
  process.stderr.write(`STDERR: ${data.toString().toUpperCase()}`)
  process.stdout.write(`STDOUT: ${data.toString().toUpperCase()}`)
})

Se il nostro codice risiede nel file example.js, possiamo testare lo script possiamo procedere nel seguente modo:

text
$ echo -e "hello, node.js in pillole" | node example.js 
STDERR: HELLO, NODE.JS IN PILLOLE
STDOUT: HELLO, NODE.JS IN PILLOLE

Un'alternativa è quella di mandare in esecuzione lo script, ogni riga in input che scriverete sul terminale verrà convertita in maiuscolo.

Sappiamo che gli streams in Node.js ereditano da EventEmitter ed emettono eventi 'data' per ogni blocco di dati che ricevono. Quando si è in modalità interattiva (ovvero, durante l'immissione tramite tastiera), ogni blocco di dati è determinato da una nuova riga. Quando si inviano i dati al processo, ogni blocco di dati è determinato dal consumo di memoria massimo consentito per lo stream.

Restiamo in ascolto dell'evento 'data', che fornisce un oggetto Buffer (chiamato anche dati) che contiene una rappresentazione binaria dell'input. Scriviamo prima su STDERR (errore standard), semplicemente passando una stringa, process.stderr.write. La stringa contiene l'oggetto Buffer, che viene automaticamente convertito in una rappresentazione di stringa quando interpolato in (o concatenato con) un'altra stringa.

Quindi scriviamo su STDOUT e STDERR, usando process.stdout.write. Gli oggetti Buffer hanno un metodo toString e a seguire toUpperCase per convertire l'input da dati binari non elaborati in una rappresentazione stringa maiuscolo.

Piping

Come menzionato in precedenza, i canali di I/O standard disponibili sull'oggetto globale process sono streams, quindi diamo un'occhiata a come questi avvolgono i canali I/O standard. Vediamo ora come effettuare il piping da process.stdin a process.stdout passando per uno stream Transform che appunto trasforma tutte lettere in maiuscolo: s

js
const { Transform } = require('stream')
const createUppercaseStream = () => {
  return new Transform({
    transform (chunk, enc, next) {
      const uppercased = chunk.toString().toUpperCase()
      next(null, uppercased)
    }
  })
}

const uppercase = createUppercaseStream()
process.stdin.pipe(uppercase).pipe(process.stdout)

Notate che non abbiamo utilizzato una pipeline, ma invece il metodo pipe. Gli streams process.stdin, process.stdout e process.stderr sono unici in quanto sono stream infiniti, non si verificano errori o si chiudono. Vale a dire, se uno di questi flussi dovesse terminare, il processo si arresterebbe in modo anomalo o terminerebbe perché il processo è terminato. Potremmo usare il metodo stream.finished per verificare che lo stream non si chiuda, ma nel nostro caso non abbiamo aggiunto la gestione degli errori allo stream createUppercaseStream perché qualsiasi problema che si verifica in questo scenario dovrebbe causare l'arresto anomalo del processo.

Terminare un processo

Esistono diversi modi per terminare un processo Node.js. Per esempio se stiamo eseguendo un programma in una console per terminare un processo basta usare la combinazione CTRL+C per terminare l'esecuzione del processo. Ma ora vedremo come farlo programmaticamente. Se invece un processo Node.js non ha più nulla da eseguire esce da solo. Per esempio questo frammento di codice:

js
console.log('Exit after this console.log')

Questo semplice script esegue l'unica istruzione, il console.log, e termina la sua esecuzione. Alcuni programmi, però, hanno handle attivi. un handle attivo è un riferimento ad una specifica risorsa che mantiene aperto il processo. Per esempio setTimeout mantiene un handle attivo che impedisce al programma in esecuzione di uscire:

js
setTimeout(() => {
  console.log('This is and infinite Node.js process')
}, 1000)

Se eseguite questo script verrà stampato il messaggio "This is and infinite Node.js process" all'infinito, ogni secondo. Per terminare il processo, dobbiamo usare la combinazione CTRL+C. Il modulo core process fornisce un pratico metodo che consente di uscire a livello di codice da un programma Node.js: process.exit().

js
setTimeout(() => {
  console.log('This is and infinite Node.js process')
}, 1000)

setTimeout(() => {
  console.log('Exit after this console.log')
  process.exit()
}, 1500)

Quando Node.js esegue process.exit(), il processo viene terminato. Ciò significa che qualsiasi callback in sospeso, qualsiasi richiesta di rete ancora in fase di invio, qualsiasi accesso al filesystem o processi che scrivono su stdout o stderr, verranno terminati immediatamente. È possibile però passare alla funzione exit un codice di uscita. I codici di uscita sono un argomento ampio e possono avere significati diversi su piattaforme diverse.

Per esempio possiamo verificare il codice di uscita dello script eseguito in precedenza nel seguente modo:

text
$ node setInfiniteTimeout.js
This is and infinite Node.js process
Exit after this console.log
$ echo $?
0

Possiamo passare un codice di uscita diverso a process.exit. Qualsiasi codice diverso da zero indica un errore e per indicare un errore generale possiamo utilizzare un codice di uscita di 1 (tecnicamente questo significa "Funzione non corretta" su Windows, ma è opinione comune che 1 significhi un errore generale). Modifichiamo la nostra chiamata process.exit per passarvi 1:

js
setTimeout(() => {
  console.log('This is and infinite Node.js process')
}, 1000)

setTimeout(() => {
  console.log('Exit after this console.log')
  process.exit(1)
}, 1500)

Ora, se controlliamo il codice di uscita dopo aver eseguito il processo, dovrebbe essere 1:

text
$ node setInfiniteTimeout.js
This is and infinite Node.js process
Exit after this console.log
$ echo $?
1

Il codice di uscita può anche essere impostato indipendentemente dall'assegnazione di process.exitCode:

js
setTimeout(() => {
  console.log('This is and infinite Node.js process')
}, 1000)

setTimeout(() => {
  console.log('Exit after this console.log')
  process.exitCode = 1
}, 1500)

L'evento 'exit' può anche essere utilizzato per rilevare quando un processo si sta chiudendo ed eseguire eventuali azioni finali, tuttavia non è possibile eseguire operazioni asincrone nell'handler perché il processo è in uscita:

js
setTimeout(() => {
  console.log('This is and infinite Node.js process')
}, 1000)

setTimeout(() => {
  console.log('Exit after this console.log')
  process.exit(1)
}, 1500)

process.on('exit', code => {
  console.log('Bye bye this is my exit code', code)
})

Questo si traduce nel seguente output:

text
$ node setInfiniteTimeout.js
This is and infinite Node.js process
Exit after this console.log
Bye bye this is my exit code 1

:::tip L'unico codice di uscita che ha un significato uniforme su tutte le piattaforme è 0. Un codice di uscita 0 indica che il processo è stato eseguito correttamente. Su Linux e macOS (o più specificamente, Bash, Zsh, Sh e altre shell * nix) possiamo verificarlo con il comando echo $? che stampa una variabile speciale chiamata $?. Su un terminale cmd.exe di Windows possiamo usare invece echo %ErrorLevel% o in PowerShell il comando è $LastExitCode. Nei seguenti esempi, useremo echo $? ma sostituire con il comando pertinente come appropriato. Potete comunque dare uno sguardo alla documentazione di Node.js per approfondire i codici di uscita di un processo Node.js. :::

Inviare segnali

Molti sviluppatori utilizzano Node.js per creare API HTTP. Per esempio:

js
const Fastify = require('fastify')
const app = Fastify({ logger: true })

app.get('/', async (request, reply) => {
  return { hello: 'world' }
})

const start = async () => {
  try {
    await app.listen(3000)
  } catch (err) {
    app.log.error(err)
    process.exit(1)
  }
}

start()

:::tip Fastify è un framework altamente focalizzato sulla fornitura della migliore esperienza di sviluppo con il minimo sovraccarico e una potente architettura di plugin. È ispirato da Hapi ed Express ed è uno dei framework web più veloci attualmente in circolazione. :::

Questo programma non finirà mai. Se invochiamo process.exit(), qualsiasi richiesta attualmente in attesa o in esecuzione verrà interrotta. In questo caso è necessario inviare al comando un segnale SIGTERM e gestirlo con un handler. Per esempio:

js
const Fastify = require('fastify')
const app = Fastify({
  logger: true
})

app.get('/', async (request, reply) => {
  return { hello: 'world' }
})

const start = async () => {
  try {
    await app.listen(3000)
  } catch (err) {
    app.log.error(err)
    process.exit(1)
  }
}

process.on('SIGTERM', () => {
  app.log.info('Shutting down the server')
  app.close(() => {
    console.log('Server down!')
  })
})

start()

Se proviamo ad eseguire questo programma il processo rimane in attesa di una richiesta HTTP e non termina mai:

text
node server.js
{"level":30,"time":1625562244001,"pid":4839,"hostname":"MacBook-Pro-Davide","msg":"Server listening at http://127.0.0.1:3000"}

Se proviamo, da un altro programma o da un'altra shell, ad inviare al processo creatosi nel momento in cui abbiamo lanciato l'applicazione il segnale SIGTERM (node -e "process.kill(<PID>, 'SIGTERM')"), verrà eseguito l'handler associato a process.on('SIGNTERM'), che esegue lo shutdown del server. In questo caso prima di terminare, il processo stamperà le seguenti righe:

text
{"level":30,"time":1625562259442,"pid":4839,"hostname":"MacBook-Pro-di-Davide.local","msg":"Shutting down the server"}
Server down!

Cosa sono i segnali? I segnali sono un sistema di intercomunicazione POSIX: una notifica inviata a un processo per notificarlo di un evento verificatosi.

Informazioni di processo

Oltre che a stdin, stdout e stderr l'oggetto process contiene anche una serie di informazioni e sul processo attualmente in esecuzione come:

  • La directory di lavoro corrente del processo in esecuzione.
  • La piattaforma su cui è in esecuzione
  • L'identificativo del processo
  • Le variabili d'ambiente che il processo sta utilizzando

Ci sono anche altre cose da approfondire sull'oggetto process, basta dare uno sguardo alla documentazione ufficiale per una panoramica più completa e approfondita. Ora diamo un'occhiata ai primi tre punti con un piccolo esempio:

js
console.log('Directory', process.cwd())
console.log('Platform', process.platform)
console.log('ID', process.pid)

Questo frammento di codice produrrà qualcosa tipo:

text
Current Directory /Users/davidedantonio/examples
Process Platform darwin
Process ID 8609

La directory di lavoro corrente è qualunque cartella in cui è stato eseguito il processo. Il comando process.chdir può anche cambiare la directory di lavoro corrente, nel qual caso process.cwd() produrrà la nuova directory.

La piattaforma del processo indica il sistema operativo host. A seconda del sistema può essere uno tra:

  • 'aix' – IBM AIX
  • 'darwin' – macOS
  • 'freebsd' – FreeBSD
  • 'linux' – Linux
  • 'openbsd' – OpenBSD
  • 'sunos' – Solaris / Illumos / SmartOS
  • 'win32' – Windows
  • 'android' – Android, experimental

Come vedremo in una prossima sezione, il modulo os ha anche una funzione platform (piuttosto che una proprietà) che restituirà gli stessi valori per gli stessi sistemi esistenti su process.platform.

Per ottenere le variabili d'ambiente possiamo usare process.env:

js
console.log(process.env)
text
{
  USER: 'davidedantonio',
  __CFBundleIdentifier: 'com.microsoft.VSCode',
  COMMAND_MODE: 'unix2003',
  LOGNAME: 'davidedantonio',
  PATH: '/Users/davidedantonio/.nvm/versions/node/v14.16.1/bin:/Users/davidedantonio/.nvm/versions/node/v14.16.1/bin:/Users/davidedantonio/.nvm/versions/node/v10.20.1/bin:/Users/davidedantonio/.linuxbrew/bin:/Users/davidedantonio/.linuxbrew/sbin:/bin:/Users/davidedantonio/.nvm/versions/node/v10.20.1/bin:/Library/Frameworks/Python.framework/Versions/2.7/bin:/opt/local/bin:/opt/local/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin:/Users/davidedantonio/.nvm/versions/node/v10.20.1/bin:/Users/davidedantonio/.linuxbrew/bin:/Users/davidedantonio/.linuxbrew/sbin:/Library/Frameworks/Python.framework/Versions/2.7/bin:/opt/local/bin:/opt/local/sbin',
  SSH_AUTH_SOCK: '/private/tmp/com.apple.launchd.k0zhjWEM54/Listeners',
  SHELL: '/bin/zsh',
  HOME: '/Users/davidedantonio',
  __CF_USER_TEXT_ENCODING: '0x1F5:0x0:0x4',
  TMPDIR: '/var/folders/fk/x7hf43tj65sdq9v2mpqlfmt00000gn/T/',
  XPC_SERVICE_NAME: '0',
  XPC_FLAGS: '0x0',
  ORIGINAL_XDG_CURRENT_DESKTOP: 'undefined',
  SHLVL: '1',
  PWD: '/Users/davidedantonio/Projects/nodejs-book/sourceCode/ch7',
  OLDPWD: '/Users/davidedantonio/Projects/nodejs-book/sourceCode/ch7/serverexample',
  DISPLAY: ':0',
  ZSH: '/Users/davidedantonio/.oh-my-zsh',
  PAGER: 'less',
  LESS: '-R',
  LSCOLORS: 'Gxfxcxdxbxegedabagacad',
  NVM_DIR: '/Users/davidedantonio/.nvm',
  NVM_CD_FLAGS: '-q',
  NVM_BIN: '/Users/davidedantonio/.nvm/versions/node/v14.16.1/bin',
  NVM_INC: '/Users/davidedantonio/.nvm/versions/node/v14.16.1/include/node',
  TERM_PROGRAM: 'vscode',
  TERM_PROGRAM_VERSION: '1.57.1',
  LANG: 'it_IT.UTF-8',
  COLORTERM: 'truecolor',
  VSCODE_GIT_IPC_HANDLE: '/var/folders/fk/x7hf43tj65sdq9v2mpqlfmt00000gn/T/vscode-git-7257cbe02f.sock',
  GIT_ASKPASS: '/Applications/Visual Studio Code.app/Contents/Resources/app/extensions/git/dist/askpass.sh',
  VSCODE_GIT_ASKPASS_NODE: '/Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper (Renderer).app/Contents/MacOS/Code Helper (Renderer)',
  VSCODE_GIT_ASKPASS_MAIN: '/Applications/Visual Studio Code.app/Contents/Resources/app/extensions/git/dist/askpass-main.js',
  TERM: 'xterm-256color',
  _: '/Users/davidedantonio/.nvm/versions/node/v14.16.1/bin/node'
}

Le variabili d'ambiente sono coppie chiave-valore, quando si accede a process.env, l'ambiente host viene interrogato dinamicamente e un oggetto viene costruito dalle coppie chiave-valore. Ciò significa che process.env funziona più come una funzione, è un getter. Quando viene utilizzata per impostare le variabili di ambiente, ad esempio process.env.FOO='my env var' la variabile di ambiente è impostata solo per il processo, non perde nel sistema operativo host.

Notare che process.env.PWD contiene anche la directory di lavoro corrente quando il processo viene eseguito, proprio come restituisce process.cwd(). Tuttavia, se il processo cambia la sua directory con process.chdir, process.cwd() restituirà la nuova directory mentre process.env.PWD continua a memorizzare la directory da cui il processo è stato inizialmente eseguito.

dotenv

dotenv è un modulo npm leggero che carica automaticamente le variabili di ambiente da un file .env nell'oggetto process.env. Per utilizzarlo basta installarlo come dipendenza con il comando npm install --save dotenv. Quindi nella nostra applicazione, possiamo caricarele nostre variabili d'ambiente nel seguente modo: require('dotenv').config(). Una volta installato e configurato dotenv, basta creare un file chiamato .env nella root del vostro progetto. Questo file conterrà tutte le variabili d'ambiente, scritte nel formato CHIAVE=valore. Ad esempio, potresti impostare una variabile di porta su 3000 in questo modo: PORT=3000. Per esempio immaginiamo di avere il seguente file .env nella directory dove risiede il nostro web server creato precedentemente:

text
PORT=3003
LOGGER=true

Ora, dopo aver installato dotenv, modifichiamo il nostro server nel seguente modo:

js
const Fastify = require('fastify')
require('dotenv').config()

const port = process.env.PORT || 3000
const logger = process.env.LOGGER || false

const app = Fastify({
  logger: logger
})

app.get('/', async (request, reply) => {
  return { hello: 'world' }
})

const start = async () => {
  try {
    
    await app.listen(port)
  } catch (err) {
    app.log.error(err)
    process.exit(1)
  }
}

start()

Se tutto è andato come spero, digitando dalla linea di comando node server.js il server web si metterà in ascolto sulla porta 3003:

text
$ node server.js
{"level":30,"time":1625578405354,"pid":8979,"hostname":"MacBook-Pro-di-Davide","msg":"Server listening at http://127.0.0.1:3003"}

Le variabili d'ambiente rendono il nostro codice più gestibile e più sicuro. Sono facili da configurare con dotenv e semplici da usare in Node.js.

:::tip I file di environment contengono spesso informazioni di accesso a base dati, chiavi, o token di autenticazione per servizi di terze parti. Fate molta attenzione a non utilizzare un sistema di versioning (Git o Subversion) per questo genere di file. Potrebbe compromettere la sicurezza delle vostre applicazioni. :::

Statistiche di un processo

L'oggetto process ha metodi che ci consentono di interrogare l'utilizzo delle risorse, esamineremo le funzioni process.uptime(), process.cpuUsage e process.memoryUsage. Diamo un'occhiata a process.uptime:

js
console.log('Process Uptime', process.uptime())
setTimeout(() => {
  console.log('Process Uptime', process.uptime())
}, 1000)

Questo produce il seguente output:

text
$ node processUptime.js 
Process Uptime 0.126494852
Process Uptime 1.141927972

Il tempo di attività del processo è la quantità di secondi (con 9 cifre decimali) per i quali il processo è stato eseguito. Questo non deve essere confuso con il tempo di attività della macchina host, che vedremo in una sezione futura può essere determinato utilizzando il modulo os.

La funzione process.cpuUsage restituisce un oggetto con due proprietà: user e system. La proprietà user rappresenta il tempo impiegato dal processo Node utilizzando la CPU. La proprietà system rappresenta il tempo che il kernel ha trascorso utilizzando la CPU a causa dell'attività innescata dal processo. Entrambe le proprietà contengono misurazioni in microsecondi (un milionesimo di secondo):

js
const outputStats = () => {
  const uptime = process.uptime()
  const { user, system } = process.cpuUsage()
  console.log(uptime, user, system, (user + system)/1000000)
}

outputStats()

setTimeout(() => {
  outputStats()
  const now = Date.now()
  
  while (Date.now() - now < 5000) {}
  outputStats()
}, 500)

In questo esempio, la funzione outputStats stampa il tempo di attività del processo in secondi, l'utilizzo della CPU dell'utente in microsecondi, l'utilizzo della CPU del sistema in microsecondi e l'utilizzo totale della CPU in secondi in modo da poterlo confrontare con il tempo di attività. Stampiamo le statistiche quando inizia il processo. Dopo 500 millisecondi stampiamo di nuovo le statistiche. Quindi facciamo lavorare la CPU per circa cinque secondi e stampiamo le statistiche un'ultima volta.

Vediamo l'output:

text
$ node processStats.js
0.043523017 42422 10298 0.05272
0.559501186 50804 12361 0.063165
5.565070734 4917566 73559 4.991125

Possiamo vedere dall'output che l'utilizzo della CPU aumenta in modo significativo alla terza chiamata a outputStats. Questo perché prima della terza chiamata la funzione Date.now viene chiamata ripetutamente in un ciclo while fino a quando non sono trascorsi 5000 millisecondi.

Sulla seconda riga, possiamo aumentare il tempo di attività nella prima colonna da 0,026 a 0,536 perché setTimeout è di 500 millisecondi (o 0,5 secondi). I 10 millisecondi extra saranno il tempo di esecuzione aggiuntivo per l'output delle statistiche e l'impostazione del timeout. Tuttavia, sulla stessa riga l'utilizzo della CPU aumenta solo di 0,006 secondi. Questo perché il processo era inattivo durante quel periodo, mentre la terza riga registra che il processo stava facendo molto lavoro. Poco più di 5 secondi, come previsto.

Un'altra osservazione che possiamo fare qui è che sulla prima riga l'utilizzo totale della CPU è maggiore del tempo di attività. Questo perché Node può utilizzare più di un core della CPU, il che può moltiplicare il tempo della CPU utilizzato da tutti i core utilizzati durante quel periodo.

Infine, diamo un'occhiata a process.memoryUsage:

js
const stats = [process.memoryUsage()]

let iterations = 5

while (iterations--) {
  const arr = []
  let i = 10000
  
  while (i--) {
    arr.push({[Math.random()]: Math.random()})
  }
  stats.push(process.memoryUsage())
}

console.table(stats)

La funzione console.table in questo esempio prende un array di oggetti che hanno le stesse chiavi (rss, heapTotal, heapUsed ed external) e li stampa come una tabella. Assembliamo l'array delle statistiche aggiungendo il risultato process.memoryUsage() durante l'inizializzazione e quindi altre cinque volte dopo aver creato 10.000 oggetti ogni volta. Questo produrrà qualcosa di simile al seguente:

text
$ node memoryStats.js 
┌─────────┬──────────┬───────────┬──────────┬──────────┬──────────────┐
│ (index) │   rss    │ heapTotal │ heapUsed │ external │ arrayBuffers │
├─────────┼──────────┼───────────┼──────────┼──────────┼──────────────┤
│    0    │ 19443712 │  4468736  │ 2635664  │  828484  │     9898     │
│    1    │ 23232512 │  9195520  │ 6172944  │  828524  │     9898     │
│    2    │ 31133696 │ 17113088  │ 9073120  │  828524  │     9898     │
│    3    │ 33886208 │ 17899520  │ 12375864 │  828524  │     9898     │
│    4    │ 38383616 │ 20787200  │ 14690248 │  828524  │     9898     │
│    5    │ 41574400 │ 22884352  │ 16143024 │  828524  │     9898     │
└─────────┴──────────┴───────────┴──────────┴──────────┴──────────────┘

Tutti i numeri emessi da process.memoryUsage sono in byte. Possiamo vedere ciascuna delle categorie di memoria crescere in ogni iterazione, eccetto la memoria esterna che cresce solo all'indice 1. La metrica di external si riferisce all'utilizzo della memoria da parte del livello C, quindi una volta che il motore JavaScript è stato completamente inizializzato in questo caso non c'è più memoria requisiti da quel livello nel nostro caso. La metrica heapTotal si riferisce alla memoria totale allocata per un processo. Questo è il processo che riserva quella quantità di memoria e può aumentare o ridurre quello spazio riservato nel tempo in base a come si comporta il processo. La memoria del processo può essere suddivisa in RAM e spazio di swap. Quindi la metrica rss, che sta per Resident Set Size, è la quantità di RAM utilizzata per il processo, mentre la metrica heapUsed è la quantità totale di memoria utilizzata sia nella RAM che nello spazio di swap. Man mano che mettiamo sempre più sotto pressione la memoria del processo allocando molti oggetti, possiamo vedere che il numero heapUsed cresce più velocemente del numero rss, questo significa che in questo caso si fa più affidamento sullo spazio di swap nel tempo.

Informazioni di sistema

Il modulo os può essere utilizzato per ottenere informazioni sul sistema operativo. Diamo un'occhiata a un paio di API che possiamo utilizzare per trovare informazioni utili:

js
const os = require('os')

console.log('Hostname', os.hostname())
console.log('Home dir', os.homedir())
console.log('Temp dir', os.tmpdir())

Verrà visualizzato il nome host del sistema operativo, la home directory degli utenti registrati e la posizione della directory temporanea del sistema operativo. La cartella temporanea viene regolarmente cancellata dal sistema operativo, quindi è un ottimo posto per archiviare i file usa e getta senza la necessità di rimuoverli in seguito. Questo produrrà quanto segue:

text
$ node osInfo.js
Hostname MacBook-Pro-di-Davide.local
Home dir /Users/davide
Temp dir /tmp

Esistono due modi per identificare il sistema operativo con il modulo os:

  • La funzione os.platform che restituisce la stessa proprietà process.platform
  • La funzione os.type che su sistemi non Windows utilizza il comando uname e su Windows utilizza il comando ver, e per ottenere l'identificatore del sistema operativo:
js
const os = require('os')

console.log('platform', os.platform())
console.log('type', os.type())

Su MacOS si visualizzerà qualcosa del genere:

text
$ node osInfo.js
platform darwin
type Darwin

Se eseguito su Windows, la prima riga sarebbe platform win32 e la seconda riga sarebbe uname Windows_NT. Su Linux la prima riga sarebbe platform linux e la seconda riga sarebbe uname Linux. Tuttavia ci sono molti altri sistemi meno conosciuti con un comando uname che os.type() produrrebbe, troppi per elencarli qui. Vedi alcuni esempi su Wikipedia.

Statistiche di sistema

È anche possibile raccogliere le statistiche del sistema operativo, diamo un'occhiata a:

  • Uptime
  • Memoria libera
  • Memoria totale

La funzione os.uptime restituisce il tempo di esecuzione del sistema in secondi. Le funzioni os.freemem e os.totalmem restituiscono la memoria di sistema disponibile e la memoria di sistema totale in byte:

js
const os = require('os')

setInterval(() => {
  console.log('system uptime', os.uptime())
  console.log('freemem', os.freemem())
  console.log('totalmem', os.totalmem())
  console.log()
}, 1000)

Se eseguiamo questo codice per cinque secondi e poi premiamo Ctrl+C vedremo qualcosa di simile a quanto segue:

text
$ node systemStats.js
system uptime 23570
freemem 260382720
totalmem 17179869184

system uptime 23571
freemem 266244096
totalmem 17179869184

system uptime 23572
freemem 266194944
totalmem 17179869184

system uptime 23573
freemem 266342400
totalmem 17179869184

system uptime 23574
freemem 266272768
totalmem 17179869184

^C

Creare processi figli

Node.js fornisce molti metodi per creare e gestire processi figli con il modulo core child_processes. Esistono tre metodi principali in cui è possibile farlo:

  • exec ed execSync: questi metodi eseguono un comando in una console memorizzando l'output in un buffer.
  • spawn e spawnSync: questi metodi lanciano un comando passato in input.
  • fork: è una specializzazione del metodo spawn. Per impostazione predefinita, genera un nuovo processo Node.js del processo attualmente in esecuzione.

I metodi exec e execSync

Il metodo child_process.execSync è il modo più semplice per eseguire un comando:

js
const { execSync } = require('child_process')
const output = execSync('ls -allh')
console.log(output.toString())

Come già detto, execSync restituisce un buffer contenente l'output del comando. Questo è sia l'output STDOUT che STDERR. Nell'esempio appena mostrato dovrebbe restituire il risultato del comando ls -allh, nel mio caso ecco il risultato:

text
$ node execSync.js
drwxr-xr-x  23 davide  staff   736B  2 Lug 10:49 .
drwxr-xr-x  57 davide  staff   1,8K  3 Lug 10:28 ..
-rw-r--r--   1 davide  staff   222B  2 Lug 09:31 server.js
-rw-r--r--   1 davide  staff   533B  2 Lug 10:49 subprocess.js
-rw-r--r--   1 davide  staff   533B  2 Lug 10:57 execSync.js

Sebbene execSync scrive sia su STDOUT che STDERR, questo comportamento può essere configurato, ma ne scopriremo di più nella sezione "Child STDIO" più avanti in questo capitolo. Nel codice di esempio il comando in esecuzione sembra essere il binario node. Tuttavia, qualsiasi comando disponibile sulla macchina host può essere eseguito:

js
const { execSync } = require('child_process')
const cmd = process.platform === 'win32' ? 'dir' : 'ls -allh'
const output = execSync(cmd)
console.log(output.toString())

In questo esempio abbiamo utilizzato process.platform per determinare la piattaforma in modo da poter eseguire il comando equivalente su sistemi operativi Windows e non Windows. Se vogliamo eseguire il binario node come processo figlio, è meglio fare riferimento al percorso completo del binario node del processo del node attualmente in esecuzione. Questo può essere trovato con process.execPath:

js
const { execSync } = require('child_process')
const output = execSync(
  `${process.execPath} ./subprocess.js`
)
console.log(output.toString())

Ora se il contenuto del file subprocess.js è il seguente:

js
console.log('Hello I am the subprocess!')

e se il sottoprocesso terminerà con un codice di uscita uguale a 0 il risultato sarà il seguente:

text
$ node execSync.js
Hello I am the subprocess!

Se il sottoprocesso non termina con un codice di uscita pari a 0, allora il metodo execSync lancerà un eccezione. Modificando il file subprocess.js nel seguente modo:

js
console.log('Hello I am the subprocess!')
process.exit(1)

Gestiremo l'errore nel seguente modo:

js
const { execSync } = require('child_process')

try {
  const output = execSync(
    `${process.execPath} ./subprocess.js`
  )
  console.log(output.toString())
} catch (err) {
  console.error('Error:', err)
}

L'output generato sarà qualcosa tipo:

text
$ node execSync.js
Error: Error: Command failed: /Users/davidedantonio/.nvm/versions/node/v14.16.1/bin/node ./subprocess.js
...
  status: 1,
  signal: null,
  output: [
    null,
    <Buffer 48 65 6c 6c 6f 20 49 20 61 6d 20 74 68 65 20 73 75 62 70 72 6f 63 65 73 73 21 0a>,
    <Buffer >
  ],
  pid: 10324,
  stdout: <Buffer 48 65 6c 6c 6f 20 49 20 61 6d 20 74 68 65 20 73 75 62 70 72 6f 63 65 73 73 21 0a>,
  stderr: <Buffer >

L'oggetto errore che disconnettiamo nel blocco catch ha alcune proprietà aggiuntive. Possiamo vedere che lo stato è 1, questo perché il nostro sottoprocesso ha richiamato process.exit(1). In uno scenario di codice di uscita diverso da zero, la proprietà stderr dell'oggetto errore può essere molto utile. Gli indici della matrice di output corrispondono ai file descriptor I/O standard. Tenendo ben presente che il file descriptor di STDERR è 2, la proprietà err.stderr conterrà lo stesso buffer di err.output [2], quindi err.stderr o err.output [2] possono essere usati per scoprire qualsiasi messaggi di errore scritti in STDERR dal sottoprocesso. Nel nostro caso, il buffer STDERR è vuoto.

Il metodo exec accetta un comando della shell come una stringa e lo esegue allo stesso modo di execSync. A differenza di execSync, la funzione exec asincrona divide l'output STDOUT e STDERR passandoli come argomenti separati alla funzione di callback:

js
const { exec } = require('child_process')

exec(
  `${process.execPath} ./subprocess.js`,
  (err, stdout, stderr) => {
    console.log('err', err)
    console.log('subprocess stdout: ', stdout.toString())
    console.log('subprocess stderr: ', stderr.toString())
  }
)

L'esempio di codice precedente produce il seguente output:

text
$ node exec.js
err Error: Command failed: /Users/davidedantonio/.nvm/versions/node/v14.16.1/bin/node ./subprocess.js
  ...
  killed: false,
  code: 1,
  signal: null,
  cmd: '/Users/davidedantonio/.nvm/versions/node/v14.16.1/bin/node ./subprocess.js'
}
subprocess stdout:  Hello I am the subprocess!

subprocess stderr:

Noterete che la variabile err non è vuota in quanto il sottoprocesso termina con un codice di uscita diverso da 0. Inoltre la variabile stdout conterrà il valore restituito dal console.log del sottoprocesso, mentre stderr è vuota. Ora rimodifichiamo il file subprocess.js nel seguente modo:

js
console.log('Hello I am the subprocess stdout!')
console.error('Hello I am the subprocess sterr!')

Il risultato che otterremo questa volta sarà il seguente:

text
$ node exec.js
err null
subprocess stdout:  Hello I am the subprocess stdout!
subprocess stderr:  Hello I am the subprocess sterr!

I metodi spawn e spawnSync

Mentre exec ed execSync accettano un comando shell completo, spawn accetta il percorso eseguibile come primo argomento e quindi un array di flag che dovrebbero essere passati al comando come secondo argomento:

js
const { spawnSync } = require('child_process')

const result = spawnSync(
  process.execPath,
  ['./subprocess.js']
)

console.log(result)

In questo esempio process.execPath (ad es. Il percorso completo del binario di node) è il primo argomento passato a spawnSync e il secondo argomento è un array. Il primo elemento dell'array è il path del file subprocess.js. L'esecuzione di questo codice produce quanto segue:

text
$ node spawnSync.js
{
  status: 0,
  signal: null,
  output: [
    null,
    <Buffer 48 65 6c 6c 6f 20 49 20 61 6d 20 74 68 65 20 73 75 62 70 72 6f 63 65 73 73 20 73 74 64 6f 75 74 21 0a>,
    <Buffer 48 65 6c 6c 6f 20 49 20 61 6d 20 74 68 65 20 73 75 62 70 72 6f 63 65 73 73 20 73 74 65 72 72 21 0a>
  ],
  pid: 10711,
  stdout: <Buffer 48 65 6c 6c 6f 20 49 20 61 6d 20 74 68 65 20 73 75 62 70 72 6f 63 65 73 73 20 73 74 64 6f 75 74 21 0a>,
  stderr: <Buffer 48 65 6c 6c 6f 20 49 20 61 6d 20 74 68 65 20 73 75 62 70 72 6f 63 65 73 73 20 73 74 65 72 72 21 0a>
}

Mentre la funzione execSync restituisce un buffer contenente l'output del processo figlio, la funzione spawnSync restituisce un oggetto contenente informazioni sul processo che è stato generato. Lo abbiamo assegnato alla costante del risultato e lo abbiamo disconnesso. Questo oggetto contiene le stesse proprietà allegate all'oggetto errore quando viene lanciato execSync. La proprietà result.stdout (e result.output[1]) contiene un buffer dell'output STDOUT dei nostri processi, che dovrebbe essere 'Hello I am the subprocess stdout!'. Lo stesso vale per result.stderr (e result.output[2]). Scopriamolo rimuovendo la riga console.log(result) e aggiornando il file con queste due righe:

js
console.log(result.stdout.toString())
console.log(result.stdout.toString() === result.output[1].toString())
console.log(result.stderr.toString())
console.log(result.stderr.toString() === result.output[2].toString())

L'esecuzione del codice aggiornato dovrebbe verificare che l'oggetto result contenga l'output STDOUT e STDERR previsto:

text
$ node spawnSync.js
Hello I am the subprocess stdout!
true
Hello I am the subprocess sterr!
true

A differenza di execSync, il metodo spawnSync non ha bisogno di essere racchiuso in un try/catch. Se un processo spawnSync termina con un codice di uscita diverso da zero, non viene lanciato un errore. Ancora una volta, modifichiamo il file subprocess.js nel seguente modo:

js
console.log('Hello I am the subprocess stdout!')
process.exit(1)

Mentre il nostro script torna nella nostra forma iniziale:

js
const { spawnSync } = require('child_process')

const result = spawnSync(
  process.execPath,
  ['./subprocess.js']
)

console.log(result)

Quanto sopra, una volta eseguito, si tradurrà in quanto segue:

text
$ node spawnSync.js
{
  status: 1,
  signal: null,
  output: [
    null,
    <Buffer 48 65 6c 6c 6f 20 49 20 61 6d 20 74 68 65 20 73 75 62 70 72 6f 63 65 73 73 20 73 74 64 6f 75 74 21 0a>,
    null
  ],
  pid: 11046,
  stdout: <Buffer 48 65 6c 6c 6f 20 49 20 61 6d 20 74 68 65 20 73 75 62 70 72 6f 63 65 73 73 20 73 74 64 6f 75 74 21 0a>,
  stderr: null
}

Possiamo vedere che la proprietà status è impostata su 1, poiché abbiamo passato un codice di uscita di 1 a process.exit nel processo figlio. Se avessimo generato un errore senza rilevarlo nel sottoprocesso, anche il codice di uscita sarebbe stato 1, ma il buffer result.stderr conterrebbe l'output STDERR del sottoprocesso che mostra il messaggio di errore generato e lo stack.

Così come ci sono differenze tra execSync e spawnSync, ci sono differenze tra exec e spawn.

Mentre exec accetta una callback, spawn no. Tuttavia, sia exec che spawn restituiscono un'istanza ChildProcess, che ha flussi stdin, stdout e stderr ed eredita da EventEmitter consentendo di ottenere il codice di uscita dopo l'emissione di un evento di chiusura. Per ulteriori dettagli, vedere la documentazione del costruttore ChildProcess.

Diamo un'occhiata a un esempio di spawn:

js
const { spawn } = require('child_process')

const sp = spawn(
  process.execPath,
  ['./subprocess.js']
)

console.log('PID: ', sp.pid)

sp.stdout.pipe(process.stdout)

sp.on('close', (status) => {
  console.log('Exit Status: ', status)
})

Ora se il file subprocess.js contiene quanto segue:

js
console.log('Hello I am the subprocess stdout!')

L'output restituito dallo script sarà il seguente:

text
PID: 11166
Hello I am the subprocess stdout!
Exit Status: 0

Il metodo spawn restituisce un'istanza ChildProcess che abbiamo assegnato alla costante sp. Lo sp.pid (ID processo) è immediatamente disponibile, quindi viene eseguito subito su console.log. Per ottenere lo STDOUT del processo figlio, reindirizziamo sp.stdout al processo padre process.stdout. Ciò si traduce nella nostra seconda riga di output che dice subprocess stdio output. Per ottenere il codice di stato, ascoltiamo un evento close. Quando il processo figlio termina, viene chiamata la funzione listener che passa il codice di uscita come primo e unico argomento. Qui è dove stampiamo la nostra terza riga di output che indica il codice di uscita del sottoprocesso.

Modifichiamo il nostro subprocess.js nel seguente modo:

js
console.log('Hello I am the subprocess stdout!')
process.exit(1)

L'esecuzione del nostro script questa volta produrrà il seguente risultato:

text
PID:  11316
Hello I am the subprocess stdout!
Exit Status:  1

Il metodo spawn e il metodo exec restituiscono entrambi un'istanza ChildProcess possono essere fuorvianti. C'è un fattore di differenziazione molto importante tra spawn e gli altri metodi che abbiamo esplorato (vale a dire exec, execSync e spawnSync): il metodo spawn è l'unico dei quattro metodi che non bufferizza l'output del processo figlio. Anche se il metodo exec ha streams stdout e stderr, interromperanno lo streaming una volta che l'output del sottoprocesso avrà raggiunto 1 MiB (o 1024 * 1024 byte). Questo può essere configurato con un'opzione maxBuffer, ma non importa cosa, gli altri tre metodi hanno un limite massimo sulla quantità di output che un processo figlio può generare prima che venga troncato. Poiché il metodo spawn non esegue affatto il buffer, continuerà a trasmettere l'output per l'intera durata del sottoprocesso, indipendentemente dalla quantità di output che genera. Pertanto, per processi figlio a esecuzione prolungata, si consiglia di utilizzare il metodo spawn.

Il metodo fork

Il metodo fork è una specializzazione del metodo spawn. Per impostazione predefinita, genererà un nuovo processo Node del file JavaScript attualmente in esecuzione (sebbene possa essere fornito un file JavaScript diverso da eseguire). Per impostazione predefinita, imposta anche Interprocess Communication (IPC) con il sottoprocesso. Vedere la documentazione di fork per saperne di più.

Le variabili d'ambiente

Un oggetto options può essere passato come terzo argomento nel caso di spawn e spawnSync o come secondo argomento nel caso di exec ed execSync. Esploreremo due opzioni che possono essere passate che controllano l'ambiente del processo figlio: cwd e env. Useremo spawn per il nostro esempio, ma queste opzioni sono universalmente le stesse per tutti i metodi di creazione figlio. Iniziamo col dire che, per impostazione predefinita, il processo figlio eredita le variabili di ambiente del processo padre. Immaginiamo di avere il seguente script environment.js

js
console.log(process.env)

E lo script seguente:

js
const { spawn } = require('child_process')

process.env.CUSTOM_VARIABLE = 'CUSTOM CONTENT'
const sp = spawn(process.execPath, ['./environment.js'])

sp.stdout.pipe(process.stdout)

Questo codice di esempio crea un processo figlio che esegue lo script environment.js. Il flusso stdout del processo figlio viene reindirizzato allo stdout del processo padre. Quindi, quando eseguito, questo codice restituirà le variabili di ambiente del processo figlio:

text
{
  USER: 'davidedantonio',
  __CFBundleIdentifier: 'com.microsoft.VSCode',
  COMMAND_MODE: 'unix2003',
  LOGNAME: 'davidedantonio',
  PATH: '/Users/davidedantonio/.nvm/versions/node/v14.16.1/bin:/Users/davidedantonio/.nvm/versions/node/v14.16.1/bin:/Users/davidedantonio/.nvm/versions/node/v10.20.1/bin:/Users/davidedantonio/.linuxbrew/bin:/Users/davidedantonio/.linuxbrew/sbin:/bin:/Users/davidedantonio/.nvm/versions/node/v10.20.1/bin:/Library/Frameworks/Python.framework/Versions/2.7/bin:/opt/local/bin:/opt/local/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin:/Users/davidedantonio/.nvm/versions/node/v10.20.1/bin:/Users/davidedantonio/.linuxbrew/bin:/Users/davidedantonio/.linuxbrew/sbin:/Library/Frameworks/Python.framework/Versions/2.7/bin:/opt/local/bin:/opt/local/sbin',
  SSH_AUTH_SOCK: '/private/tmp/com.apple.launchd.k0zhjWEM54/Listeners',
  SHELL: '/bin/zsh',
  HOME: '/Users/davidedantonio',
  __CF_USER_TEXT_ENCODING: '0x1F5:0x0:0x4',
  TMPDIR: '/var/folders/fk/x7hf43tj65sdq9v2mpqlfmt00000gn/T/',
  XPC_SERVICE_NAME: '0',
  XPC_FLAGS: '0x0',
  ORIGINAL_XDG_CURRENT_DESKTOP: 'undefined',
  SHLVL: '1',
  PWD: '/Users/davidedantonio/Projects/nodejs-book',
  OLDPWD: '/Users/davidedantonio/Projects/nodejs-book/sourceCode',
  DISPLAY: ':0',
  ZSH: '/Users/davidedantonio/.oh-my-zsh',
  PAGER: 'less',
  LESS: '-R',
  LSCOLORS: 'Gxfxcxdxbxegedabagacad',
  NVM_DIR: '/Users/davidedantonio/.nvm',
  NVM_CD_FLAGS: '-q',
  NVM_BIN: '/Users/davidedantonio/.nvm/versions/node/v14.16.1/bin',
  NVM_INC: '/Users/davidedantonio/.nvm/versions/node/v14.16.1/include/node',
  TERM_PROGRAM: 'vscode',
  TERM_PROGRAM_VERSION: '1.57.1',
  LANG: 'it_IT.UTF-8',
  COLORTERM: 'truecolor',
  VSCODE_GIT_IPC_HANDLE: '/var/folders/fk/x7hf43tj65sdq9v2mpqlfmt00000gn/T/vscode-git-7257cbe02f.sock',
  GIT_ASKPASS: '/Applications/Visual Studio Code.app/Contents/Resources/app/extensions/git/dist/askpass.sh',
  VSCODE_GIT_ASKPASS_NODE: '/Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper (Renderer).app/Contents/MacOS/Code Helper (Renderer)',
  VSCODE_GIT_ASKPASS_MAIN: '/Applications/Visual Studio Code.app/Contents/Resources/app/extensions/git/dist/askpass-main.js',
  TERM: 'xterm-256color',
  _: '/Users/davidedantonio/.nvm/versions/node/v14.16.1/bin/node',
  CUSTOM_VARIABLE: 'CUSTOM CONTENT'
}

Se passiamo un oggetto con una proprietà env le variabili d'ambiente genitore verranno sovrascritte:

js
const { spawn } = require('child_process')

process.env.CUSTOM_VARIABLE = 'CUSTOM CONTENT'
const sp = spawn(process.execPath, ['./subprocess.js'], {
  env: { CUSTOM_VARIABLE: 'CUSTOM CONTENT NEW' }
})

sp.stdout.pipe(process.stdout)

Abbiamo modificato il codice in modo che un oggetto env venga passato tramite l'oggetto options, che contiene una singola variabile di ambiente denominata CUSTOM_VARIABLE. Quando viene eseguito, il processo padre restituirà l'oggetto delle variabili di ambiente del processo figlio e conterrà solo ciò che abbiamo passato tramite l'opzione env:

text
{
  CUSTOM_VARIABLE: 'CUSTOM CONTENT NEW',
  __CF_USER_TEXT_ENCODING: '0x1F5:0x0:0x4'
}

Un'altra opzione che può essere impostata durante la creazione di un processo figlio è l'opzione cwd:

js
const { IS_CHILD } = process.env

if (IS_CHILD) {
  console.log('Subprocess cwd:', process.cwd())
  console.log('env', process.env)
} else {
  const { parse } = require('path')
  const { root } = parse(process.cwd())
  const { spawn } = require('child_process')
  const sp = spawn(process.execPath, [__filename], {
    cwd: root,
    env: {IS_CHILD: '1'}
  })

  sp.stdout.pipe(process.stdout)
}

In questo esempio, stiamo eseguendo lo stesso file due volte. Una volta come processo padre e poi una volta come processo figlio. Creiamo il processo figlio passando __filename, all'interno dell'array degli argomenti passato a spawn. Ciò significa che il processo figlio eseguirà il processo con il percorso del file corrente.

Passiamo un'opzione env per spawn, con una proprietà IS_CHILD impostata su una stringa ('1'), in modo che quando il sottoprocesso viene caricato, entrerà nel blocco if. Considerando che nel processo padre, process.env.IS_CHILD non è definito, quindi quando il processo padre viene eseguito entrerà nel blocco else, che è il punto in cui viene generato il processo figlio.

La proprietà radice dell'oggetto restituito da parse(process.cwd()) sarà diversa a seconda della piattaforma e su Windows, a seconda del disco rigido su cui viene eseguito il codice. Impostando l'opzione cwd su root, stiamo impostando la directory di lavoro corrente del processo figlio sul percorso della directory root del nostro file system.

Nel processo figlio, IS_CHILD sarà true, quindi entrerà nel blocco if stamperà la directory di lavoro corrente e le variabili di ambiente dei processi figlio. Poiché il processo padre invia il flusso sp.stdout al flusso process.stdout, l'esecuzione di questo codice stamperà la directory di lavoro corrente e le variabili di ambiente del processo figlio, che impostiamo tramite le opzioni di configurazione:

text
Subprocess cwd: /
env { IS_CHILD: '1', __CF_USER_TEXT_ENCODING: '0x1F5:0x0:0x4' 

Le opzioni cwd e env possono essere impostate per qualsiasi metodo di processo figlio discusso nella sezione precedente, ma ci sono anche altre opzioni che possono essere impostate. Per ulteriori informazioni, vedere le opzioni di exec, le opzioni di execSync le opzioni di spawn, e le opzioni di spawnSync.