Menjelajahi Internal Node.js

Diterbitkan: 2022-03-10
Ringkasan cepat Node.js adalah alat yang menarik untuk pengembang web. Dengan tingkat konkurensi yang tinggi, ia telah menjadi kandidat utama bagi orang-orang yang memilih alat untuk digunakan dalam pengembangan web. Dalam artikel ini, kita akan belajar tentang apa yang membentuk Node.js, memberikan definisi yang bermakna, memahami bagaimana internal Node.js berinteraksi satu sama lain, dan menjelajahi repositori proyek untuk Node.js di GitHub.

Sejak diperkenalkannya Node.js oleh Ryan Dahl di JSConf Eropa pada 8 November 2009, Node.js telah digunakan secara luas di seluruh industri teknologi. Perusahaan seperti Netflix, Uber, dan LinkedIn memberikan kredibilitas pada klaim bahwa Node.js dapat bertahan dalam jumlah lalu lintas dan konkurensi yang tinggi.

Berbekal pengetahuan dasar, pengembang Node.js pemula dan menengah berjuang dengan banyak hal: “Ini hanya runtime!” “Ini memiliki loop acara!” “Node.js adalah utas tunggal seperti JavaScript!”

Meskipun beberapa klaim ini benar, kami akan menggali lebih dalam runtime Node.js, memahami cara menjalankan JavaScript, melihat apakah itu benar-benar single-threaded, dan, akhirnya, lebih memahami interkoneksi antara dependensi intinya, V8 dan libuv .

Prasyarat

  • Pengetahuan dasar tentang JavaScript
  • Keakraban dengan semantik Node.js ( require , fs )

Apa itu Node.js?

Mungkin tergoda untuk mengasumsikan apa yang diyakini banyak orang tentang Node.js, definisi yang paling umum adalah bahwa itu adalah runtime untuk bahasa JavaScript . Untuk mempertimbangkan ini, kita harus memahami apa yang menyebabkan kesimpulan ini.

Node.js sering digambarkan sebagai kombinasi C++ dan JavaScript. Bagian C++ terdiri dari binding yang menjalankan kode tingkat rendah yang memungkinkan untuk mengakses perangkat keras yang terhubung ke komputer. Bagian JavaScript mengambil JavaScript sebagai kode sumbernya dan menjalankannya dalam penerjemah bahasa yang populer, bernama mesin V8.

Dengan pemahaman ini, kita dapat menggambarkan Node.js sebagai alat unik yang menggabungkan JavaScript dan C++ untuk menjalankan program di luar lingkungan browser.

Tapi bisakah kita benar-benar menyebutnya runtime? Untuk menentukan itu, mari kita definisikan apa itu runtime.

Dalam salah satu jawabannya di StackOverflow, DJNA mendefinisikan lingkungan runtime sebagai "semua yang Anda butuhkan untuk menjalankan program, tetapi tidak ada alat untuk mengubahnya". Menurut definisi ini, kami dapat dengan yakin mengatakan bahwa semua yang terjadi saat kami menjalankan kode kami (dalam bahasa apa pun) berjalan di lingkungan runtime.

Bahasa lain memiliki lingkungan runtime mereka sendiri. Untuk Java, ini adalah Java Runtime Environment (JRE). Untuk .NET, ini adalah Common Language Runtime (CLR). Untuk Erlang, itu adalah BEAM.

Namun demikian, beberapa runtime ini memiliki bahasa lain yang bergantung padanya. Misalnya, Java memiliki Kotlin, bahasa pemrograman yang dikompilasi ke kode yang dapat dipahami oleh JRE. Erlang memiliki Elixir. Dan kita tahu ada banyak varian untuk pengembangan .NET, yang semuanya berjalan di CLR, yang dikenal sebagai .NET Framework.

Sekarang kita memahami bahwa runtime adalah lingkungan yang disediakan untuk sebuah program agar dapat dieksekusi dengan sukses, dan kita tahu bahwa V8 dan sejumlah library C++ memungkinkan aplikasi Node.js untuk dieksekusi. Node.js sendiri adalah runtime aktual yang mengikat semuanya bersama-sama untuk membuat library tersebut menjadi entitas, dan ia hanya memahami satu bahasa — JavaScript — terlepas dari apa Node.js dibuat.

Lebih banyak setelah melompat! Lanjutkan membaca di bawah ini

Struktur Internal Node.js

Saat kami mencoba menjalankan program Node.js (seperti index.js ) dari baris perintah kami menggunakan perintah node index.js , kami memanggil runtime Node.js. Runtime ini, seperti yang disebutkan, terdiri dari dua dependensi independen, V8 dan libuv.

