1. 程式人生 > 其它 >為什麼有些專案要用 Node.js超詳細分析

為什麼有些專案要用 Node.js超詳細分析

  Node.js 是什麼

  傳統意義上的 JavaScript 執行在瀏覽器上,這是因為瀏覽器核心實際上分為兩個部分:渲染引擎和 JavaScript 引擎。前者負責渲染 HTML + CSS,後者則負責執行 JavaScript。Chrome 使用的 JavaScript 引擎是 V8,它的速度非常快。

  Node.js 是一個執行在服務端的框架,它的底層就使用了 V8 引擎。我們知道 Apache + PHP 以及 Java 的 Servlet 都可以用來開發動態網頁,Node.js 的作用與他們類似,只不過是使用 JavaScript 來開發。

  從定義上介紹完後,舉一個簡單的例子,新建一個 app.js 檔案並輸入以下內容:

  var http=require('http');

  http.createServer(function (request, response) {

  response.writeHead(200, {'Content-Type': 'text/plain'}); // HTTP Response 頭部

  response.end('Hello World

  '); // 返回資料 “Hello World”}).listen(8888); // 監聽 8888 埠// 終端列印如下資訊console.log('Server running at 127.0.0.1:8888/');

  這樣,一個簡單的 HTTP Server 就算是寫完了,輸入 node app.js 即可執行,隨後訪問 便會看到輸出結果。

  為什麼要用 Node.js

  面對一個新技術,多問幾個為什麼總是好的。既然 PHP、Python、Java 都可以用來進行後端開發,為什麼還要去學習 Node.js?至少我們應該知道在什麼場景下,選擇 Node.js 更合適。

  總的來說,Node.js 適合以下場景:

  實時性應用,比如線上多人協作工具,網頁聊天應用等。

  以 I/O 為主的高併發應用,比如為客戶端提供 API,讀取資料庫。

  流式應用,比如客戶端經常上傳檔案。

  前後端分離。

  實際上前兩者可以歸結為一種,即客戶端廣泛使用長連線,雖然併發數較高,但其中大部分是空閒連線。

  Node.js 也有它的侷限性,它並不適合 CPU 密集型的任務,比如人工智慧方面的計算,視訊、圖片的處理等。

  當然,以上缺點不是信口開河,或者死記硬背,更不是人云亦云,需要我們對 Node.js 的原理有一定的瞭解,才能做出正確的判斷。

  基礎概念

  在介紹 Node.js 之前,理清楚一些基本概念有助於更深入的理解 Node.js 。

  併發

  與客戶端不同,服務端開發者非常關心的一項資料是併發數,也就是這臺伺服器最多能支援多少個客戶端的併發請求。早年的 C10K 問題就是討論如何利用單臺伺服器支援 10K 併發數。當然隨著軟硬體效能的提高,目前 C10K 已經不再是問題,我們開始嘗試解決 C10M 問題,即單臺伺服器如何處理百萬級的併發。

  在 C10K 提出時,我們還在使用 Apache 伺服器,它的工作原理是每當有一個網路請求到達,就 fork 出一個子程序並在子程序中執行 PHP 指令碼。執行完指令碼後再把結果發回客戶端。

  這樣可以確保不同程序之間互不干擾,即使一個程序出問題也不影響整個伺服器,但是缺點也很明顯:程序是一個比較重的概念,擁有自己的堆和棧,佔用記憶體較多,一臺伺服器能執行的程序數量有上限,大約也就在幾千左右。

  雖然 Apache 後來使用了 FastCGI,但本質上只是一個程序池,它減少了建立程序的開銷,但無法有效提高併發數。

  Java 的 Servlet 使用了執行緒池,即每個 Servlet 執行在一個執行緒上。執行緒雖然比程序輕量,但也是相對的。每個執行緒獨享的棧的大小是 1M,依然不夠高效。除此以外,多執行緒程式設計會帶來各種麻煩,這一點想必程式設計師們都深有體會。

  如果不使用執行緒,還有兩種解決方案,分別是使用協程(coroutine)和非阻塞 I/O。協程比執行緒更加輕量,多個協程可以執行在同一個執行緒中,並由程式設計師自己負責排程,這種技術在 Go 語言中被廣泛使用。而非阻塞 I/O 則被 Node.js 用來處理高併發的場景。

  非阻塞 I/O

  這裡所說的 I/O 可以分為兩種: 網路 I/O 和檔案 I/O,實際上兩者高度類似。 I/O 可以分為兩個步驟,首先把檔案(網路)中的內容拷貝到緩衝區,這個緩衝區位於作業系統獨佔的記憶體區域中。隨後再把緩衝區中的內容拷貝到使用者程式的記憶體區域中。

  對於阻塞 I/O 來說,從發起讀請求,到緩衝區就緒,再到使用者程序獲取資料,這兩個步驟都是阻塞的。

  非阻塞 I/O 實際上是向核心輪詢,緩衝區是否就緒,如果沒有則繼續執行其他操作。當緩衝區就緒時,講緩衝區內容拷貝到使用者程序,這一步實際上還是阻塞的。

  I/O 多路複用技術是指利用單個執行緒處理多個網路 I/O,我們常說的 select、epoll 就是用來輪詢所有 socket 的函式。比如 Apache 採用了前者,而 Nginx 和 Node.js 使用了後者,區別在於後者效率更高。由於 I/O 多路複用實際上還是單執行緒的輪詢,因此它也是一種非阻塞 I/O 的方案。

  非同步 I/O 是最理想的 I/O 模型,然而可惜的是真正的非同步 I/O 並不存在。 Linux 上的 AIO 通過訊號和回撥來傳遞資料,但是存在缺陷。現有的 libeio 以及 Windows 上的 IOCP,本質上都是利用執行緒池與阻塞 I/O 來模擬非同步 I/O。

  Node.js 執行緒模型

  很多文章都提到 Node.js 是單執行緒的,然而這樣的說法並不嚴謹,甚至可以說很不負責,因為我們至少會想到以下幾個問題:

  Node.js 在一個執行緒中如何處理併發請求?

  Node.js 在一個執行緒中如何進行檔案的非同步 I/O?

  Node.js 如何重複利用伺服器上的多個 CPU 的處理能力?

  網路 I/O

  Node.js 確實可以在單執行緒中處理大量的併發請求,但這需要一定的程式設計技巧。我們回顧一下文章開頭的程式碼,執行了 app.js 檔案後控制檯立刻就會有輸出,而在我們訪問網頁時才會看到 “Hello,World”。

  這是因為 Node.js 是事件驅動的,也就是說只有網路請求這一事件發生時,它的回撥函式才會執行。當有多個請求到來時,他們會排成一個佇列,依次等待執行。

  這看上去理所當然,然而如果沒有深刻認識到 Node.js 執行在單執行緒上,而且回撥函式是同步執行,同時還按照傳統的模式來開發程式,就會導致嚴重的問題。舉個簡單的例子,這裡的 “Hello World” 字串可能是其他某個模組的執行結果。假設 “Hello World” 的生成非常耗時,就會阻塞當前網路請求的回撥,導致下一次網路請求也無法被響應。

  解決方法很簡單,採用非同步回撥機制即可。我們可以把用來產生輸出結果的 response 引數傳遞給其他模組,並用非同步的方式生成輸出結果,最後在回撥函式中執行真正的輸出。這樣的好處是,http.createServer 的回撥函式不會阻塞,因此不會出現請求無響應的情況。

  舉個例子,我們改造一下 server 的入口,實際上如果要自己完成路由,大約也是這個思路:

  var http=require('http');var output=require('./string') // 一個第三方模組http.createServer(function (request, response) {

  output.output(response); // 呼叫第三方模組進行輸出}).listen(8888);

  第三方模組:

  function sleep(milliSeconds) { // 模擬卡頓

  var startTime=new Date().getTime(); while (new Date().getTime() < startTime + milliSeconds);

  }function outputString(response) {

  sleep(10000); // 阻塞 10s

  response.end('Hello World

  '); // 先執行耗時操作,再輸出}

  exports.output=outputString;

  總之,在利用 Node.js 程式設計時,任何耗時操作一定要使用非同步來完成,避免阻塞當前函式。因為你在為客戶端提供服務,而所有程式碼總是單執行緒、順序執行。

  如果初學者看到這裡還是無法理解,建議閱讀 “Nodejs 入門” 這本書,或者閱讀下文關於事件迴圈的章節。

  檔案 I/O

  非同步是為了優化體驗,避免卡頓。而真正節省處理時間,利用 CPU 多核效能,還是要靠多執行緒並行處理。

  實際上 Node.js 在底層維護了一個執行緒池。之前在基礎概念部分也提到過,不存在真正的非同步檔案 I/O,通常是通過執行緒池來模擬。執行緒池中預設有四個執行緒,用來進行檔案 I/O。

  需要注意的是,我們無法直接操作底層的執行緒池,實際上也不需要關心它們的存在。執行緒池的作用僅僅是完成 I/O 操作,而非用來執行 CPU 密集型的操作,比如影象、視訊處理,大規模計算等。

  如果有少量 CPU 密集型的任務需要處理,我們可以啟動多個 Node.js 程序並利用 IPC 機制進行程序間通訊,或者呼叫外部的 C++/Java 程式。如果有大量 CPU 密集型任務,那隻能說明選擇 Node.js 是一個錯誤的決定。

  榨乾 CPU

  到目前為止,我們知道了 Node.js 採用 I/O 多路複用技術,利用單執行緒處理網路 I/O,利用執行緒池和少量執行緒模擬非同步檔案 I/O。那在一個 32 核 CPU 上,Node.js 的單執行緒是否顯得雞肋呢?

  答案是否定的,我們可以啟動多個 Node.js 程序。不同於上一節的是,程序之間不需要通訊,它們各自監聽一個埠,同時在最外層利用 Nginx 做負載均衡。

  Nginx 負載均衡非常容易實現,只要編輯配置檔案即可:

  http{

  upstream sampleapp { // 可選配置項,如 least_conn,ip_hash

  server 127.0.0.1:3000;

  server 127.0.0.1:3001; // ... 監聽更多埠

  }

  ....

  server{

  listen 80;

  ...

  location / {

  proxy_pass sampleapp; // 監聽 80 埠,然後轉發

  }

  }

  預設的負載均衡規則是把網路請求依次分配到不同的埠,我們可以用 least_conn 標誌把網路請求轉發到連線數最少的 Node.js 程序,也可以用 ip_hash 保證同一個 ip 的請求一定由同一個 Node.js 程序處理。

  多個 Node.js 程序可以充分發揮多核 CPU 的處理能力,也具有很強大的拓展能力。

  事件迴圈

  在 Node.js 中存在一個事件迴圈(Event Loop),有過 iOS 開發經驗的同學可能會覺得眼熟。沒錯,它和 Runloop 在一定程度上是類似的。

  一次完整的 Event Loop 也可以分為多個階段(phase),依次是 poll、check、close callbacks、timers、I/O callbacks 、Idle。

  由於 Node.js 是事件驅動的,每個事件的回撥函式會被註冊到 Event Loop 的不同階段。比如 fs.readFile 的回撥函式被新增到 I/O callbacks,setImmediate 的回撥被新增到下一次 Loop 的 poll 階段結束後,process.nextTick() 的回撥被新增到當前 phase 結束後,下一個 phase 開始前。

  不同非同步方法的回撥會在不同的 phase 被執行,掌握這一點很重要,否則就會因為呼叫順序問題產生邏輯錯誤。

  Event Loop 不斷的迴圈,每一個階段內都會同步執行所有在該階段註冊的回撥函式。這也正是為什麼我在網路 I/O 部分提到,不要在回撥函式中呼叫阻塞方法,總是用非同步的思想來進行耗時操作。一個耗時太久的回撥函式可能會讓 Event Loop 卡在某個階段很久,新來的網路請求就無法被及時響應。

  由於本文的目的是對 Node.js 有一個初步的,全面的認識。就不詳細介紹 Event Loop 的每個階段了,具體細節可以檢視官方文件。

  可以看出 Event Loop 還是比較偏底層的,為了方便的使用事件驅動的思想,Node.js 封裝了 EventEmitter 這個類:

  var EventEmitter=require('events');var util=require('util');function MyThing() {

  EventEmitter.call(this);

  setImmediate(function (self) {

  self.emit('thing1');

  }, this);

  process.nextTick(function (self) {

  self.emit('thing2');

  }, this);

  }

  util.inherits(MyThing, EventEmitter);var mt=new MyThing();

  mt.on('thing1', function onThing1() { console.log("Thing1 emitted");

  });

  mt.on('thing2', function onThing1() { console.log("Thing2 emitted");

  });

  根據輸出結果可知,self.emit(thing2) 雖然後定義,但先被執行,這也完全符合 Event Loop 的呼叫規則。

  Node.js 中很多模組都繼承自 EventEmitter,比如下一節中提到的 fs.readStream,它用來建立一個可讀檔案流, 開啟檔案、讀取資料、讀取完成時都會丟擲相應的事件。

  資料流

  使用資料流的好處很明顯,生活中也有真實寫照。舉個例子,老師佈置了暑假作業,如果學生每天都做一點(作業流),就可以比較輕鬆的完成任務。如果積壓在一起,到了最後一天,面對堆成小山的作業本,就會感到力不從心。

  Server 開發也是這樣,假設使用者上傳 1G 檔案,或者讀取本地 1G 的檔案。如果沒有資料流的概念,我們需要開闢 1G 大小的緩衝區,然後在緩衝區滿後一次性集中處理。

  如果是採用資料流的方式,我們可以定義很小的一塊緩衝區,比如大小是 1Mb。當緩衝區滿後就執行回撥函式,對這一小塊資料進行處理,從而避免出現積壓。

  實際上 request 和 fs 模組的檔案讀取都是一個可讀資料流:

  var fs=require('fs');var readableStream=fs.createReadStream('file.txt');var data='';

  readableStream.setEncoding('utf8');// 每次緩衝區滿,處理一小塊資料 chunkreadableStream.on('data', function(chunk) {

  data+=chunk;

  });// 檔案流全部讀取完成readableStream.on('end', function() { console.log(data);

  });

  利用管道技術,可以把一個流中的內容寫入到另一個流中:

  var fs=require('fs');var readableStream=fs.createReadStream('file1.txt');var writableStream=fs.createWriteStream('file2.txt');

  readableStream.pipe(writableStream);

  不同的流還可以串聯(Chain)起來,比如讀取一個壓縮檔案,一邊讀取一邊解壓,並把解壓內容寫入到檔案中:

  var fs=require('fs');var zlib=require('zlib');

  fs.createReadStream('input.txt.gz')

  .pipe(zlib.createGunzip())

  .pipe(fs.createWriteStream('output.txt'));

  Node.js 提供了非常簡潔的資料流操作,以上就是簡單的使用介紹。

  總結

  對於高併發的長連線,事件驅動模型比執行緒輕量得多,多個 Node.js 程序配合負載均衡可以方便的進行拓展。因此 Node.js 非常適合為 I/O 密集型應用提供服務。但這種方式的缺陷就是不擅長處理 CPU 密集型任務。

  Node.js 中通常以流的方式來描述資料,也對此提供了很好的封裝。

  Node.js 使用前端語言(JavaScript) 開發,同時也是一個後端伺服器,因此為前後端分離提供了一個良好的思路。我會在下一篇文章中對此進行分析。