37.需要搞懂的Node.js的核心Feature
Node.js的核心Feature
- EventLoop 事件循環
- global 和 process
- Event Emitters 事件觸發
- Stream 和 Buffer
- Cluster 集群
- 異步Error
- C++ 插件
Event Loop
事件循環算是Node的一個核心了,即使進程中不斷有I/O調用也能處理其他任務。正因為阻塞I/O代價太高所以就凸顯了Node的高效。
ps: keynote做的圖,不會PS,太麻煩。。。
在 Python 這樣來實現一個延遲處理
import time
print "Step 1"
print "Step 2"
time.sleep(2)
print "Step 3"</code></pre>
Node或JavaScript 通過異步回調的方式來實現
console.log('Step 1');
setTimeout(function () {
console.log('Step 3');
}, 2000)
console.log('Step 2');
可以事件循環想象成一個for或while循環,只有在現在或將來沒有任務要執行的時候才會停下來。

在等待I/O任務完成之前就可以做更多事情,事件循環因此讓系統更加高效。

Node也讓我們從死鎖中解放,因為根本沒有鎖。
PS:我們仍然可以寫出阻塞的代碼
var start = Date.now();
for (var i = 1; i<1000000000; i++) {}
var end = Date.now();
console.log(end-start);
這次的阻塞在我的機器上花了3400多毫秒。不過我們多數情況下不會跑一個空循環。
而且fs模塊提供了同步(阻塞)和異步(非阻塞)兩套處理方法(區別于方法名后是否有Sync)
如下阻塞方式的代碼:
var fs = require('fs');
var con1 = fs.readFileSync('1.txt','utf8');
console.log(con1);
console.log('read 1.txt');
var con2 = fs.readFileSync('2.txt','utf8');
console.log(con2);
console.log('read 2.txt');</code></pre>
結果就是
content1->read 1.txt->content2->read 2.txt
非阻塞方式的代碼:
var fs = require('fs');
fs.readFile('1.txt','utf8', function(err, contents){
console.log(contents);
});
console.log('read 1.txt');
fs.readFile('2.txt','utf8', function(err, contents){
console.log(contents);
});
console.log("read 2.txt");</code></pre>
代碼執行后因為要花時間執行 讀 的操作,所以會在最后的回調函數中打印出文件內容。當讀取操作結束后事件循環就會拿到內容
read 1.txt->read 2.txt->content1->content2
事件循環的概念對前端工程師比較好理解,關鍵就是異步、非阻塞I\O。
global
從瀏覽器端切換到Node端就會出現幾個問題
- 全局變量如何創建(window對象已經沒了)
- 從 CLI 輸入的參數、系統信息、內存信息、版本信息等從哪獲取
有一個 global 對象,顧名思義就是全局的,它的屬性很多
- global.process 可獲取版本信息、CLI 參數、內存使用(process.memoryUsage()這個函數很好用)
- global.__filename 當前腳本的文件名、路徑
- global.__dirname 當前腳本絕對路徑
- global.module 最常見的模塊輸出
- global.require() 模塊引入
- global.console()、setInterval()、setTimeout() 這些瀏覽器端的方法也在global對象下面
在命令行里執行一次 global 一切就都懂了。
process
通過process對象獲取和控制Node自身進程的各種信息。另外process是一個全局對象,在任何地方都可以直接使用。
部分屬性
- process.pid 進程pid
- process.versions node、v8、ssl、zlib等組件的版本
- process.arch 系統架構,如:x64
- process.argv CLI參數
- process.env 環境變量
部分方法
- process.uptime() 正常運行的時長
- process.memoryUsage() 內存使用情況
- process.cwd() 當前目錄
- process.exit() 退出進程
- process.on() 添加事件監聽 比如:on('uncaughtException')
Events
Events == Node Observer Pattern
異步處理寫多了就會出現callbackhell(回調地獄),還有人專門做了叫 callbackhell 的 網站 。
EventEmitter 就是可以觸發任何可監聽的事件,callbackhell可以通過事件監聽和觸發來避免。
用法
var events = require('events');
var emitter = new events.EventEmitter();
添加事件監聽和事件觸發
emitter.on('eat', function() {
console.log('eating...');
});
emitter.on('eat', function() {
console.log('still eating...');
});
emitter.emit('eat');</code></pre>
假設我們有一個已經繼承了 EventEmitter 的類,能每周、每天的處理郵件任務,而且這個類具有足夠的可擴展性能夠自定義最后的輸出內容,換言之就是每個使用這個類的人都能夠在任務結束時增加自定義的方法和函數。
如下圖,我們繼承了 EventEmitter 模塊創建了一個Class: Job,然后通過事件監聽器 done 來實現Job的自定義處理方式。

