Esplorazione degli interni di Node.js

Pubblicato: 2022-03-10
Riassunto veloce ↬ Node.js è uno strumento interessante per gli sviluppatori web. Con il suo alto livello di concorrenza, è diventato un candidato leader per le persone che scelgono gli strumenti da utilizzare nello sviluppo web. In questo articolo impareremo cosa compone Node.js, daremo una definizione significativa, capiremo come interagiscono gli interni di Node.js ed esploreremo il repository del progetto per Node.js su GitHub.

Dall'introduzione di Node.js da parte di Ryan Dahl alla JSConf europea l'8 novembre 2009, è stato ampiamente utilizzato nel settore tecnologico. Aziende come Netflix, Uber e LinkedIn danno credibilità all'affermazione che Node.js può resistere a un'elevata quantità di traffico e concorrenza.

Armati delle conoscenze di base, gli sviluppatori principianti e intermedi di Node.js lottano con molte cose: "È solo un runtime!" "Ha loop di eventi!" "Node.js è a thread singolo come JavaScript!"

Sebbene alcune di queste affermazioni siano vere, approfondiremo il runtime di Node.js, comprendendo come esegue JavaScript, verificando se è effettivamente a thread singolo e, infine, comprendendo meglio l'interconnessione tra le sue dipendenze principali, V8 e libuv .

Prerequisiti

  • Conoscenza di base di JavaScript
  • Familiarità con la semantica di Node.js ( require , fs )

Che cos'è Node.js?

Potrebbe essere allettante presumere ciò che molte persone hanno creduto su Node.js, la definizione più comune è che si tratta di un runtime per il linguaggio JavaScript . Per considerare questo, dovremmo capire cosa ha portato a questa conclusione.

Node.js è spesso descritto come una combinazione di C++ e JavaScript. La parte C++ è costituita da associazioni che eseguono codice di basso livello che consentono di accedere all'hardware connesso al computer. La parte JavaScript prende JavaScript come codice sorgente e lo esegue in un popolare interprete del linguaggio, chiamato motore V8.

Con questa comprensione, potremmo descrivere Node.js come uno strumento unico che combina JavaScript e C++ per eseguire programmi al di fuori dell'ambiente del browser.

Ma potremmo davvero chiamarlo runtime? Per determinarlo, definiamo cos'è un runtime.

In una delle sue risposte su StackOverflow, DJNA definisce un ambiente di runtime come "tutto ciò di cui hai bisogno per eseguire un programma, ma nessuno strumento per cambiarlo". Secondo questa definizione, possiamo affermare con sicurezza che tutto ciò che sta accadendo mentre eseguiamo il nostro codice (in qualsiasi linguaggio) è in esecuzione in un ambiente di runtime.

Altre lingue hanno il proprio ambiente di runtime. Per Java, è Java Runtime Environment (JRE). Per .NET, è il Common Language Runtime (CLR). Per Erlang, è BEAM.

Tuttavia, alcuni di questi runtime hanno altri linguaggi che dipendono da essi. Ad esempio, Java ha Kotlin, un linguaggio di programmazione che compila in codice che un JRE può comprendere. Erlang ha l'elisir. E sappiamo che esistono molte varianti per lo sviluppo .NET, tutte eseguite in CLR, noto come .NET Framework.

Ora sappiamo che un runtime è un ambiente fornito affinché un programma possa essere eseguito correttamente e sappiamo che V8 e una serie di librerie C++ rendono possibile l'esecuzione di un'applicazione Node.js. Node.js stesso è il runtime effettivo che lega tutto insieme per rendere quelle librerie un'entità e comprende solo un linguaggio, JavaScript, indipendentemente da ciò con cui Node.js è costruito.

Altro dopo il salto! Continua a leggere sotto ↓

Struttura interna di Node.js

Quando tentiamo di eseguire un programma Node.js (come index.js ) dalla nostra riga di comando utilizzando il comando node index.js , chiamiamo il runtime Node.js. Questo runtime, come accennato, è costituito da due dipendenze indipendenti, V8 e libuv.

