Eksploracja elementów wewnętrznych Node.js

Opublikowany: 2022-03-10
Szybkie podsumowanie ↬ Node.js to ciekawe narzędzie dla twórców stron internetowych. Dzięki wysokiemu poziomowi współbieżności stał się wiodącym kandydatem dla osób wybierających narzędzia do wykorzystania w tworzeniu stron internetowych. W tym artykule dowiemy się, co składa się na Node.js, nadamy mu sensowną definicję, zrozumiemy, w jaki sposób wewnętrzne elementy Node.js współdziałają ze sobą i zbadamy repozytorium projektu dla Node.js na GitHub.

Od czasu wprowadzenia Node.js przez Ryana Dahla na European JSConf w dniu 8 listopada 2009 r., jest on szeroko stosowany w branży technologicznej. Firmy takie jak Netflix, Uber i LinkedIn uwiarygodniają twierdzenie, że Node.js może wytrzymać duży ruch i współbieżność.

Uzbrojeni w podstawową wiedzę, początkujący i średniozaawansowani programiści Node.js zmagają się z wieloma rzeczami: „To tylko środowisko uruchomieniowe!” „Ma pętle wydarzeń!” „Node.js jest jednowątkowy jak JavaScript!”

Chociaż niektóre z tych twierdzeń są prawdziwe, zagłębimy się w środowisko uruchomieniowe Node.js, zrozumiemy, jak obsługuje JavaScript, sprawdzimy, czy rzeczywiście jest jednowątkowe, i wreszcie lepiej zrozumiemy wzajemne powiązania między jego podstawowymi zależnościami, V8 i libuv .

Warunki wstępne

  • Podstawowa znajomość JavaScript
  • Znajomość semantyki Node.js ( require , fs )

Co to jest Node.js?

Kuszące może być założenie tego, w co wielu ludzi wierzyło o Node.js, najczęstszą definicją tego jest to, że jest to środowisko uruchomieniowe dla języka JavaScript . Aby to rozważyć, powinniśmy zrozumieć, co doprowadziło do tego wniosku.

Node.js jest często opisywany jako połączenie C++ i JavaScript. Część C++ składa się z powiązań uruchamiających kod niskiego poziomu, który umożliwia dostęp do sprzętu podłączonego do komputera. Część JavaScript pobiera JavaScript jako swój kod źródłowy i uruchamia go w popularnym interpreterze języka, zwanym silnikiem V8.

Dzięki temu zrozumieniu moglibyśmy opisać Node.js jako unikalne narzędzie, które łączy JavaScript i C++ do uruchamiania programów poza środowiskiem przeglądarki.

Ale czy rzeczywiście moglibyśmy nazwać to środowiskiem wykonawczym? Aby to ustalić, zdefiniujmy, czym jest środowisko wykonawcze.

W jednej ze swoich odpowiedzi na StackOverflow, DJNA definiuje środowisko uruchomieniowe jako „wszystko, czego potrzebujesz do wykonania programu, ale bez narzędzi, aby to zmienić”. Zgodnie z tą definicją możemy śmiało powiedzieć, że wszystko, co dzieje się podczas uruchamiania naszego kodu (w dowolnym języku) działa w środowisku uruchomieniowym.

Inne języki mają swoje własne środowisko wykonawcze. W przypadku Javy jest to Java Runtime Environment (JRE). W przypadku platformy .NET jest to środowisko uruchomieniowe języka wspólnego (CLR). Dla Erlanga to BEAM.

Niemniej jednak niektóre z tych środowisk wykonawczych mają inne języki, które od nich zależą. Na przykład Java ma Kotlin, język programowania, który kompiluje się do kodu, który może zrozumieć JRE. Erlang ma Eliksir. Wiemy, że istnieje wiele wariantów rozwoju .NET, które działają w środowisku CLR, znanym jako .NET Framework.

Teraz rozumiemy, że środowisko wykonawcze jest środowiskiem umożliwiającym pomyślne wykonanie programu i wiemy, że wersja 8 i wiele bibliotek C++ umożliwiają wykonanie aplikacji Node.js. Sam Node.js jest rzeczywistym środowiskiem wykonawczym, które łączy wszystko razem, aby uczynić te biblioteki bytem i rozumie tylko jeden język — JavaScript — niezależnie od tego, z czym jest zbudowany Node.js.

Więcej po skoku! Kontynuuj czytanie poniżej ↓

Wewnętrzna struktura Node.js

Kiedy próbujemy uruchomić program Node.js (taki jak index.js ) z naszego wiersza poleceń za pomocą node index.js , wywołujemy środowisko uruchomieniowe Node.js. To środowisko wykonawcze, jak wspomniano, składa się z dwóch niezależnych zależności, V8 i libuv.

