探索 Node.js 內部結構

已發表: 2022-03-10
快速總結 ↬ Node.js 對 Web 開發人員來說是一個有趣的工具。 憑藉其高水平的並發性,它已成為人們選擇用於 Web 開發的工具的主要候選者。 在本文中,我們將了解 Node.js 的組成部分,給它一個有意義的定義,了解 Node.js 的內部如何相互交互,並在 GitHub 上探索 Node.js 的項目存儲庫。

自從 Ryan Dahl 於 2009 年 11 月 8 日在歐洲 JSConf 上介紹 Node.js 以來,它已經在整個科技行業得到廣泛使用。 Netflix、Uber 和 LinkedIn 等公司相信 Node.js 可以承受大量流量和並發性。

掌握了基礎知識後,Node.js 的初學者和中級開發人員會遇到很多事情:“這只是一個運行時!” “它有事件循環!” “Node.js 像 JavaScript 一樣是單線程的!”

雖然其中一些說法是正確的,但我們將更深入地研究 Node.js 運行時,了解它如何運行 JavaScript,看看它是否真的是單線程的,最後,更好地理解它的核心依賴項、V8 和 libuv 之間的互連.

先決條件

  • JavaScript 基礎知識
  • 熟悉 Node.js 語義( requirefs

什麼是 Node.js?

假設許多人對 Node.js 的看法可能很誘人,最常見的定義是它是JavaScript 語言的運行時。 考慮到這一點,我們應該了解是什麼導致了這個結論。

Node.js 通常被描述為 C++ 和 JavaScript 的組合。 C++ 部分由運行低級代碼的綁定組成,使訪問連接到計算機的硬件成為可能。 JavaScript 部分將 JavaScript 作為其源代碼,並在該語言的流行解釋器(稱為 V8 引擎)中運行它。

有了這種理解,我們可以將 Node.js 描述為一種獨特的工具,它結合了 JavaScript 和 C++,可以在瀏覽器環境之外運行程序。

但我們真的可以稱它為運行時嗎? 為了確定這一點,讓我們定義什麼是運行時。

在他對 StackOverflow 的一個回答中,DJNA 將運行時環境定義為“執行程序所需的一切,但沒有工具可以改變它”。 根據這個定義,我們可以自信地說,當我們運行我們的代碼(無論使用任何語言)時發生的一切都是在運行時環境中運行的。

其他語言有自己的運行時環境。 對於 Java,它是 Java 運行時環境 (JRE)。 對於 .NET,它是公共語言運行時 (CLR)。 對於 Erlang,它是 BEAM。

然而,其中一些運行時具有依賴於它們的其他語言。 例如,Java 有 Kotlin,這是一種可以編譯為 JRE 可以理解的代碼的編程語言。 Erlang 有 Elixir。 我們知道 .NET 開發有許多變體,它們都在 CLR 中運行,稱為 .NET Framework。

現在我們了解運行時是為程序能夠成功執行提供的環境,並且我們知道 V8 和大量 C++ 庫使 Node.js 應用程序可以執行。 Node.js 本身是將所有內容綁定在一起以使這些庫成為一個實體的實際運行時,並且它只理解一種語言——JavaScript——而不管 Node.js 是用什麼構建的。

跳躍後更多! 繼續往下看↓

Node.js 的內部結構

當我們嘗試使用命令node index.js從命令行運行 Node.js 程序(例如index.js )時,我們正在調用 Node.js 運行時。 如前所述,此運行時包含兩個獨立的依賴項,V8 和 libuv。

核心 Node.js 依賴項
核心 Node.js 依賴項(大預覽)

V8 是由 Google 創建和維護的項目。 它需要 JavaScript 源代碼並在瀏覽器環境之外運行它。 當我們通過node命令運行程序時,源代碼由 Node.js 運行時傳遞給 V8 執行。

libuv 庫包含允許對操作系統進行低級訪問的 C++ 代碼。 V8 中默認不提供網絡、寫入文件系統和並發等功能,V8 是運行我們的 JavaScript 代碼的 Node.js 的一部分。 libuv 憑藉其一組庫,在 Node.js 環境中提供了這些實用程序以及更多功能。

Node.js 是將兩個庫結合在一起的粘合劑,從而成為一個獨特的解決方案。 在腳本的整個執行過程中,Node.js 了解將控制權傳遞給哪個項目以及何時傳遞。

服務器端程序的有趣 API

如果我們研究一下 JavaScript 的歷史,我們就會知道它旨在為瀏覽器中的頁面添加一些功能和交互。 在瀏覽器中,我們將與構成頁面的文檔對像模型 (DOM) 的元素進行交互。 為此,存在一組 API,統稱為 DOM API。

DOM 只存在於瀏覽器中; 它是被解析以呈現頁面的內容,它基本上是用稱為 HTML 的標記語言編寫的。 此外,瀏覽器存在於一個窗口中,因此window對像在 JavaScript 上下文中充當頁面上所有對象的根。 該環境稱為瀏覽器環境,是 JavaScript 的運行時環境。

Node.js API 為某些功能調用 libuv
Node.js API 與 libuv 交互(大預覽)

在 Node.js 環境中,我們沒有頁面,也沒有瀏覽器——這使我們對全局窗口對象的了解無效。 我們所擁有的是一組與操作系統交互以向 JavaScript 程序提供附加功能的 API。 Node.js 的這些 API( fspathbuffereventsHTTP等),正如我們所擁有的那樣,僅存在於 Node.js 中,它們由 Node.js (本身是一個運行時)提供,因此我們可以運行為 Node.js 編寫的程序。

實驗: fs.writeFile如何創建新文件

如果創建 V8 是為了在瀏覽器之外運行 JavaScript,並且如果 Node.js 環境沒有與瀏覽器相同的上下文或環境,那麼我們將如何執行諸如訪問文件系統或創建 HTTP 服務器之類的操作?

作為示例,讓我們以一個簡單的 Node.js 應用程序為例,它將文件寫入當前目錄中的文件系統:

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

如圖所示,我們正在嘗試將新文件寫入文件系統。 此功能在 JavaScript 語言中不可用; 它僅在 Node.js 環境中可用。 這是如何執行的?

為了理解這一點,讓我們瀏覽一下 Node.js 代碼庫。

前往 Node.js 的 GitHub 存儲庫,我們看到兩個主要文件夾, srcliblib文件夾包含 JavaScript 代碼,這些代碼提供了每個 Node.js 安裝默認包含的一組不錯的模塊。 src文件夾包含 libuv 的 C++ 庫。

如果我們查看lib文件夾並瀏覽fs.js文件,我們會發現它充滿了令人印象深刻的 JavaScript 代碼。 在第 1880 行,我們會注意到一個exports語句。 該語句通過導入fs模塊導出我們可以訪問的所有內容,我們可以看到它導出了一個名為writeFile的函數。

搜索function writeFile( (定義函數的地方)將我們帶到第 1303 行,在那裡我們看到函數定義有四個參數:

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

在第 1315 和 1324 行,我們看到一個函數writeAll在經過一些驗證檢查後被調用。 我們在同一個fs.js文件的第 1278 行找到了這個函數。

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

值得注意的是,這個模塊正在嘗試調用自己。 我們在第 1280 行看到了這一點,它正在調用fs.write 。 尋找write函數,我們會發現一點信息。

write函數從第 571 行開始,它運行了大約 42 行。 我們在這個函數中看到了一個重複的模式:它調用binding模塊上的函數的方式,如第 594 和 612 行所示。 binding模塊上的函數不僅在這個函數中被調用,而且在幾乎任何導出的函數中都被調用在fs.js文件文件中。 它一定有什麼特別之處。

binding變量在文件最頂部的第 58 行聲明,在 GitHub 的幫助下,單擊該函數調用會顯示一些信息。

綁定變量的聲明
綁定變量的聲明(大預覽)

這個internalBinding函數位於名為 loaders 的模塊中。 loaders模塊的主要功能是加載所有libuv庫,並通過V8項目與Node.js進行連接。 它是如何做到的相當神奇,但要了解更多信息,我們可以仔細查看fs模塊調用的writeBuffer函數。

我們應該看看它與 libuv 的聯繫,以及 V8 的來源。在加載程序模塊的頂部,一些很好的文檔說明了這一點:

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

我們在這裡了解到的是,對於從 Node.js 項目的 JavaScript 部分中的binding對象調用的每個模塊,在src文件夾中的 C++ 部分中都有一個等效的模塊。

從我們的fs之旅中,我們看到執行此操作的模塊位於node_file.cc中。 可以通過模塊訪問的每個函數都在文件中定義; 例如,我們在第 2258 行有writeBuffer 。該方法在 C++ 文件中的實際定義在第 1785 行。此外,對 libuv 進行實際寫入文件的部分的調用可以在第 1809 行找到,並且1815,libuv函數uv_fs_write被異步調用。

我們從這種理解中得到什麼?

就像許多其他解釋語言運行時一樣,Node.js 的運行時可能會被黑客入侵。 有了更深入的了解,我們可以通過查看源代碼來完成標準發行版無法完成的事情。 我們可以添加庫來更改某些函數的調用方式。 但最重要的是,這種理解是進一步探索的基礎。

Node.js 是單線程的嗎?

借助 libuv 和 V8,Node.js 可以訪問一些在瀏覽器中運行的典型 JavaScript 引擎所沒有的附加功能。

在瀏覽器中運行的任何 JavaScript 都將在單個線程中執行。 程序執行中的線程就像一個黑匣子,位於執行程序的 CPU 頂部。 在 Node.js 上下文中,一些代碼可以在我們的機器可以承載的盡可能多的線程中執行。

為了驗證這個特定的說法,讓我們探索一個簡單的代碼片段。

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

在上面的代碼片段中,我們試圖在當前目錄的磁盤上創建一個新文件。 為了查看這可能需要多長時間,我們添加了一個小基準來監控腳本的開始時間,它為我們提供了創建文件的腳本的持續時間(以毫秒為單位)。

如果我們運行上面的代碼,我們會得到這樣的結果:

在 Node.js 中創建單個文件所需時間的結果
在 Node.js 中創建單個文件所花費的時間(大預覽)
 $ node ./test.js -> 1 Done: 0.003s

這非常令人印象深刻:僅 0.003 秒。

但是讓我們做一些非常有趣的事情。 首先讓我們複製生成新文件的代碼,並更新日誌語句中的數字以反映它們的位置:

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

如果我們嘗試運行這段代碼,我們會得到一些讓我們大吃一驚的東西。 這是我的結果:

創建多個文件所需時間的結果
一次創建多個文件(大預覽)

首先,我們會注意到結果並不一致。 其次,我們看到時間增加了。 發生了什麼?

低級任務被委派

正如我們現在所知,Node.js 是單線程的。 Node.js 的一部分是用 JavaScript 編寫的,而其他部分是用 C++ 編寫的。 Node.js 使用了我們在瀏覽器環境中熟悉的事件循環和調用堆棧的相同概念,這意味著 Node.js 的 JavaScript 部分是單線程的。 但是需要與操作系統對話的低級任務不是單線程的。

低級任務通過 libuv 委託給操作系統
Node.js 低級任務委託(大預覽)

當 Node.js 將調用識別為針對 libuv 的調用時,它會將這個任務委託給 libuv。 在它的操作中,libuv 的一些庫需要線程,因此在需要時使用線程池來執行 Node.js 程序。

默認情況下,libuv 提供的 Node.js 線程池中有四個線程。 我們可以通過在腳本頂部調用process.env.UV_THREADPOOL_SIZE來增加或減少這個線程池。

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

我們的文件製作程序會發生什麼

看起來,一旦我們調用代碼來創建我們的文件,Node.js 就會命中其代碼的 libuv 部分,該部分專門為該任務分配一個線程。 libuv 中的這一部分在處理文件之前獲取有關磁盤的一些統計信息。

這種統計檢查可能需要一段時間才能完成; 因此,線程被釋放用於一些其他任務,直到完成統計檢查。 檢查完成後,libuv 部分會佔用任何可用線程或等待線程可用。

我們只有四個調用和四個線程,所以有足夠的線程可以運行。 唯一的問題是每個線程處理其任務的速度有多快。 我們會注意到,第一個進入線程池的代碼將首先返回其結果,並在運行其代碼時阻塞所有其他線程。

結論

我們現在了解 Node.js 是什麼。 我們知道這是一個運行時。 我們已經定義了運行時是什麼。 我們已經深入挖掘了 Node.js 提供的運行時的構成要素。

我們已經走了很長一段路。 從我們對 GitHub 上的 Node.js 存儲庫的小遊覽中,我們可以探索我們可能感興趣的任何 API,遵循我們在此處進行的相同過程。 Node.js 是開源的,所以我們當然可以深入研究源代碼,不是嗎?

儘管我們已經觸及了 Node.js 運行時中發生的一些低級別的事情,但我們不能假設我們知道這一切。 以下資源指向我們可以建立知識的一些信息:

  • Node.js 簡介
    作為一個官方網站,Node.dev 解釋了 Node.js 是什麼,以及它的包管理器,並列出了基於它構建的 Web 框架。
  • “JavaScript & Node.js”, Node 初學者書籍
    Manuel Kiessling 的這本書在解釋 Node.js 方面做得非常出色,因為它警告說瀏覽器中的 JavaScript 與 Node.js 中的不同,儘管兩者都是用相同的語言編寫的。
  • 開始 Node.js
    這本初學者書籍超越了對運行時的解釋。 它教授包和流以及使用 Express 框架創建 Web 服務器。
  • LibUV
    這是 Node.js 運行時的支持 C++ 代碼的官方文檔。
  • V8
    這是 JavaScript 引擎的官方文檔,它使使用 JavaScript 編寫 Node.js 成為可能。