我們需要做的只是在進程結束的時候觸發 done 事件:
// job.js
var util = require('util');
var Job = function() {
var job = this;
job.process = function() {
job.emit('done', { completeTime: new Date() })
}
}
util.inherits(Job, require('events').EventEmitter);
module.exports = Job;</code></pre>
我們的目的是在 Job 任務結束時執行自定義的函數方法,因此我們可以監聽 done 事件然后添加回調:
var Job = require('./job.js')
var job = new Job()
job.on('done', function(data){
console.log('Job completed at', data.completeTime)
job.removeAllListeners()
})
job.process()</code></pre>
關于 emitter 還有這些常用方法
- emitter.listeners(eventName) 列出 eventName 的所有監聽器
- emitter.once(eventName, listener) 只監聽一次
- emitter.removeListener(eventName, listener) 刪除監聽器
stream 流
用Node處理比較大的數據時可能會出現些問題:
- 速度較慢
- 緩沖器只限制在1GB上等,
- 數據連續不斷的時如何何操作
用Stream就會解決。因為Node的 Stream 是對連續數據進行分塊后的一個抽象,也就是不需要等待資源完全加載后再操作。
標準Buffer的處理方式:

只有整個Buffer加載完后才能進行下一步操作,看圖對比下Node的Stream,只要拿到數據的第一個 chunk 就可以進行處理了

