帶你重新認識 Node

最初做 Node 的目的是什麼?

Node 作者 Ryan Dahl:

基於 V8 創建一個輕量級的高性能 Web 服務器並提供一套庫

爲什麼是 JavaScript?

Ryan Dahl 是一名資深的 C/C++ 程序員,創造出 Node 之前主要工作是圍繞 Web 高性能服務器進行的

他發現 Web 高性能服務器的兩個要點:

Ryan Dahl 也曾評估過使用 C、Lua、Haskell、Ruby 等語言作爲備選實現,得出以下結論:

JavaScript 的優勢:

Node 給 JavaScript 帶來的意義

除了 HTML、Webkit 和顯卡這些 UI 相關技術沒有支持外,Node 的結構與 Chrome 十分相似。他們都是基於事件驅動的異步架構:

在 Node 中,JavaScript 還被賦予了新的能力:

Node 使 JavaScript 可以運行在不同的地方,不再限制在瀏覽器中、DOM 樹打交道。如果 HTTP 協議是水平面,Node 就是瀏覽器在協議棧另一邊的倒影。

Node 不處理 UI,但用與瀏覽器相同的機制和原理運行,打破了 JavaScript 只能在瀏覽器中運行的局面。前後端編程環境統一,可以大大降低前後端轉換所需要的上下文代價。

Node 的特點

異步 I / O

var fs = require('fs'); 

fs.readFile('/path'function (err, file) { 
 console.log('讀取文件完成') 
}); 

console.log('發起讀取文件');

熟悉的用戶必知道,“讀取文件完成”是在 “發起讀取文件” 之後輸出的

fs.readFile 後的代碼是被立即執行的,而 “讀取文件完成” 的執行時間是不被預期的

只知道它將在這個異步操作後執行,但並不知道具體的時間點

異步調用中對於結果值的捕獲是符合 “Don't call me, I will call you” 原則的

這也是注重結果,不關心過程的一種表現

Node 中,絕大多數操作都以異步的方式進行調用,Ryan Dahl 排除萬難,在底層構建了很多異步 I / O 的 API,從文件讀取到網絡請求等。使開發者很已從語言層面很自然地進行並行 I / O 操作,在每個調用之間無需等待之前的 I / O 調用結束,在編程模型上可以極大提升效率

「注:異步 I / O 機制將在下文中詳細闡述」

事件與回調函數

「事件」

隨着 Web2.0 的到來,JavaScript 在前端擔任了更多的職責,時間也得到了廣泛的應用。將前端瀏覽器中廣泛應用且成熟的事件與回到函數引入後端,配合異步 I / O ,可以很好地將事件發生的時間點暴露給業務邏輯。

對於服務器綁定了 request 事件

對於請求對象,綁定了 data 和 end 事件

var http = require('http'); 
var querystring = require('querystring'); 

// 偵聽服務器的request事件 
http.createServer(function (req, res) { 
 var postData = ''; 
 req.setEncoding('utf8'); 
 // 偵聽請求的data事件 
 req.on('data'function (trunk) {
 postData += trunk;
 });

 // 偵聽請求的end事件
 req.on('end'function () { 
 res.end(postData); 
 }); 
}).listen(8080); 

console.log('服務器啓動完成');

發出請求後,只需關心請求成功時執行相應的業務邏輯即可

request({ 
 url: '/url', 
 method: 'POST', 
 data: {}, 
 success: function (data) { 
 // success事件 
 } 
});

事件的編程方式具有輕量級、松耦合、只關注事務點等優勢,但是在多個異步任務的場景下,事件與事件之間各自獨立,如何協作是一個問題,後續也出現了一系列異步編程解決方案:

「回調函數」

單線程

Node 保持了 JavaScript 在瀏覽器中單線程的特點

JavaScript 與其他線程是無法共享任何狀態的,最大的好處是不用像多線程編程那樣處處在意狀態的同步問題,這裏沒有死鎖的存在,也沒有線程上下文交換所帶來的性能上的開銷

跨平臺

