Explorarea elementelor interne ale Node.js

Publicat: 2022-03-10
Rezumat rapid ↬ Node.js este un instrument interesant pentru dezvoltatorii web. Cu nivelul său ridicat de concurență, a devenit un candidat de top pentru oamenii care aleg instrumente pentru a le folosi în dezvoltarea web. În acest articol, vom afla despre ce alcătuiește Node.js, îi vom oferi o definiție semnificativă, vom înțelege modul în care elementele interne ale Node.js interacționează între ele și vom explora depozitul de proiect pentru Node.js pe GitHub.

De la introducerea Node.js de către Ryan Dahl la JSConf european pe 8 noiembrie 2009, acesta a fost utilizat pe scară largă în industria tehnologiei. Companii precum Netflix, Uber și LinkedIn conferă credibilitate afirmației că Node.js poate rezista la o cantitate mare de trafic și concurență.

Înarmați cu cunoștințe de bază, dezvoltatorii începători și intermediari ai Node.js se luptă cu multe lucruri: „Este doar un timp de rulare!” „Are bucle de evenimente!” „Node.js are un singur thread ca JavaScript!”

Deși unele dintre aceste afirmații sunt adevărate, vom săpa mai adânc în timpul de execuție Node.js, înțelegem cum rulează JavaScript, vedem dacă este într-adevăr cu un singur thread și, în sfârșit, înțelegem mai bine interconexiunea dintre dependențele sale de bază, V8 și libuv. .

Cerințe preliminare

  • Cunoștințe de bază JavaScript
  • Familiaritate cu semantica Node.js ( require , fs )

Ce este Node.js?

Ar putea fi tentant să presupunem ceea ce mulți oameni au crezut despre Node.js, cea mai comună definiție a acestuia fiind că este un timp de execuție pentru limbajul JavaScript . Pentru a lua în considerare acest lucru, ar trebui să înțelegem ce a condus la această concluzie.

Node.js este adesea descris ca o combinație de C++ și JavaScript. Partea C++ constă în legături care rulează cod de nivel scăzut care fac posibilă accesarea hardware-ului conectat la computer. Partea JavaScript ia JavaScript ca cod sursă și îl rulează într-un interpret popular al limbajului, numit motor V8.

Cu această înțelegere, am putea descrie Node.js ca un instrument unic care combină JavaScript și C++ pentru a rula programe în afara mediului browser.

Dar am putea numi asta de fapt un timp de rulare? Pentru a determina asta, să definim ce este un timp de execuție.

Într-unul dintre răspunsurile sale despre StackOverflow, DJNA definește un mediu de rulare ca „tot ce aveți nevoie pentru a executa un program, dar fără instrumente pentru a-l schimba”. Conform acestei definiții, putem spune cu încredere că tot ceea ce se întâmplă în timp ce rulăm codul nostru (în orice limbă) rulează într-un mediu de rulare.

Alte limbi au propriul mediu de rulare. Pentru Java, este Java Runtime Environment (JRE). Pentru .NET, este Common Language Runtime (CLR). Pentru Erlang, este BEAM.

Cu toate acestea, unele dintre aceste runtime au alte limbaje care depind de ele. De exemplu, Java are Kotlin, un limbaj de programare care se compilează într-un cod pe care un JRE îl poate înțelege. Erlang are Elixir. Și știm că există multe variante pentru dezvoltarea .NET, care rulează toate în CLR, cunoscut sub numele de .NET Framework.

Acum înțelegem că un timp de execuție este un mediu oferit pentru ca un program să se poată executa cu succes și știm că V8 și o serie de biblioteci C++ fac posibilă executarea unei aplicații Node.js. Node.js însuși este timpul de execuție real care leagă totul pentru a face din acele biblioteci o entitate și înțelege doar o singură limbă - JavaScript - indiferent cu ce este construit Node.js.

Mai multe după săritură! Continuați să citiți mai jos ↓

Structura internă a Node.js

Când încercăm să rulăm un program Node.js (cum ar fi index.js ) din linia noastră de comandă folosind node index.js , apelăm timpul de execuție Node.js. Acest runtime, așa cum am menționat, constă din două dependențe independente, V8 și libuv.