Ketergantungan inti Node.js
Ketergantungan Inti Node.js (Pratinjau besar)

V8 adalah proyek yang dibuat dan dikelola oleh Google. Dibutuhkan kode sumber JavaScript dan menjalankannya di luar lingkungan browser. Saat kita menjalankan program melalui perintah node , kode sumber diteruskan oleh runtime Node.js ke V8 untuk dieksekusi.

Pustaka libuv berisi kode C++ yang memungkinkan akses tingkat rendah ke sistem operasi. Fungsionalitas seperti jaringan, penulisan ke sistem file, dan konkurensi tidak dikirimkan secara default di V8, yang merupakan bagian dari Node.js yang menjalankan kode JavaScript kami. Dengan kumpulan pustakanya, libuv menyediakan utilitas ini dan lebih banyak lagi di lingkungan Node.js.

Node.js adalah perekat yang menyatukan kedua perpustakaan, sehingga menjadi solusi yang unik. Sepanjang eksekusi skrip, Node.js memahami proyek mana yang harus melewati kontrol dan kapan.

API Menarik Untuk Program Sisi Server

Jika kita mempelajari sedikit sejarah JavaScript, kita akan tahu bahwa itu dimaksudkan untuk menambahkan beberapa fungsi dan interaksi ke halaman di browser. Dan di browser, kita akan berinteraksi dengan elemen model objek dokumen (DOM) yang membentuk halaman. Untuk ini, ada satu set API, yang secara kolektif disebut sebagai DOM API.

DOM hanya ada di browser; itu adalah apa yang diuraikan untuk membuat halaman, dan pada dasarnya ditulis dalam bahasa markup yang dikenal sebagai HTML. Juga, browser ada di jendela, oleh karena itu objek window , yang bertindak sebagai root untuk semua objek di halaman dalam konteks JavaScript. Lingkungan ini disebut lingkungan browser, dan ini adalah lingkungan runtime untuk JavaScript.

API Node.js memanggil libuv untuk beberapa fungsi
API Node.js berinteraksi dengan libuv (Pratinjau besar)

Di lingkungan Node.js, kami tidak memiliki apa pun seperti halaman, atau browser — ini membatalkan pengetahuan kami tentang objek jendela global. Apa yang kami miliki adalah seperangkat API yang berinteraksi dengan sistem operasi untuk menyediakan fungsionalitas tambahan pada program JavaScript. API ini untuk Node.js ( fs , path , buffer , events , HTTP , dan sebagainya), seperti yang kita miliki, hanya ada untuk Node.js, dan mereka disediakan oleh Node.js (itu sendiri runtime) sehingga kita dapat menjalankan program yang ditulis untuk Node.js.

Eksperimen: Bagaimana fs.writeFile Membuat File Baru

Jika V8 dibuat untuk menjalankan JavaScript di luar browser, dan jika lingkungan Node.js tidak memiliki konteks atau lingkungan yang sama dengan browser, lalu bagaimana kita melakukan sesuatu seperti mengakses sistem file atau membuat server HTTP?

Sebagai contoh, mari kita ambil aplikasi Node.js sederhana yang menulis file ke sistem file di direktori saat ini:

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

Seperti yang ditunjukkan, kami mencoba menulis file baru ke sistem file. Fitur ini tidak tersedia dalam bahasa JavaScript; itu hanya tersedia di lingkungan Node.js. Bagaimana ini dieksekusi?

Untuk memahami ini, mari kita tur ke basis kode Node.js.

Menuju ke repositori GitHub untuk Node.js, kita melihat dua folder utama, src dan lib . Folder lib memiliki kode JavaScript yang menyediakan kumpulan modul bagus yang disertakan secara default dengan setiap instalasi Node.js. Folder src berisi pustaka C++ untuk libuv.

Jika kita melihat di folder lib dan menelusuri file fs.js , kita akan melihat bahwa itu penuh dengan kode JavaScript yang mengesankan. Pada baris 1880, kita akan melihat pernyataan exports . Pernyataan ini mengekspor semua yang dapat kita akses dengan mengimpor modul fs , dan kita dapat melihat bahwa itu mengekspor fungsi bernama writeFile .