Node中有四種數據流:
- Readable 讀
- Writable 寫
- Duplex 讀&寫
- Transform 數據轉換
Stream在Node中很常見:
- HTTP 的 request response
- 標準 I/O
- 文件讀寫
Readable Stream
process.stdin 是一個標準輸入流,數據一般來自于鍵盤輸入,用它來實現一個可讀流的例子。
我們使用 data 和 end 事件從 stdin 中讀取數據。其中 data 事件的回調函數的參數就是 chunk 。
process.stdin.resume()
process.stdin.setEncoding('utf8')
process.stdin.on('data', function (chunk) {
console.log('chunk: ', chunk)
})
process.stdin.on('end', function () {
console.log('--- END ---')
})</code></pre>
PS: stdin 默認是處于pause狀態的,要想讀取數據首先要將其 resume()
可讀流還有一個 同步 的 read() 方法,當流讀取完后會返回 chunk或null ,課這樣來用:
var readable = getReadableStreamMethod()
readable.on('readable', () => {
var chunk
while (null !== (chunk = readable.read())) {
console.log('got %d bytes of data', chunk.length)
}
})
我們在Node中要盡可能寫異步代碼避免阻塞線程,不過好在 chunk 都很小所以不用擔心同步的 read() 方法把線程阻塞
Writable Stream
我們用 process.stdin 對應的 process.stdout 方法來實現個例子
process.stdout.write('this is stdout data');
把數據寫入標準輸出后是在命令行中可見的,就像用 console.log()
Pipe
就像自來水要有自來水管一樣,Stream 需要傳送 也需要 Pipe。
下面的代碼就是從文件中讀數據,然后GZip壓縮,再把數據寫入文件
const r = fs.createReadStream('file.txt')
const z = zlib.createGzip()
const w = fs.createWriteStream('file.txt.gz')
r.pipe(z).pipe(w)
readable.pipe() 方法從可讀流中拉取所有數據,并寫入到目標流中,同時返回目標流,因此可以鏈式調用(也可以叫導流鏈)。
PS:該方法能自動控制流量以避免目標流被快速讀取的可讀流所淹沒。
HTTP 流
web應用最常見了,HTTP 流用的也最多。
request 和 response 是繼承自Event Emitter的可讀可寫流。下面的代碼在各種教程中就很常見了:
const http = require('http')
var server = http.createServer( (req, res) => {
var body = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
body += chunk;
})
req.on('end', () => {
var data = JSON.parse(body);
res.write(typeof data);
res.end();
})
})
server.listen(5502)</code></pre>
之前還寫過一篇關于 Stream 和 Pipe 的文章: Node.js 使用 fs 模塊做文件 copy 的四種方法 能清楚的比較出使用Stream后能更快的獲取到數據。
Buffer
瀏覽器中的JS沒有二進制類型的數據(ES6中ArrayBuffer是二進制),但是Node里面有,就是 Buffer。 Buffer是個全局對象,可直接使用來創建二進制數據:
- Buffer.alloc(size)
- Buffer.from(array)
- Buffer.from(buffer)
- Buffer.from(string[, encoding])
官方API 中有最全的方法。
標準的Buffer數據比較難看懂,一般用 toString() 來轉換成人類可讀的數據
let buf = Buffer.alloc(26)
for (var i = 0 ; i < 26 ; i++) {
buf[i] = i + 97;
}
console.log(buf); // <Buffer 61 62 63 64 65 66 67 68 69 6a 6b 6c 6d 6e 6f 70 71 72 73 74 75 76 77 78 79 7a>
buf.toString('utf8'); // abcdefghijklmnopqrstuvwxyz
buf.toString('ascii'); // abcdefghijklmnopqrstuvwxyz
// 截取
buf.toString('utf8', 0, 5); // abcde
buf.toString(undefined, 0, 5); // abcde 編碼默認是 utf8</code></pre>
fs 模塊的 readFile 方法回調中的data就是個Buffer
fs.readFile('file-path', function (err, data) {
if (err) return console.error(err);
console.log(data);
});
Cluster 集群
單個 Node 實例運行在單個線程中。要發揮多核系統的能力,就需要啟動一個 Node 進程集群來處理負載。核心模塊 cluster 可以讓一臺機器的所有 CPU 都用起來,這樣就能縱向的來擴展我們的Node程序。
var cluster = require('cluster');
var numCPUs = 4; // 我的 MacBook Air 是4核的
if (cluster.isMaster) {
for (var i = 0; i < numCPUs; i++) {
cluster.fork()
}
} else if (cluster.isWorker) {
console.log('worker')
}
上面的代碼比較簡單,引入模塊創建一個 master 多個 worker,不過 numCPUs 并一定非得是一臺電腦的核心數,這個可以根據自己需求想要多少都行。
worker 和 master 可以監聽相同的端口,worker通過事件和master通信。master也能監聽事件并根據需要重啟集群。
重要代表 pm2
pm2優點很多:
- 負載均衡
- 熱重載:0s reload
- 非常好的測試覆蓋率
pm2 啟動很簡單:
$ pm2 start server.js -i 4 -l ./log.txt
- -i 4 是 cpu數量(我是4核的)
- -l ./log.txt 打日志
pm2 啟動后自動到后臺執行 通過 $ pm2 list 可以查看正在跑著那些進程。
更多內容直接看官網: http://pm2.keymetrics.io/
Spawn vs Fork vs Exec
child_process.spawn() vs child_process.fork() vs child_process.exec()
Node中有如上三種方式創建一個外部進程,而且都來自于核心模塊 child_process
-
require('child_process').spawn() 用于比較大的數據,支持流,可以與任何命令(bash python ruby 以及其他腳本)一起使用,并且不創建一個新的V8實例
官網的例子 就是執行了一個bash命令
-
require('child_process').fork() 創建一個新的V8實例,實例出多個worker
和spawn不同的的是 fork 只執行 node 命令
-
require('child_process').exec() 使用Buffer,這使得它不適合比較大的數據和Stream,異步調用,并在回調一次性拿到所有數據
exec()用的不是事件模式,而是在回調中返回 stdout stderr
處理異步 Error
處理異常一般都用 try/catch ,尤其是在同步代碼非常好用,但是在異步執行中就會出現問題
try {
setTimeout(function () {
throw new Error('Fail!')
}, 1000)
} catch (e) {
console.log(e.message)
}
這種異步的回調里面情況肯定catch不到錯誤,當然把try/catch放入回調函數肯定沒問題,但這并不是個好辦法。
所以Node里面標準的回調函數的第一個參數都是 error ,于是就有了這樣的代碼
if (error) return callback(error)
一般處理異步異常的方法如下:
- 監聽所有的 on('error')
- 監聽 uncaughtException
- 用 domain 模塊 或者 AsyncWrap
- 打日志
- 退出然后重啟進程。。。。
uncaughtException
uncaughtException是一種很簡單粗暴的機制,當一個異常冒泡到事件循環中就會觸發這個事件,不過這個不建議使用。
process.on('uncaughtException', function (err) {
console.error('uncaughtException: ', err.message)
console.error(err.stack)
process.exit(1)
})
Domain
domain自身其實是一個EventEmitter對象,它通過事件的方式來傳遞捕獲的異常。
進程拋出了異常,沒有被任何的try catch捕獲到,這時候將會觸發整個process的processFatal,此時如果在domain包裹之中,將會在domain上觸發error事件,反之,將會在process上觸發uncaughtException事件。
用法:
var domain = require('domain').create()
d.on('error', function(e) {
console.log(e)
})
d.run(function() {
setTimeout(function () {
throw new Error('Failed!')
}, 1000)
});</code></pre>
從 4.0 開始Node已經不建議使用了,以后也會被剝離出核心模塊
打日志
日志不僅僅能記錄異常信息
C++ 插件
Node.js在硬件、IoT領域開始流行就是因為能和 C/C++ 代碼合體使用。
官方提供了很多C++插件的例子: https://github.com/nodejs/node-addon-examples
以第一個HelloWorld為例:
創建 hello.cc 文件,這個代碼比較好理解
#include <node.h>
namespace demo {
using v8::FunctionCallbackInfo;
using v8::HandleScope;
using v8::Isolate;
using v8::Local;
using v8::Object;
using v8::String;
using v8::Value;
void Method(const FunctionCallbackInfo<Value>& args) {
Isolate* isolate = args.GetIsolate();
args.GetReturnValue().Set(String::NewFromUtf8(isolate, "Hello World C++ addon")); // 輸出Hello World
}
void init(Local<Object> exports) {
NODE_SET_METHOD(exports, "hello", Method); // Exporting
}
NODE_MODULE(addon, init)
}</code></pre>
創建binding.gyp文件 內容一看就懂
{
"targets": [
{
"target_name": "addon",
"sources": [ "hello.cc" ]
}
]
}
依次執行兩個命令
$ node-gyp configure
$ node-gyp build
編譯完成后在 build/Release/ 下面會有一個 addon.node 文件
然后就簡單了創建 h.js
var addon = require('./build/Release/addon')
console.log(addon.hello())
執行 $ node hello.js 打印出了 Hello World C++ addon
OVER....
PS: 文中圖片都是用keynote制作的
PS: 部分參考和翻譯自:
- https://nodejs.org/api/
- http://www.ruanyifeng.com/blog/2014/10/event-loop.html
- http://webapplog.com/you-dont-know-node/
- https://cnodejs.org/topic/516b64596d38277306407936
來自:https://github.com/ccforward/cc/issues/38