Dependențe de bază Node.js
Dependențe de bază Node.js (previzualizare mare)

V8 este un proiect creat și întreținut de Google. Preia codul sursă JavaScript și îl rulează în afara mediului browser. Când rulăm un program printr-o comandă node , codul sursă este transmis de runtimeul Node.js către V8 pentru execuție.

Biblioteca libuv conține cod C++ care permite accesul la nivel scăzut la sistemul de operare. Funcționalități precum crearea de rețele, scrierea în sistemul de fișiere și concurența nu sunt livrate implicit în V8, care este partea din Node.js care rulează codul nostru JavaScript. Cu setul său de biblioteci, libuv oferă aceste utilități și multe altele într-un mediu Node.js.

Node.js este lipiciul care ține cele două biblioteci împreună, devenind astfel o soluție unică. Pe parcursul execuției unui script, Node.js înțelege cărui proiect să îi transmită controlul și când.

API-uri interesante pentru programele pe partea de server

Dacă studiem puțin istoricul JavaScript, am ști că este menit să adauge o anumită funcționalitate și interacțiune la o pagină din browser. Și în browser, am interacționa cu elementele modelului obiect document (DOM) care alcătuiesc pagina. Pentru aceasta, există un set de API-uri, denumite colectiv API DOM.

DOM-ul există doar în browser; este ceea ce este analizat pentru a reda o pagină și, în principiu, este scris în limbajul de marcare cunoscut sub numele de HTML. De asemenea, browserul există într-o fereastră, de unde obiectul window , care acționează ca rădăcină pentru toate obiectele de pe pagină într-un context JavaScript. Acest mediu se numește mediu browser și este un mediu de rulare pentru JavaScript.

API-urile Node.js apelează libuv pentru unele funcții
API-urile Node.js interacționează cu libuv (previzualizare mare)

Într-un mediu Node.js, nu avem nimic ca o pagină și nici un browser - acest lucru anulează cunoștințele noastre despre obiectul fereastră globală. Ceea ce avem este un set de API-uri care interacționează cu sistemul de operare pentru a oferi funcționalități suplimentare unui program JavaScript. Aceste API-uri pentru Node.js ( fs , path , buffer , events , HTTP , și așa mai departe), așa cum le avem, există numai pentru Node.js și sunt furnizate de Node.js (însuși un runtime), astfel încât noi poate rula programe scrise pentru Node.js.

Experiment: Cum fs.writeFile creează un fișier nou

Dacă V8 a fost creat pentru a rula JavaScript în afara browserului și dacă un mediu Node.js nu are același context sau mediu ca un browser, atunci cum am face ceva precum accesarea sistemului de fișiere sau crearea unui server HTTP?

Ca exemplu, să luăm o aplicație simplă Node.js care scrie un fișier în sistemul de fișiere din directorul curent:

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

După cum se arată, încercăm să scriem un fișier nou în sistemul de fișiere. Această caracteristică nu este disponibilă în limbajul JavaScript; este disponibil numai într-un mediu Node.js. Cum se execută acest lucru?

Pentru a înțelege acest lucru, să facem un tur al bazei de cod Node.js.

Îndreptându-ne către depozitul GitHub pentru Node.js, vedem două foldere principale, src și lib . Dosarul lib are codul JavaScript care oferă setul frumos de module care sunt incluse implicit la fiecare instalare Node.js. Dosarul src conține bibliotecile C++ pentru libuv.

Dacă ne uităm în folderul lib și parcurgem fișierul fs.js , vom vedea că este plin de cod JavaScript impresionant. Pe linia 1880, vom observa o declarație de exports . Această instrucțiune exportă tot ceea ce putem accesa importând modulul fs și putem vedea că exportă o funcție numită writeFile .

Căutarea function writeFile( (unde este definită funcția) ne duce la linia 1303, unde vedem că funcția este definită cu patru 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); } }); }

Pe liniile 1315 și 1324, vedem că o singură funcție, writeAll , este apelată după câteva verificări de validare. Găsim această funcție pe linia 1278 în același fișier 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); } }); }