Dipendenze di Core Node.js
Dipendenze Core Node.js (Anteprima grande)

V8 è un progetto creato e mantenuto da Google. Prende il codice sorgente JavaScript e lo esegue al di fuori dell'ambiente del browser. Quando eseguiamo un programma tramite un comando node , il codice sorgente viene passato dal runtime Node.js a V8 per l'esecuzione.

La libreria libuv contiene codice C++ che consente l'accesso di basso livello al sistema operativo. Funzionalità come networking, scrittura nel file system e concorrenza non vengono fornite per impostazione predefinita in V8, che è la parte di Node.js che esegue il nostro codice JavaScript. Con il suo set di librerie, libuv fornisce queste utilità e altro in un ambiente Node.js.

Node.js è il collante che tiene unite le due librerie, diventando così una soluzione unica. Durante l'esecuzione di uno script, Node.js comprende a quale progetto passare il controllo e quando.

API interessanti per programmi lato server

Se studiamo un po' di storia di JavaScript, sapremmo che ha lo scopo di aggiungere alcune funzionalità e interazioni a una pagina nel browser. E nel browser, interagiamo con gli elementi del modello a oggetti del documento (DOM) che compongono la pagina. Per questo, esiste un insieme di API, denominate collettivamente API DOM.

Il DOM esiste solo nel browser; è ciò che viene analizzato per il rendering di una pagina ed è fondamentalmente scritto nel linguaggio di markup noto come HTML. Inoltre, il browser esiste in una finestra, da cui l'oggetto window , che funge da radice per tutti gli oggetti nella pagina in un contesto JavaScript. Questo ambiente è chiamato ambiente browser ed è un ambiente di runtime per JavaScript.

Le API di Node.js chiamano libuv per alcune funzioni
Le API di Node.js interagiscono con libuv (anteprima grande)

In un ambiente Node.js, non abbiamo niente come una pagina, né un browser: questo annulla la nostra conoscenza dell'oggetto finestra globale. Quello che abbiamo è un insieme di API che interagiscono con il sistema operativo per fornire funzionalità aggiuntive a un programma JavaScript. Queste API per Node.js ( fs , path , buffer , events , HTTP e così via), così come le abbiamo, esistono solo per Node.js e sono fornite da Node.js (a sua volta un runtime) in modo da può eseguire programmi scritti per Node.js.

Esperimento: come fs.writeFile crea un nuovo file

Se V8 è stato creato per eseguire JavaScript al di fuori del browser e se un ambiente Node.js non ha lo stesso contesto o ambiente di un browser, come potremmo fare qualcosa come accedere al file system o creare un server HTTP?

Ad esempio, prendiamo una semplice applicazione Node.js che scrive un file nel file system nella directory corrente:

 const fs = require("fs") fs.writeFile("./test.txt", "text");

Come mostrato, stiamo provando a scrivere un nuovo file nel file system. Questa funzione non è disponibile nel linguaggio JavaScript; è disponibile solo in un ambiente Node.js. Come viene eseguito?

Per capirlo, facciamo un tour della base di codice di Node.js.

Andando al repository GitHub per Node.js, vediamo due cartelle principali, src e lib . La cartella lib ha il codice JavaScript che fornisce il bel set di moduli che sono inclusi per impostazione predefinita con ogni installazione di Node.js. La cartella src contiene le librerie C++ per libuv.

Se guardiamo nella cartella lib ed esaminiamo il file fs.js , vedremo che è pieno di codice JavaScript impressionante. Alla riga 1880, noteremo una dichiarazione di exports . Questa istruzione esporta tutto ciò a cui possiamo accedere importando il modulo fs e possiamo vedere che esporta una funzione denominata writeFile .