Podstawowe zależności Node.js
Podstawowe zależności Node.js (duży podgląd)

V8 to projekt stworzony i utrzymywany przez Google. Pobiera kod źródłowy JavaScript i uruchamia go poza środowiskiem przeglądarki. Kiedy uruchamiamy program za pomocą polecenia node , kod źródłowy jest przekazywany przez środowisko wykonawcze Node.js do wersji 8 w celu wykonania.

Biblioteka libuv zawiera kod C++, który umożliwia niskopoziomowy dostęp do systemu operacyjnego. Funkcje takie jak praca w sieci, zapisywanie w systemie plików i współbieżność nie są domyślnie dostarczane w wersji 8, która jest częścią Node.js, która uruchamia nasz kod JavaScript. Wraz ze swoim zestawem bibliotek, libuv dostarcza te narzędzia i więcej w środowisku Node.js.

Node.js to spoiwo, które spaja obie biblioteki, stając się tym samym unikalnym rozwiązaniem. Podczas wykonywania skryptu Node.js rozumie, do którego projektu i kiedy przekazać kontrolę.

Ciekawe interfejsy API dla programów po stronie serwera

Jeśli przyjrzymy się trochę historii JavaScript, dowiemy się, że ma to na celu dodanie funkcjonalności i interakcji do strony w przeglądarce. A w przeglądarce wchodzilibyśmy w interakcję z elementami modelu obiektowego dokumentu (DOM), które tworzą stronę. W tym celu istnieje zestaw interfejsów API, określanych zbiorczo jako DOM API.

DOM istnieje tylko w przeglądarce; to jest to, co jest analizowane w celu renderowania strony i jest zasadniczo napisane w języku znaczników znanym jako HTML. Ponadto przeglądarka istnieje w oknie, stąd obiekt window , który działa jako katalog główny dla wszystkich obiektów na stronie w kontekście JavaScript. To środowisko nazywa się środowiskiem przeglądarki i jest środowiskiem wykonawczym dla JavaScript.

Interfejsy API Node.js wywołują libuv dla niektórych funkcji
Interfejsy API Node.js współdziałają z libuv (duży podgląd)

W środowisku Node.js nie mamy nic takiego jak strona ani przeglądarka — to unieważnia naszą wiedzę na temat globalnego obiektu window. Mamy zestaw interfejsów API, które współdziałają z systemem operacyjnym w celu zapewnienia dodatkowej funkcjonalności programowi JavaScript. Te interfejsy API dla Node.js ( fs , path , buffer , events , HTTP itd.), tak jak je mamy, istnieją tylko dla Node.js i są dostarczane przez Node.js (sam w sobie środowisko uruchomieniowe), dzięki czemu potrafi uruchamiać programy napisane dla Node.js.

Eksperyment: Jak fs.writeFile tworzy nowy plik

Jeśli V8 został stworzony do uruchamiania JavaScript poza przeglądarką, a środowisko Node.js nie ma tego samego kontekstu lub środowiska co przeglądarka, to jak moglibyśmy uzyskać dostęp do systemu plików lub utworzyć serwer HTTP?

Jako przykład weźmy prostą aplikację Node.js, która zapisuje plik do systemu plików w bieżącym katalogu:

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

Jak pokazano, próbujemy zapisać nowy plik w systemie plików. Ta funkcja nie jest dostępna w języku JavaScript; jest dostępny tylko w środowisku Node.js. Jak to się dzieje?

Aby to zrozumieć, przejrzyjmy bazę kodu Node.js.

Kierując się do repozytorium GitHub dla Node.js, widzimy dwa główne foldery, src i lib . Folder lib zawiera kod JavaScript, który zapewnia ładny zestaw modułów, które są domyślnie dołączane do każdej instalacji Node.js. Folder src zawiera biblioteki C++ dla libuv.

Jeśli zajrzymy do folderu lib i przejrzymy plik fs.js , zobaczymy, że jest on pełen imponującego kodu JavaScript. W linii 1880 zauważymy oświadczenie o exports . Ta instrukcja eksportuje wszystko, do czego możemy uzyskać dostęp, importując moduł fs , i widzimy, że eksportuje ona funkcję o nazwie writeFile .

Szukanie function writeFile( (gdzie funkcja jest zdefiniowana) prowadzi nas do wiersza 1303, gdzie widzimy, że funkcja jest zdefiniowana z czterema parametrami:

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

W wierszach 1315 i 1324 widzimy, że pojedyncza funkcja writeAll , jest wywoływana po kilku sprawdzeniach poprawności. Funkcję tę znajdujemy w wierszu 1278 w tym samym pliku 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); } }); }