Este, de asemenea, interesant de observat că acest modul încearcă să se numească singur. Vedem asta pe linia 1280, unde apelează fs.write . Căutând funcția de write , vom descoperi câteva informații.

Funcția de write începe pe linia 571 și rulează aproximativ 42 de linii. Vedem un model recurent în această funcție: modul în care apelează o funcție pe modulul de binding , așa cum se vede pe liniile 594 și 612. O funcție de pe modulul de binding este numită nu numai în această funcție, ci și în orice funcție care este exportată. în fișierul fs.js Trebuie să fie ceva foarte special în ea.

Variabila binding este declarată pe linia 58, chiar în partea de sus a fișierului, iar un clic pe acel apel de funcție dezvăluie câteva informații, cu ajutorul GitHub.

Declararea variabilei obligatorii
Declarația variabilei de legare (Previzualizare mare)

Această funcție internalBinding se găsește în modulul numit loaders. Funcția principală a modulului de încărcare este de a încărca toate bibliotecile libuv și de a le conecta prin proiectul V8 cu Node.js. Cum face acest lucru este mai degrabă magic, dar pentru a afla mai multe ne putem uita îndeaproape la funcția writeBuffer care este apelată de modulul fs .

Ar trebui să ne uităm unde se conectează acest lucru cu libuv și unde intervine V8. În partea de sus a modulului de încărcare, o documentație bună de acolo arată asta:

 // 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.

Ceea ce aflăm aici este că pentru fiecare modul apelat din obiectul de binding din secțiunea JavaScript a proiectului Node.js, există un echivalent al acestuia în secțiunea C++, în folderul src .

Din turul nostru fs , vedem că modulul care face acest lucru se află în node_file.cc . Fiecare funcție care este accesibilă prin modul este definită în fișier; de exemplu, avem writeBuffer pe linia 2258. Definiția actuală a acelei metode în fișierul C++ este pe linia 1785. De asemenea, apelul către partea din libuv care face scrierea efectivă în fișier poate fi găsită pe liniile 1809 și 1815, unde funcția uv_fs_write este apelată asincron.

Ce câștigăm din această înțelegere?

La fel ca multe alte runtime de limbaj interpretat, timpul de rulare al Node.js poate fi piratat. Cu o mai bună înțelegere, am putea face lucruri care sunt imposibile cu distribuția standard doar prin căutarea prin sursă. Am putea adăuga biblioteci pentru a face modificări în modul în care sunt numite unele funcții. Dar, mai presus de toate, această înțelegere este o bază pentru explorare ulterioară.

Node.js are un singur thread?

Aflat pe libuv și V8, Node.js are acces la unele funcționalități suplimentare pe care nu le are un motor JavaScript tipic care rulează în browser.

Orice JavaScript care rulează într-un browser se va executa într-un singur fir. Un fir în execuția unui program este exact ca o cutie neagră așezată deasupra procesorului în care este executat programul. Într-un context Node.js, un anumit cod ar putea fi executat în câte fire de execuție pot transporta mașinile noastre.

Pentru a verifica această afirmație specială, haideți să explorăm un fragment de cod simplu.

 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) });

În fragmentul de mai sus, încercăm să creăm un nou fișier pe disc în directorul curent. Pentru a vedea cât de mult ar putea dura acest lucru, am adăugat un mic punct de referință pentru a monitoriza ora de începere a scriptului, care ne oferă durata în milisecunde a scriptului care creează fișierul.

Dacă rulăm codul de mai sus, vom obține un rezultat ca acesta:

Rezultatul timpului necesar pentru a crea un singur fișier în Node.js
Timp necesar pentru a crea un singur fișier în Node.js (previzualizare mare)
 $ node ./test.js -> 1 Done: 0.003s

Este foarte impresionant: doar 0,003 secunde.

Dar haideți să facem ceva cu adevărat interesant. Mai întâi să duplicăm codul care generează noul fișier și să actualizăm numărul din instrucțiunea de jurnal pentru a reflecta pozițiile acestora:

 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) });

Dacă încercăm să rulăm acest cod, vom obține ceva care ne va uimi. Iata rezultatul meu:

Rezultatul timpului necesar pentru a crea mai multe fișiere
Crearea mai multor fișiere simultan (previzualizare mare)

În primul rând, vom observa că rezultatele nu sunt consistente. În al doilea rând, vedem că timpul a crescut. Ce se întâmplă?

Sarcinile de nivel scăzut sunt delegate

Node.js are un singur thread, așa cum știm acum. Părți din Node.js sunt scrise în JavaScript, iar altele în C++. Node.js folosește aceleași concepte ale buclei de evenimente și ale stivei de apeluri cu care suntem familiarizați din mediul browser, ceea ce înseamnă că părțile JavaScript ale Node.js sunt cu un singur thread. Dar sarcina de nivel scăzut care necesită vorbirea cu un sistem de operare nu este cu un singur thread.

Sarcinile de nivel scăzut sunt delegate sistemului de operare prin libuv
Delegarea sarcinilor la nivel scăzut Node.js (previzualizare mare)

Când un apel este recunoscut de către Node.js ca fiind destinat libuv, acesta deleagă această sarcină lui libuv. În funcționarea sa, libuv necesită fire pentru unele dintre bibliotecile sale, de unde utilizarea pool-ului de fire în executarea programelor Node.js atunci când sunt necesare.

În mod implicit, pool-ul de fire Node.js furnizat de libuv are patru fire în el. Am putea crește sau reduce acest grup de fire apelând process.env.UV_THREADPOOL_SIZE în partea de sus a scriptului nostru.

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

Ce se întâmplă cu programul nostru de creare a fișierelor

Se pare că, odată ce invocăm codul pentru a crea fișierul nostru, Node.js atinge partea libuv a codului său, care dedică un fir pentru această sarcină. Această secțiune din libuv primește câteva informații statistice despre disc înainte de a lucra la fișier.

Finalizarea acestei verificări statistice poate dura ceva timp; prin urmare, firul este eliberat pentru alte sarcini până la finalizarea verificării statistice. Când verificarea este finalizată, secțiunea libuv ocupă orice fir disponibil sau așteaptă până când un fir devine disponibil pentru acesta.

Avem doar patru apeluri și patru fire, așa că există destule fire de discuție. Singura întrebare este cât de repede își va procesa sarcina fiecare fir. Vom observa că primul cod care îl introduce în pool-ul de fire își va returna primul rezultat și blochează toate celelalte fire în timp ce rulează codul său.

Concluzie

Înțelegem acum ce este Node.js. Știm că este un timp de rulare. Am definit ce este un timp de execuție. Și am săpat adânc în ceea ce alcătuiește timpul de execuție oferit de Node.js.

Am parcurs un drum lung. Și din micul nostru tur al depozitului Node.js de pe GitHub, putem explora orice API care ne-ar putea interesa, urmând același proces pe care l-am urmat aici. Node.js este open source, așa că cu siguranță ne putem arunca în sursă, nu-i așa?

Chiar dacă am atins câteva dintre nivelurile scăzute ale ceea ce se întâmplă în timpul de execuție Node.js, nu trebuie să presupunem că le știm pe toate. Resursele de mai jos indică câteva informații pe care ne putem construi cunoștințele:

  • Introducere în Node.js
    Fiind un site web oficial, Node.dev explică ce este Node.js, precum și managerii săi de pachete și enumeră cadrele web construite pe deasupra.
  • „JavaScript și Node.js”, Cartea pentru începători Node
    Această carte de Manuel Kiessling face o treabă fantastică de a explica Node.js, după ce a avertizat că JavaScript din browser nu este același cu cel din Node.js, chiar dacă ambele sunt scrise în aceeași limbă.
  • Începând cu Node.js
    Această carte pentru începători depășește o explicație a timpului de rulare. Învață despre pachete și fluxuri și despre crearea unui server web cu cadrul Express.
  • LibUV
    Aceasta este documentația oficială a codului C++ compatibil al rulării Node.js.
  • V8
    Aceasta este documentația oficială a motorului JavaScript care face posibilă scrierea Node.js cu JavaScript.