Mencari function writeFile( (di mana fungsi didefinisikan) membawa kita ke baris 1303, di mana kita melihat bahwa fungsi didefinisikan dengan empat parameter:

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

Pada baris 1315 dan 1324, kita melihat bahwa satu fungsi, writeAll , dipanggil setelah beberapa pemeriksaan validasi. Kami menemukan fungsi ini pada baris 1278 dalam file fs.js yang sama.

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

Menarik juga untuk dicatat bahwa modul ini mencoba memanggil dirinya sendiri. Kami melihat ini pada baris 1280, di mana ia memanggil fs.write . Mencari fungsi write , kami akan menemukan sedikit informasi.

Fungsi write dimulai pada baris 571, dan berjalan sekitar 42 baris. Kami melihat pola berulang dalam fungsi ini: cara memanggil fungsi pada modul binding , seperti yang terlihat pada baris 594 dan 612. Fungsi pada modul binding dipanggil tidak hanya dalam fungsi ini, tetapi hampir semua fungsi yang diekspor dalam file file fs.js Pasti ada sesuatu yang sangat istimewa tentangnya.

Variabel binding dideklarasikan pada baris 58, di bagian paling atas file, dan klik pada pemanggilan fungsi tersebut mengungkapkan beberapa informasi, dengan bantuan GitHub.

Deklarasi variabel yang mengikat
Deklarasi variabel yang mengikat (Pratinjau besar)

Fungsi internalBinding ini ditemukan dalam modul bernama loader. Fungsi utama modul loader adalah memuat semua pustaka libuv dan menghubungkannya melalui proyek V8 dengan Node.js. Cara melakukannya agak ajaib, tetapi untuk mempelajari lebih lanjut kita dapat melihat lebih dekat pada fungsi writeBuffer yang dipanggil oleh modul fs .

Kita harus melihat di mana ini terhubung dengan libuv, dan di mana V8 masuk. Di bagian atas modul loader, beberapa dokumentasi bagus di sana menyatakan ini:

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

Apa yang kita pelajari di sini adalah bahwa untuk setiap modul yang dipanggil dari objek binding di bagian JavaScript dari proyek Node.js, ada padanannya di bagian C++, di folder src .

Dari tur fs kami, kami melihat bahwa modul yang melakukan ini terletak di node_file.cc . Setiap fungsi yang dapat diakses melalui modul didefinisikan dalam file; misalnya, kita memiliki writeBuffer pada baris 2258. Definisi sebenarnya dari metode tersebut dalam file C++ adalah pada baris 1785. Juga, panggilan ke bagian libuv yang melakukan penulisan sebenarnya ke file dapat ditemukan pada baris 1809 dan 1815, di mana fungsi uv_fs_write disebut asynchronous.

Apa yang Kita Dapatkan Dari Pemahaman Ini?

Sama seperti banyak runtime bahasa yang ditafsirkan lainnya, runtime Node.js dapat diretas. Dengan pemahaman yang lebih besar, kita dapat melakukan hal-hal yang tidak mungkin dilakukan dengan distribusi standar hanya dengan melihat melalui sumbernya. Kita bisa menambahkan perpustakaan untuk membuat perubahan pada cara beberapa fungsi dipanggil. Namun di atas semua itu, pemahaman ini merupakan landasan untuk eksplorasi lebih lanjut.

Apakah Node.js Single-Threaded?

Duduk di libuv dan V8, Node.js memiliki akses ke beberapa fungsi tambahan yang tidak dimiliki mesin JavaScript biasa yang berjalan di browser.

JavaScript apa pun yang berjalan di browser akan dieksekusi dalam satu utas. Sebuah utas dalam eksekusi program seperti kotak hitam yang berada di atas CPU tempat program dijalankan. Dalam konteks Node.js, beberapa kode dapat dieksekusi dalam utas sebanyak yang dapat dibawa oleh mesin kita.

Untuk memverifikasi klaim khusus ini, mari jelajahi cuplikan kode sederhana.

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

Dalam cuplikan di atas, kami mencoba membuat file baru pada disk di direktori saat ini. Untuk melihat berapa lama waktu yang dibutuhkan, kami telah menambahkan sedikit patokan untuk memantau waktu mulai skrip, yang memberi kami durasi dalam milidetik skrip yang membuat file.

Jika kita menjalankan kode di atas, kita akan mendapatkan hasil seperti ini:

Hasil waktu yang dibutuhkan untuk membuat satu file di Node.js
Waktu yang dibutuhkan untuk membuat satu file di Node.js (Pratinjau besar)
 $ node ./test.js -> 1 Done: 0.003s

Ini sangat mengesankan: hanya 0,003 detik.

Tapi mari kita lakukan sesuatu yang sangat menarik. Pertama mari kita duplikat kode yang menghasilkan file baru, dan perbarui nomor dalam pernyataan log untuk mencerminkan posisinya:

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

Jika kami mencoba menjalankan kode ini, kami akan mendapatkan sesuatu yang mengejutkan kami. Inilah hasil saya:

Hasil waktu yang dibutuhkan untuk membuat banyak file
Membuat banyak file sekaligus (Pratinjau besar)

Pertama, kita akan melihat bahwa hasilnya tidak konsisten. Kedua, kita melihat bahwa waktu telah meningkat. Apa yang terjadi?

Tugas Tingkat Rendah Didelegasikan

Node.js adalah single-threaded, seperti yang kita ketahui sekarang. Bagian dari Node.js ditulis dalam JavaScript, dan lainnya dalam C++. Node.js menggunakan konsep yang sama dari loop peristiwa dan tumpukan panggilan yang kita kenal dari lingkungan browser, yang berarti bahwa bagian JavaScript dari Node.js adalah single-threaded. Tetapi tugas tingkat rendah yang mengharuskan berbicara dengan sistem operasi bukanlah tugas tunggal.

Tugas tingkat rendah didelegasikan ke OS melalui libuv
Delegasi tugas tingkat rendah Node.js (Pratinjau besar)

Saat panggilan dikenali oleh Node.js sebagai ditujukan untuk libuv, ia mendelegasikan tugas ini ke libuv. Dalam pengoperasiannya, libuv membutuhkan thread untuk beberapa library-nya, oleh karena itu penggunaan thread pool dalam mengeksekusi program Node.js saat dibutuhkan.

Secara default, kumpulan utas Node.js yang disediakan oleh libuv memiliki empat utas di dalamnya. Kami dapat menambah atau mengurangi kumpulan utas ini dengan memanggil process.env.UV_THREADPOOL_SIZE di bagian atas skrip kami.

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

Apa Yang Terjadi Dengan Program Pembuatan File Kami

Tampaknya begitu kita memanggil kode untuk membuat file kita, Node.js menyentuh bagian libuv dari kodenya, yang mendedikasikan utas untuk tugas ini. Bagian ini di libuv mendapatkan beberapa informasi statistik tentang disk sebelum mengerjakan file.

Pemeriksaan statistik ini bisa memakan waktu cukup lama; karenanya, utas dilepaskan untuk beberapa tugas lain hingga pemeriksaan statistik selesai. Ketika pemeriksaan selesai, bagian libuv menempati utas yang tersedia atau menunggu hingga utas tersedia untuk itu.

Kami hanya memiliki empat panggilan dan empat utas, jadi ada cukup utas untuk diputar. Satu-satunya pertanyaan adalah seberapa cepat setiap utas akan memproses tugasnya. Kami akan melihat bahwa kode pertama yang membuatnya menjadi kumpulan utas akan mengembalikan hasilnya terlebih dahulu, dan itu memblokir semua utas lainnya saat menjalankan kodenya.

Kesimpulan

Sekarang kita mengerti apa itu Node.js. Kami tahu ini adalah runtime. Kami telah mendefinisikan apa itu runtime. Dan kami telah menggali jauh ke dalam apa yang membentuk runtime yang disediakan oleh Node.js.

Kami telah datang jauh. Dan dari tur kecil kami di repositori Node.js di GitHub, kami dapat menjelajahi API apa pun yang mungkin kami minati, mengikuti proses yang sama yang kami lakukan di sini. Node.js adalah open source, jadi tentunya kita bisa menyelami sumbernya, bukan?

Meskipun kita telah menyentuh beberapa level rendah dari apa yang terjadi di runtime Node.js, kita tidak boleh berasumsi bahwa kita mengetahui semuanya. Sumber daya di bawah ini menunjukkan beberapa informasi di mana kita dapat membangun pengetahuan kita:

  • Pengenalan Node.js
    Menjadi situs web resmi, Node.dev menjelaskan apa itu Node.js, serta manajer paketnya, dan mencantumkan kerangka kerja web yang dibangun di atasnya.
  • “JavaScript & Node.js”, Buku Pemula Node
    Buku karya Manuel Kiessling ini menjelaskan Node.js dengan sangat baik, setelah memperingatkan bahwa JavaScript di browser tidak sama dengan yang ada di Node.js, meskipun keduanya ditulis dalam bahasa yang sama.
  • Awal Node.js
    Buku pemula ini melampaui penjelasan tentang runtime. Ini mengajarkan tentang paket dan aliran dan membuat server web dengan kerangka kerja Express.
  • LibUV
    Ini adalah dokumentasi resmi dari kode C++ pendukung runtime Node.js.
  • V8
    Ini adalah dokumentasi resmi dari mesin JavaScript yang memungkinkan untuk menulis Node.js dengan JavaScript.