Warto również zauważyć, że ten moduł próbuje się wywołać. Widzimy to w linii 1280, gdzie wywołuje fs.write . Szukając funkcji write , odkryjemy trochę informacji.

Funkcja write zaczyna się w linii 571 i wykonuje około 42 linii. Widzimy powtarzający się wzorzec w tej funkcji: sposób, w jaki wywołuje ona funkcję w module binding , jak widać w wierszach 594 i 612. Funkcja w module binding jest wywoływana nie tylko w tej funkcji, ale praktycznie w każdej eksportowanej funkcji w pliku fs.js Musi być w tym coś wyjątkowego.

Zmienna binding jest zadeklarowana w linii 58, na samej górze pliku, a kliknięcie tego wywołania funkcji ujawnia pewne informacje za pomocą GitHub.

Deklaracja zmiennej wiążącej
Deklaracja zmiennej wiążącej (duży podgląd)

Ta funkcja internalBinding znajduje się w module o nazwie loaders. Główną funkcją modułu loaderów jest załadowanie wszystkich bibliotek libuv i połączenie ich poprzez projekt V8 z Node.js. Sposób, w jaki to robi, jest raczej magiczny, ale aby dowiedzieć się więcej, możemy przyjrzeć się bliżej funkcji writeBuffer , która jest wywoływana przez moduł fs .

Powinniśmy sprawdzić, gdzie łączy się to z libuv i gdzie pojawia się V8. Na górze modułu ładującego, dobra dokumentacja stwierdza, że:

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

Dowiadujemy się tutaj, że dla każdego modułu wywoływanego z obiektu binding w sekcji JavaScript projektu Node.js istnieje odpowiednik w sekcji C++, w folderze src .

Z naszej wycieczki po fs widzimy, że moduł, który to robi, znajduje się w node_file.cc . Każda funkcja dostępna przez moduł jest zdefiniowana w pliku; na przykład mamy writeBuffer w wierszu 2258. Faktyczna definicja tej metody w pliku C++ znajduje się w wierszu 1785. Również wywołanie tej części libuv, która dokonuje faktycznego zapisu do pliku, można znaleźć w wierszach 1809 i 1815, gdzie funkcja uv_fs_write jest wywoływana asynchronicznie.

Co zyskujemy z tego zrozumienia?

Podobnie jak wiele innych środowisk wykonawczych języka interpretowanego, środowisko uruchomieniowe Node.js może zostać zhakowane. Mając większe zrozumienie, moglibyśmy robić rzeczy, które są niemożliwe w standardowej dystrybucji, po prostu przeglądając źródła. Moglibyśmy dodać biblioteki, aby wprowadzić zmiany w sposobie wywoływania niektórych funkcji. Ale przede wszystkim to zrozumienie jest podstawą do dalszych poszukiwań.

Czy Node.js jest jednowątkowy?

Siedząc na libuv i V8, Node.js ma dostęp do kilku dodatkowych funkcjonalności, których nie posiada typowy silnik JavaScript działający w przeglądarce.

Każdy JavaScript uruchomiony w przeglądarce zostanie wykonany w jednym wątku. Wątek w wykonaniu programu jest jak czarna skrzynka na górze procesora, w którym program jest wykonywany. W kontekście Node.js, część kodu może być wykonywana w tylu wątkach, ile mogą udźwignąć nasze maszyny.

Aby zweryfikować to konkretne twierdzenie, przyjrzyjmy się prostemu fragmentowi kodu.

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

W powyższym fragmencie próbujemy utworzyć nowy plik na dysku w bieżącym katalogu. Aby zobaczyć, jak długo może to potrwać, dodaliśmy mały test porównawczy do monitorowania czasu uruchomienia skryptu, który podaje nam czas trwania skryptu tworzącego plik w milisekundach.

Jeśli uruchomimy powyższy kod, otrzymamy taki wynik:

Wynik czasu potrzebnego na utworzenie pojedynczego pliku w Node.js
Czas potrzebny na utworzenie pojedynczego pliku w Node.js (duży podgląd)
 $ node ./test.js -> 1 Done: 0.003s

To bardzo imponujące: tylko 0,003 sekundy.

Ale zróbmy coś naprawdę interesującego. Najpierw zduplikujmy kod, który generuje nowy plik, i zaktualizujmy numer w instrukcji log, aby odzwierciedlał ich pozycje:

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

Jeśli spróbujemy uruchomić ten kod, dostaniemy coś, co rozwali nasze umysły. Oto mój wynik:

Wynik czasu potrzebnego na utworzenie wielu plików
Tworzenie wielu plików na raz (duży podgląd)

Po pierwsze, zauważymy, że wyniki nie są spójne. Po drugie, widzimy, że czas się wydłużył. Co się dzieje?

Zadania niskiego poziomu są delegowane

Node.js jest jednowątkowy, jak wiemy teraz. Części Node.js są napisane w JavaScript, a inne w C++. Node.js wykorzystuje te same koncepcje pętli zdarzeń i stosu wywołań, które znamy ze środowiska przeglądarki, co oznacza, że ​​części JavaScript w Node.js są jednowątkowe. Ale zadanie niskiego poziomu, które wymaga mówienia z systemem operacyjnym, nie jest jednowątkowe.

Zadania niskiego poziomu są delegowane do systemu operacyjnego przez libuv
Delegowanie zadań niskopoziomowych Node.js (duży podgląd)

Gdy wywołanie zostanie rozpoznane przez Node.js jako przeznaczone dla libuv, deleguje to zadanie do libuv. W swoim działaniu libuv wymaga wątków dla niektórych swoich bibliotek, stąd wykorzystanie puli wątków do wykonywania programów Node.js, gdy są one potrzebne.

Domyślnie pula wątków Node.js dostarczana przez libuv zawiera cztery wątki. Możemy zwiększyć lub zmniejszyć tę pulę wątków, wywołując process.env.UV_THREADPOOL_SIZE w górnej części naszego skryptu.

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

Co się dzieje z naszym programem tworzenia plików?

Wygląda na to, że po wywołaniu kodu, aby utworzyć nasz plik, Node.js trafia do części libuv swojego kodu, która dedykuje wątek do tego zadania. Ta sekcja w libuv pobiera pewne informacje statystyczne o dysku przed rozpoczęciem pracy nad plikiem.

To statystyczne sprawdzenie może trochę potrwać; w związku z tym wątek jest zwalniany dla niektórych innych zadań, dopóki kontrola statystyczna nie zostanie zakończona. Po zakończeniu sprawdzania sekcja libuv zajmuje dowolny dostępny wątek lub czeka, aż wątek stanie się dla niego dostępny.

Mamy tylko cztery rozmowy i cztery wątki, więc jest wystarczająco dużo wątków do obejrzenia. Pytanie tylko, jak szybko każdy wątek wykona swoje zadanie. Zauważymy, że pierwszy kod, który trafi do puli wątków, jako pierwszy zwróci swój wynik i blokuje wszystkie inne wątki podczas uruchamiania swojego kodu.

Wniosek

Teraz rozumiemy, czym jest Node.js. Wiemy, że to środowisko wykonawcze. Zdefiniowaliśmy, czym jest środowisko wykonawcze. I zagłębiliśmy się w to, co składa się na środowisko wykonawcze dostarczane przez Node.js.

Przebyliśmy długą drogę. A z naszej krótkiej wycieczki po repozytorium Node.js na GitHub, możemy zbadać dowolny interfejs API, który może nas zainteresować, postępując zgodnie z tym samym procesem, który omówiliśmy tutaj. Node.js jest oprogramowaniem typu open source, więc z pewnością możemy zagłębić się w źródła, prawda?

Mimo że dotknęliśmy kilku niskich poziomów tego, co dzieje się w środowisku wykonawczym Node.js, nie możemy zakładać, że wiemy wszystko. Poniższe zasoby wskazują pewne informacje, na których możemy budować naszą wiedzę:

  • Wprowadzenie do Node.js
    Będąc oficjalną stroną internetową, Node.dev wyjaśnia, czym jest Node.js, a także jego menedżery pakietów, a także wymienia frameworki internetowe zbudowane na jego podstawie.
  • „JavaScript i Node.js”, książka dla początkujących węzłów
    Ta książka Manuela Kiesslinga fantastycznie wyjaśnia Node.js, po ostrzeżeniu, że JavaScript w przeglądarce nie jest taki sam jak ten w Node.js, mimo że oba są napisane w tym samym języku.
  • Początek Node.js
    Ta książka dla początkujących wykracza poza wyjaśnienie środowiska wykonawczego. Uczy o pakietach i strumieniach oraz tworzeniu serwera WWW za pomocą frameworka Express.
  • LibUV
    To jest oficjalna dokumentacja wspierającego kodu C++ środowiska uruchomieniowego Node.js.
  • V8
    To jest oficjalna dokumentacja silnika JavaScript, która umożliwia pisanie Node.js za pomocą JavaScript.