起初 Node 只能在 Linux 平臺上運行,如果想在 Windows 平臺上學習和使用 Node,則必須通過 Cygwin / MinGW,後微軟投入通過基於 libuv 實現跨平臺架構

在操作系統與 Node 上層模塊系統之間構建了一層平臺架構

通過良好的架構,Node 的第三方 C++ 模塊也可以藉助 libuv 實現跨平臺

Node 模塊機制 - CommonJS

背景:

在其他高級語言中,Java 有類文件,Python 有 import 機制,Ruby 有 require,PHP 有 include 和 require。而 JavaScript 通過 script 標籤引入代碼的方式顯得雜亂無章,。人們不得不用命名空間等方式人爲地約束代碼,以達到安全和易用的目的。

直到後來出現了 CommonJS...

願景

希望 JavaScript 能在任何地方運行

出發點

對於 JavaScript 自身而言,它的規範依然是薄弱的,還有以下缺陷:

CommonJS 的提出,主要是爲了彌補當前 JavaScript 沒有標準的缺陷,以達到像 Python、Ruby 和 Java 具備開發大型應用的基礎能力,而不是停留在小腳本程序的階段,希望可以利用 JavaScript 開發:

CommonJS 規範涵蓋了:

Node 與瀏覽器以及 W3C 組織、CommonJS 組織、ECMAScript 之間的關係,共同構成了一個繁榮的生態系統

模塊規範

上下文提供了 exports 對象用於導出當前模塊的方法或者變量,並且它是導出的唯一出口

在模塊中,還存在一個 module 對象,它代表模塊自身,而 exports 是 module 的屬性

在 Node 中,一個文件就是一個模塊,將方法掛載在 exports 對象上作爲屬性即可定義導出的方式

// math.js
exports.add = function(a, b){
    return a + b;
}
const math = require('./math');

const res = math.add(1, 1);
console.log(res);
// 2

在 CommonJS 規範中,存在 require 方法,這個方法接受模塊標識,以此引入一個模塊的 API 到當前上下文中

模塊標識就是傳遞給 require 方法的參數,可以是:

模塊的定義十分簡單,接口也十分簡潔

每個模塊具有獨立的空間,它們互不干擾,在引用時也顯得乾淨利落

將類聚的方法和變量等限定在私有的作用域中,同時支持引入和導出功能以順暢地連接上下游依賴

模塊實現

在 Node 引入模塊,需要經歷以下三個步驟

Node 中模塊分爲兩類:

編譯過程中,編譯進了二進制執行文件

在 Node 進程啓動時,部分核心模塊就直接被加載進內存中,所以這部分核心模塊引入時,文件定位和編譯執行這兩個步驟可以省略,並且在路徑分析中優先判斷,所以它的加載速度是最快的。

運行時動態加載,需要完整的路徑分析、文件定位、編譯執行過程,速度比核心模塊慢

優先從緩存加載

與瀏覽器會緩存靜態腳本文件以提高性能一樣,Node 對引入過的模塊都會進行二次緩存,以減少二次引入時的開銷。不同點在於:

無論核心模塊還是文件模塊,require 方法對相同模塊的二次加載都一律採用緩存優先的方式

路徑分析和文件定位

「標識符分析(路徑)」

前面說到過,require 方法接受一個參數作爲標識符,分爲以下幾類:

優先級僅次於緩存加載,在 Node 的源代碼編譯過程中已編譯爲二進制代碼,加載過程最快

「注:加載一個與核心模塊標識符相同的自定義模塊是不會成功的,只能通過選擇不同的標識符 / 換用路徑的方式實現」

以 ./ 、../ 開頭的標識符都被當做文件模塊處理

require 方法會將路徑轉爲真實路徑,並以真實路徑爲索引,將編譯執行後的結果存放到緩存中,以使二次加載更快

文件模塊給 Node 指明瞭確切的文件位置,所以在查找過程中可以節約大量時間,其加載速度僅慢於核心模塊

是一種特殊的文件模塊,是一個文件或者包的形式