La ricerca della function writeFile( (dove è definita la funzione) ci porta alla riga 1303, dove vediamo che la funzione è definita con quattro parametri:

 function writeFile(path, data, options, callback) { callback = maybeCallback(callback || options); options = getOptions(options, { encoding: 'utf8', mode: 0o666, flag: 'w' }); const flag = options.flag || 'w'; if (!isArrayBufferView(data)) { validateStringAfterArrayBufferView(data, 'data'); data = Buffer.from(data, options.encoding || 'utf8'); } if (isFd(path)) { const isUserFd = true; writeAll(path, isUserFd, data, 0, data.byteLength, callback); return; } fs.open(path, flag, options.mode, (openErr, fd) => { if (openErr) { callback(openErr); } else { const isUserFd = false; writeAll(fd, isUserFd, data, 0, data.byteLength, callback); } }); }

Alle righe 1315 e 1324 vediamo che una singola funzione, writeAll , viene chiamata dopo alcuni controlli di validazione. Troviamo questa funzione alla riga 1278 nello stesso file fs.js

 function writeAll(fd, isUserFd, buffer, offset, length, callback) { // write(fd, buffer, offset, length, position, callback) fs.write(fd, buffer, offset, length, null, (writeErr, written) => { if (writeErr) { if (isUserFd) { callback(writeErr); } else { fs.close(fd, function close() { callback(writeErr); }); } } else if (written === length) { if (isUserFd) { callback(null); } else { fs.close(fd, callback); } } else { offset += written; length -= written; writeAll(fd, isUserFd, buffer, offset, length, callback); } }); }

È anche interessante notare che questo modulo sta tentando di chiamare se stesso. Lo vediamo alla riga 1280, dove chiama fs.write . Cercando la funzione di write , scopriremo una piccola informazione.

La funzione di write inizia alla riga 571 ed esegue circa 42 righe. Vediamo uno schema ricorrente in questa funzione: il modo in cui chiama una funzione sul modulo di binding , come si vede alle righe 594 e 612. Una funzione sul modulo di binding viene chiamata non solo in questa funzione, ma praticamente in qualsiasi funzione esportata nel file fs.js Deve esserci qualcosa di molto speciale.

La variabile binding è dichiarata alla riga 58, nella parte superiore del file, e un clic su quella chiamata di funzione rivela alcune informazioni, con l'aiuto di GitHub.

Dichiarazione della variabile vincolante
Dichiarazione della variabile vincolante (Anteprima grande)

Questa funzione internalBinding si trova nel modulo chiamato loaders. La funzione principale del modulo dei caricatori è caricare tutte le librerie libuv e collegarle tramite il progetto V8 con Node.js. Come fa questo è piuttosto magico, ma per saperne di più possiamo guardare da vicino la funzione writeBuffer che viene chiamata dal modulo fs .

Dovremmo guardare dove si collega con libuv e dove entra in gioco V8. Nella parte superiore del modulo dei caricatori, una buona documentazione afferma questo:

 // This file is compiled and run by node.cc before bootstrap/node.js // was called, therefore the loaders are bootstraped before we start to // actually bootstrap Node.js. It creates the following objects: // // C++ binding loaders: // - process.binding(): the legacy C++ binding loader, accessible from user land // because it is an object attached to the global process object. // These C++ bindings are created using NODE_BUILTIN_MODULE_CONTEXT_AWARE() // and have their nm_flags set to NM_F_BUILTIN. We do not make any guarantees // about the stability of these bindings, but still have to take care of // compatibility issues caused by them from time to time. // - process._linkedBinding(): intended to be used by embedders to add // additional C++ bindings in their applications. These C++ bindings // can be created using NODE_MODULE_CONTEXT_AWARE_CPP() with the flag // NM_F_LINKED. // - internalBinding(): the private internal C++ binding loader, inaccessible // from user land unless through `require('internal/test/binding')`. // These C++ bindings are created using NODE_MODULE_CONTEXT_AWARE_INTERNAL() // and have their nm_flags set to NM_F_INTERNAL. // // Internal JavaScript module loader: // - NativeModule: a minimal module system used to load the JavaScript core // modules found in lib/**/*.js and deps/**/*.js. All core modules are // compiled into the node binary via node_javascript.cc generated by js2c.py, // so they can be loaded faster without the cost of I/O. This class makes the // lib/internal/*, deps/internal/* modules and internalBinding() available by // default to core modules, and lets the core modules require itself via // require('internal/bootstrap/loaders') even when this file is not written in // CommonJS style.

Quello che impariamo qui è che per ogni modulo chiamato dall'oggetto binding nella sezione JavaScript del progetto Node.js, ce n'è un equivalente nella sezione C++, nella cartella src .

Dal nostro tour fs , vediamo che il modulo che fa questo si trova in node_file.cc . Ogni funzione accessibile tramite il modulo è definita nel file; per esempio, abbiamo writeBuffer alla riga 2258. La definizione effettiva di quel metodo nel file C++ è alla riga 1785. Inoltre, la chiamata alla parte di libuv che esegue la scrittura effettiva del file può essere trovata alle righe 1809 e 1815, dove la funzione uv_fs_write viene chiamata in modo asincrono.

Cosa guadagniamo da questa comprensione?

Proprio come molti altri runtime di lingua interpretati, il runtime di Node.js può essere violato. Con una maggiore comprensione, potremmo fare cose impossibili con la distribuzione standard semplicemente guardando attraverso la fonte. Potremmo aggiungere librerie per apportare modifiche al modo in cui vengono chiamate alcune funzioni. Ma soprattutto, questa comprensione è una base per ulteriori esplorazioni.

Node.js è a thread singolo?

Seduto su libuv e V8, Node.js ha accesso ad alcune funzionalità aggiuntive che un tipico motore JavaScript in esecuzione nel browser non ha.

Qualsiasi JavaScript eseguito in un browser verrà eseguito in un singolo thread. Un thread nell'esecuzione di un programma è proprio come una scatola nera sopra la CPU in cui il programma viene eseguito. In un contesto Node.js, del codice potrebbe essere eseguito in tutti i thread che le nostre macchine possono trasportare.

Per verificare questa particolare affermazione, esploriamo un semplice frammento di codice.

 const fs = require("fs"); // A little benchmarking const startTime = Date.now() fs.writeFile("./test.txt", "test", (err) => { If (error) { console.log(err) } console.log("1 Done: ", Date.now() — startTime) });

Nello snippet sopra, stiamo cercando di creare un nuovo file sul disco nella directory corrente. Per vedere quanto tempo potrebbe richiedere, abbiamo aggiunto un piccolo benchmark per monitorare l'ora di inizio dello script, che ci fornisce la durata in millisecondi dello script che sta creando il file.

Se eseguiamo il codice sopra, otterremo un risultato come questo:

Risultato del tempo necessario per creare un singolo file in Node.js
Tempo impiegato per creare un singolo file in Node.js (anteprima grande)
 $ node ./test.js -> 1 Done: 0.003s

Questo è davvero impressionante: solo 0,003 secondi.

Ma facciamo qualcosa di veramente interessante. Per prima cosa duplichiamo il codice che genera il nuovo file e aggiorniamo il numero nell'istruzione log per riflettere le loro posizioni:

 const fs = require("fs"); // A little benchmarking const startTime = Date.now() fs.writeFile("./test1.txt", "test", function (err) { if (err) { console.log(err) } console.log("1 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test2.txt", "test", function (err) { if (err) { console.log(err) } console.log("2 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test3.txt", "test", function (err) { if (err) { console.log(err) } console.log("3 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test4.txt", "test", function (err) { if (err) { console.log(err) } console.log("4 Done: %ss", (Date.now() — startTime) / 1000) });

Se tentiamo di eseguire questo codice, otterremo qualcosa che ci lascia a bocca aperta. Ecco il mio risultato:

Risultato del tempo necessario per creare più file
Creazione di più file contemporaneamente (anteprima grande)

Innanzitutto, noteremo che i risultati non sono coerenti. In secondo luogo, vediamo che il tempo è aumentato. Cosa sta succedendo?

Le attività di basso livello vengono delegate

Node.js è a thread singolo, come sappiamo ora. Parti di Node.js sono scritte in JavaScript e altre in C++. Node.js utilizza gli stessi concetti del ciclo di eventi e dello stack di chiamate che conosciamo dall'ambiente del browser, il che significa che le parti JavaScript di Node.js sono a thread singolo. Ma l'attività di basso livello che richiede di parlare con un sistema operativo non è a thread singolo.

Le attività di basso livello sono delegate al sistema operativo tramite libuv
Delega attività di basso livello Node.js (anteprima grande)

Quando una chiamata viene riconosciuta da Node.js come destinata a libuv, delega questa attività a libuv. Nel suo funzionamento, libuv richiede thread per alcune delle sue librerie, da qui l'uso del pool di thread nell'esecuzione di programmi Node.js quando sono necessari.

Per impostazione predefinita, il pool di thread Node.js fornito da libuv contiene quattro thread. Potremmo aumentare o ridurre questo pool di thread chiamando process.env.UV_THREADPOOL_SIZE nella parte superiore del nostro script.

 // script.js process.env.UV_THREADPOOL_SIZE = 6; // … // …

Cosa succede con il nostro programma di creazione di file

Sembra che una volta che invochiamo il codice per creare il nostro file, Node.js colpisce la parte libuv del suo codice, che dedica un thread a questo compito. Questa sezione in libuv ottiene alcune informazioni statistiche sul disco prima di lavorare sul file.

Il completamento di questo controllo statistico potrebbe richiedere del tempo; quindi, il thread viene rilasciato per alcune altre attività fino al completamento del controllo statistico. Quando il controllo è completato, la sezione libuv occupa qualsiasi thread disponibile o attende fino a quando non diventa disponibile un thread per esso.

Abbiamo solo quattro chiamate e quattro thread, quindi ci sono abbastanza thread da girare. L'unica domanda è quanto velocemente ogni thread elaborerà la sua attività. Noteremo che il primo codice inserito nel pool di thread restituirà prima il suo risultato e blocca tutti gli altri thread durante l'esecuzione del suo codice.

Conclusione

Ora capiamo cos'è Node.js. Sappiamo che è un runtime. Abbiamo definito cos'è un runtime. E abbiamo approfondito ciò che costituisce il runtime fornito da Node.js.

Abbiamo fatto molta strada. E dal nostro piccolo tour del repository Node.js su GitHub, possiamo esplorare qualsiasi API a cui potremmo essere interessati, seguendo lo stesso processo che abbiamo seguito qui. Node.js è open source, quindi sicuramente possiamo immergerci nel sorgente, no?

Anche se abbiamo toccato molti dei bassi livelli di ciò che accade nel runtime di Node.js, non dobbiamo presumere di sapere tutto. Le risorse seguenti indicano alcune informazioni su cui possiamo costruire la nostra conoscenza:

  • Introduzione a Node.js
    Essendo un sito Web ufficiale, Node.dev spiega cos'è Node.js, così come i suoi gestori di pacchetti ed elenca i framework Web basati su di esso.
  • "JavaScript & Node.js", Il libro per principianti di Node
    Questo libro di Manuel Kiessling fa un ottimo lavoro nello spiegare Node.js, dopo aver avvertito che JavaScript nel browser non è lo stesso di Node.js, anche se entrambi sono scritti nella stessa lingua.
  • Node.js iniziale
    Questo libro per principianti va oltre una spiegazione del runtime. Insegna su pacchetti e flussi e sulla creazione di un server Web con il framework Express.
  • LibUV
    Questa è la documentazione ufficiale del codice C++ di supporto del runtime Node.js.
  • V8
    Questa è la documentazione ufficiale del motore JavaScript che permette di scrivere Node.js con JavaScript.