這類模塊的查找是最費時的,也是最慢的一種

先介紹一下模塊路徑這個概念,也是定位文件模塊時制定的查找策略,具體表現爲一個路徑組成的數組

['/home/bytedance/reasearch/node_modules',

'/home/bytedance/node_modules',

'home/node_module', /node_modules']

可以看出規則如下:

它的生成方式與 JavaScript 原型鏈 / 作用域鏈的查找方式十分類似

在加載過程中,Node 會逐個嘗試模塊路徑中的路徑,直到找到目標文件

文件路徑越深,模塊查找耗時會越多,這是自定義模塊的加載速度最慢的原因

「文件定位」

require 分析標識符會出現不包含文件擴展名的情況

會按. js、.json、.node 的次序補足擴展名,一次嘗試

過程中,需調用 fs 模塊同步阻塞地判斷文件是否存在,Node 單線程因此會引起性能問題

如果是. node / .json 文件帶上擴展名能加快點速度,配合緩存機制,可大幅緩解 Node 單線程阻塞調用的缺陷

分析標識符的過程中可能沒有找到文件,卻得到一個目錄,則會將目錄當做一個包來處理

通過解析 package.json 文件對應該包的 main 屬性指定的文件名

如果 main 相應文件解析錯誤 / 沒有 package.json 文件,node 會將 index 作爲文件名

一次查找 index.js  index.json  index.node

該目錄沒有定位成功則進行下一個模塊路徑進行查找

直到模塊路徑數組都被遍歷完依然沒有查找到目標文件則拋出異常

模塊編譯

在 Node 中,每個文件模塊都是一個對象

function Module(id, parent) { 
 this.id = id; 
 this.exports = {}; 
 this.parent = parent; 
 if (parent && parent.children) { 
     parent.children.push(this); 
 } 
 this.filename = null; 
 this.loaded = false; 
 this.children = []; 
}

每一個編譯成功的模塊都會將其文件路徑作爲索引存在 Module.cache 對象上,以提高二次引入的性能

包與 NPM

Node 組織了自身核心模塊,也使得第三方文件模塊可以有序地編寫和使用

但是在第三方模塊中,模塊與模塊之間仍然是散列在各地的,相互之間不能直接引用

而在模塊之外,包和 NPM 則是將模塊聯繫起來的一種機制

一定程度上解決了變量依賴、依賴關係等代碼組織性問題

包結構

包實際上是一個存檔文件,即一個目錄直接打包爲一個. zip/tar.gz 格式的文件,安裝後解壓還原爲目錄

包描述文件

package.json

CommonJS 爲 package.json 定義瞭如下一些必須的字段

包規範的定義可以幫助 Node 解決依賴包安裝的問題,而 NPM 正是基於該規範進行了實現

NPM 常用功能

CommonJS 包規範是理論,NPM 是其中一種實踐

NPM 於 Node,相當於 gem 於 Ruby,pear 於 PHP

幫助完成了第三方模塊的發佈、安裝和依賴等

  1. 查看幫助
  1. 安裝依賴包
npm install {packageName}

執行該命令後,NPM 會在當前目錄下創建 node_modules 目錄下創建包目錄,接着將相應的包解壓到這個目錄下

npm install {packageName} -g

全局模式並不是將一個模塊包安裝爲一個全局包的意思,它並不意味着可以從任何地方 reuqire 它

全局模式這個稱謂並不精確,-g 實際上是將一個包安裝爲全局可用的執行命令

它根據包描述文件中的 bin 字段配置,將實際腳本鏈接到與 Node 可執行文件相同的路徑下

對於一些沒有發佈到 NPM 上的包,或者因爲網絡原因無法直接安裝的包

可以通過將包下載到本地,然後本地安裝

npm install <tarball file>
npm install <tarball url>
npm install folder>

如果不能通過官方源安裝,可以通過鏡像源安裝

npm install --registry={urlResource}

如果使用過程中幾乎全使用鏡像源,可以指定默認源

npm config set registry {urlResource}
  1. NPM 鉤子命令

package.json 中 scripts 字段的提出就是讓包在安裝或者卸載等過程中提供鉤子機制

"scripts":{
    "preinstall""preinstall.js",
    "install""install.js",
    "uninstall""uninstall.js",
    "test""test.js",
}

局域 NPM

企業的限制在於,一方面需要享受到模塊開發帶來的低耦合和項目組織上的好處,另一方面卻要考慮模塊保密性的問題。所以,通過 NPM 共享和發佈存在潛在的風險。

爲了同時能夠享受到 NPM 上衆多的包,同時對自己的包進行保密和限制,現有的解決方案就是企業搭建自己的 NPM 倉庫,NPM 無論是它的服務端和客戶端都是開源的。

局域 NPM 倉庫的搭建方法與搭建鏡像站的方式幾乎一樣,與鏡像倉庫不同的地方在於可以選擇不同步官方源倉庫中的包

異步 I / O

爲什麼需要異步 I / O ?

瀏覽器中 JavaScript 在單線程上執行,還和 UI 渲染共用一個線程

《高性能 JavaScript》曾總結過,如果腳本執行的時間超過 100ms 用戶就會感到頁面卡頓

如果網頁臨時需要獲取一個網絡資源,通過同步的方式獲取,JS 需要等資源完全從服務器獲取後才能繼續執行,這期間 UI 將停頓,不響應用戶的交互行爲。可以想象,這樣的用戶體驗將會多差。

而採用異步請求,JavaScript 和 UI 的執行都不會處於等待狀態,給用戶一個鮮活的頁面

I / O 是昂貴的,分佈式 I / O 是更昂貴的

只有後端能夠快速響應資源,才能讓前端體驗變好

計算機在發展過程中將組件進行了抽象,分爲了 I / O 設備和計算設備

假設業務場景有一組互不相關的任務需要完成,主流方法有兩種:

  1. 多線程並行完成

多線程的代價在於創建線程和執行線程上下文切換的開銷較大。

在複雜的業務中經常面臨鎖、狀態同步等問題。但是多線程在多核 CPU 上能夠有效提升 CPU 利用率

  1. 單線程串行依次執行

單線程順序執行任務比較符合編程人員按順序思考的思維方式,依然是主流的編程方式

串行執行的缺點在於性能,任意一個略慢的任務都會導致後續執行代碼被阻塞

在計算機資源中,通常 I / O 與 CPU 計算是可以並行的,同步編程模型導致的問題是,I / O 的進行會讓後續任務等待,這造成資源不能更好地被利用

  1. Node 在兩者之間給出了它的答案

利用單線程,遠離多線程死鎖、狀態同步等問題;

利用異步 I / O,讓單線程可以遠離阻塞,更好地使用 CPU

爲了彌補單線程無法利用多核 CPU 的缺點,Node 提供了類似前端瀏覽器中 Web Workers 的子進程,該子進程可以通過工作進程高效地利用 CPU 和 I / O

異步 I / O 的提出是期望 I / O 的調用不再阻塞後續運算,將原有等待 I / O 完成的這段時間分配給其餘需要的業務去執行

異步 I / O 現狀

異步 I / O 與非阻塞 I / O

操作系統內核對於 I / O 方式只有兩種:阻塞與非阻塞

在調用阻塞 I / O 時,應用程序需要等待 I / O 完成才返回結果

特點:調用之後一定要等到系統內核層面完成所有操作後調用才結束

例子:系統內核在完成磁盤尋道、讀取數據、複製數據到內幕才能中之後,這個調用才結束》

非阻塞 I / O 與阻塞 I / O 的差別爲調用之後會立即返回

非阻塞 I / O 返回之後,CPU 的時間片可以用來處理其他事務,此時的性能提升是明顯的

存在的問題:

主要的輪詢技術

它是最原始、性能最低的一種,通過重複調用檢查 I / O 的狀態來完成數據的完整讀取

在得到最終數據前,CPU 一直耗用在等待上

它是在 read 的基礎上改進的一種方案,通過對文件描述符上的事件狀態來進行判斷

限制:它採用一個 1024 長度的數組來存儲狀態,最多可以同時檢查 1024 個文件描述符

較 select 有所改進,採用鏈表的方式避免數組長度的限制,其次它能避免不需要的檢查

文件描述符較多時,它的性能還是十分低下的

該方案是 Linux 下效率最高的 I / O 事件通知機制,在進入輪詢的時候如果沒有檢查到 I / O 事件,將會進行休眠,直到事件將它喚醒。它是真實利用了事件通知、執行回調的方式,而不是遍歷查詢,所以不會浪費 CPU,執行效率較高

理想的非阻塞異步 I / O

儘管 epoll 已經利用了時間來降低 CPU 的耗用,但是休眠期間 CPU 幾乎是限制的,對於當前線程而言利用率不夠

完美的異步 I / O 應該是應用程序發起非阻塞調用,無需通過遍歷或者時間喚醒等方式輪詢

可以直接處理下一個任務,只需在 I / O 完成後通過信號或回調將數據傳遞給應用程序即可

Linux 下存在原生提供的一種異步 I / O 方式(AIO)就是通過信號或者回調來傳遞數據的

缺點:

注:關於 O_DIRECT

現實的異步 I / O

通過讓部分線程進行阻塞 I / O 或者非阻塞 I / O 加輪詢技術來完成數據獲取,讓一個線程進行計算處理,通過線程之間的通信將 I / O 得到的數據進行傳遞,這就輕鬆實現了異步 I / O(儘管它是模擬的

Node 的異步 I / O

Node 完成整個異步 I / O 環節的有事件循環、觀察者和請求對象等

事件循環

着重強調一下 Node 自身的執行模型——事件循環

Node 進程啓動時,會創建一個類似 while(true) 的循環

每次循環體的過程稱之爲 Tick,每個 Tick 的過程就是查看是否有事件待處理

如果有就取出事件及其相關的回調函數,並執行它們

觀察者

每個事件循環中有一個或多個觀察者,而判斷是否有事件要處理的過程就是向這些觀察者詢問是否有要處理的事件

小結

事件循環、觀察者、請求對象、I / O 線程池這四者共同構成了 NOde 異步 I / O 模型的基本要素

由於我們知道 JavaScipt 是單線程的,所以按嘗試很容易理解它不能充分利用多核 CPU

事實上在 Node 中,除了 JavaScript 是單線程外,Node 自身其實是多喜愛暱稱的,只是 I / O 線程使用的 CPU 較少

另一個需要注意的點是,除了用戶代碼無法並行執行以外,所有的 I / O 是可以並行執行的

注:圖爲 Node 整個異步 I / O 過程

事件驅動與高性能服務器

前面對異步的講解,也基本勾勒出了事件驅動的實質,即通過主循環加事件觸發的方式來運行程序

下面爲幾種經典的服務器模型:

事件驅動帶來的高效已經漸漸開始爲業界所重視

知名服務器 Nginx 也摒棄了多線程的方式,採用和 Node 相同的事件驅動

不同之處在於 Nginx 採用純 C 寫成,性能較高,但是它僅適合於做 Web 服務器,用於反向代理或者負載均衡服務,在業務處理方面較爲欠缺

Node 則是一套高性能平臺,可以利用它構建與 Nginx 相同的功能,也可以處理各種具體業務

Node 沒有 Nginx 在 Web 服務器方面那麼專業,但場景更大,自身性能也不錯

在實際項目中可以結合它們各自的優點以達到應用的最優性能

JavaScript 在服務器端近乎空白,使得 Node 沒有任何歷史包袱,而 Node 在性能優化上的表現使得它一下子就在社區中流行了起來~

公衆號:前端食堂

知乎:童歐巴

掘金:童歐巴

這是一個終身學習的男人,他在堅持自己熱愛的事情,歡迎你加入前端食堂,和這個男人一起開心的變胖~

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Veq5ZTylp9cbGmD8JUzapw