1. 程式人生 > >NodeJS簡易部落格系統(六)NodeJS入門學習(下)

NodeJS簡易部落格系統(六)NodeJS入門學習(下)

一、網路程式設計

1、小試牛刀

NodeJS本來的用途是編寫高效能Web伺服器。首先在這裡重複一下官方文件裡的例子,使用NodeJS內建的http模組簡單實現一個HTTP伺服器。

var http = require('http');

http.createServer(function (request, response) {
    response.writeHead(200, { 'Content-Type': 'text-plain' });
    response.end('Hello World\n');
}).listen(8080);

以上程式建立了一個HTTP伺服器並監聽8080埠,開啟瀏覽器訪問該埠http://127.0.0.1:8080/就能夠看到效果。

2、常用

  • HTTP

'http'模組提供兩種使用方式:

作為服務端使用時,建立一個HTTP伺服器,監聽HTTP客戶端請求並返回響應。

作為客戶端使用時,發起一個HTTP客戶端請求,獲取服務端響應。

首先來看看服務端模式下如何工作。如小試牛刀中的例子所示,首先需要使用.createServer方法建立一個伺服器,然後呼叫.listen方法監聽埠。之後,每當來了一個客戶端請求,建立伺服器時傳入的回撥函式就被呼叫一次。可以看出,這是一種事件機制。

HTTP請求本質上是一個數據流,由請求頭(headers)和請求體(body)組成。例如以下是一個完整的HTTP請求資料內容。

POST / HTTP/1.1
User-Agent: curl/7.26.0
Host: localhost
Accept: */*
Content-Length: 11
Content-Type: application/x-www-form-urlencoded

Hello World
可以看到,空行之上是請求頭,之下是請求體。HTTP請求在傳送給伺服器時,可以認為是按照從頭到尾的順序一個位元組一個位元組地以資料流方式傳送的。而http模組建立的HTTP伺服器在接收到完整的請求頭後,就會呼叫回撥函式。在回撥函式中,除了可以使用request物件訪問請求頭資料外,還能把request物件當作一個只讀資料流來訪問請求體資料。以下是一個例子。

http.createServer(function (request, response) {
    var body = [];

    console.log(request.method);
    console.log(request.headers);

    request.on('data', function (chunk) {
        body.push(chunk);
    });

    request.on('end', function () {
        body = Buffer.concat(body);
        console.log(body.toString());
    });
}).listen(8080);

------------------------------------
POST
{ 'user-agent': 'curl/7.26.0',
  host: 'localhost',
  accept: '*/*',
  'content-length': '11',
  'content-type': 'application/x-www-form-urlencoded' }
Hello World

HTTP響應本質上也是一個數據流,同樣由響應頭(headers)和響應體(body)組成。例如以下是一個完整的HTTP請求資料內容。

HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 11
Date: Tue, 05 Nov 2013 05:31:38 GMT
Connection: keep-alive

Hello World
在回撥函式中,除了可以使用response物件來寫入響應頭資料外,還能把response物件當作一個只寫資料流來寫入響應體資料。例如在以下例子中,服務端原樣將客戶端請求的請求體資料返回給客戶端。

http.createServer(function (request, response) {
    response.writeHead(200, { 'Content-Type': 'text/plain' });

    request.on('data', function (chunk) {
        response.write(chunk);
    });

    request.on('end', function () {
        response.end();
    });
}).listen(8080);

接下來我們看看客戶端模式下如何工作。為了發起一個客戶端HTTP請求,我們需要指定目標伺服器的位置併發送請求頭和請求體,以下示例演示了具體做法。

var options = {
        hostname: 'www.example.com',
        port: 80,
        path: '/upload',
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
        }
    };

var request = http.request(options, function (response) {});

request.write('Hello World');
request.end();

可以看到,.request方法建立了一個客戶端,並指定請求目標和請求頭資料。之後,就可以把request物件當作一個只寫資料流來寫入請求體資料和結束請求。另外,由於HTTP請求中GET請求是最常見的一種,並且不需要請求體,因此http模組也提供了以下便捷API。

http.get('http://www.example.com/', function (response) {});
當客戶端傳送請求並接收到完整的服務端響應頭時,就會呼叫回撥函式。在回撥函式中,除了可以使用response物件訪問響應頭資料外,還能把response物件當作一個只讀資料流來訪問響應體資料。以下是一個例子。

http.get('http://www.example.com/', function (response) {
    var body = [];

    console.log(response.statusCode);
    console.log(response.headers);

    response.on('data', function (chunk) {
        body.push(chunk);
    });

    response.on('end', function () {
        body = Buffer.concat(body);
        console.log(body.toString());
    });
});

------------------------------------
200
{ 'content-type': 'text/html',
  server: 'Apache',
  'content-length': '801',
  date: 'Tue, 05 Nov 2013 06:08:41 GMT',
  connection: 'keep-alive' }
<!DOCTYPE html>
...

 

  • HTTPS

https模組與http模組極為類似,區別在於https模組需要額外處理SSL證書。

在服務端模式下,建立一個HTTPS伺服器的示例如下。

var options = {
        key: fs.readFileSync('./ssl/default.key'),
        cert: fs.readFileSync('./ssl/default.cer')
    };

var server = https.createServer(options, function (request, response) {
        // ...
    });

可以看到,與建立HTTP伺服器相比,多了一個options物件,通過key和cert欄位指定了HTTPS伺服器使用的私鑰和公鑰。

另外,NodeJS支援SNI技術,可以根據HTTPS客戶端請求使用的域名動態使用不同的證書,因此同一個HTTPS伺服器可以使用多個域名提供服務。接著上例,可以使用以下方法為HTTPS伺服器新增多組證書。

server.addContext('foo.com', {
    key: fs.readFileSync('./ssl/foo.com.key'),
    cert: fs.readFileSync('./ssl/foo.com.cer')
});

server.addContext('bar.com', {
    key: fs.readFileSync('./ssl/bar.com.key'),
    cert: fs.readFileSync('./ssl/bar.com.cer')
});

在客戶端模式下,發起一個HTTPS客戶端請求與http模組幾乎相同,示例如下。

var options = {
        hostname: 'www.example.com',
        port: 443,
        path: '/',
        method: 'GET'
    };

var request = https.request(options, function (response) {});

request.end();
但如果目標伺服器使用的SSL證書是自制的,不是從頒發機構購買的,預設情況下https模組會拒絕連線,提示說有證書安全問題。在options里加入rejectUnauthorized: false欄位可以禁用對證書有效性的檢查,從而允許https模組請求開發環境下使用自制證書的HTTPS伺服器。

  • URL

處理HTTP請求時url模組使用率超高,因為該模組允許解析URL、生成URL,以及拼接URL。首先我們來看看一個完整的URL的各組成部分。

                           href
 -----------------------------------------------------------------
                            host              path
                      --------------- ----------------------------
 http: // user:pass @ host.com : 8080 /p/a/t/h ?query=string #hash
 -----    ---------   --------   ---- -------- ------------- -----
protocol     auth     hostname   port pathname     search     hash
                                                ------------
                                                   query

我們可以使用.parse方法來將一個URL字串轉換為URL物件,示例如下。

url.parse('http://user:[email protected]:8080/p/a/t/h?query=string#hash');
/* =>
{ protocol: 'http:',
  auth: 'user:pass',
  host: 'host.com:8080',
  port: '8080',
  hostname: 'host.com',
  hash: '#hash',
  search: '?query=string',
  query: 'query=string',
  pathname: '/p/a/t/h',
  path: '/p/a/t/h?query=string',
  href: 'http://user:[email protected]:8080/p/a/t/h?query=string#hash' }
*/

傳給.parse方法的不一定要是一個完整的URL,例如在HTTP伺服器回撥函式中,request.url不包含協議頭和域名,但同樣可以用.parse方法解析。

http.createServer(function (request, response) {
    var tmp = request.url; // => "/foo/bar?a=b"
    url.parse(tmp);
    /* =>
    { protocol: null,
      slashes: null,
      auth: null,
      host: null,
      port: null,
      hostname: null,
      hash: null,
      search: '?a=b',
      query: 'a=b',
      pathname: '/foo/bar',
      path: '/foo/bar?a=b',
      href: '/foo/bar?a=b' }
    */
}).listen(8080);

.parse方法還支援第二個和第三個布林型別可選引數。第二個引數等於true時,該方法返回的URL物件中,query欄位不再是一個字串,而是一個經過querystring模組轉換後的引數物件。第三個引數等於true時,該方法可以正確解析不帶協議頭的URL,例如//www.example.com/foo/bar。

反過來,format方法允許將一個URL物件轉換為URL字串,示例如下。

url.format({
    protocol: 'http:',
    host: 'www.example.com',
    pathname: '/p/a/t/h',
    search: 'query=string'
});
/* =>
'http://www.example.com/p/a/t/h?query=string'
*/

另外,.resolve方法可以用於拼接URL,示例如下。

url.resolve('http://www.example.com/foo/bar', '../baz');
/* =>
http://www.example.com/baz
*/

  • Query String

querystring模組用於實現URL引數字串與引數物件的互相轉換,示例如下。

querystring.parse('foo=bar&baz=qux&baz=quux&corge');
/* =>
{ foo: 'bar', baz: ['qux', 'quux'], corge: '' }
*/

querystring.stringify({ foo: 'bar', baz: ['qux', 'quux'], corge: '' });
/* =>
'foo=bar&baz=qux&baz=quux&corge='
*/

  • Zlib

zlib模組提供了資料壓縮和解壓的功能。當我們處理HTTP請求和響應時,可能需要用到這個模組。

首先我們看一個使用zlib模組壓縮HTTP響應體資料的例子。這個例子中,判斷了客戶端是否支援gzip,並在支援的情況下使用zlib模組返回gzip之後的響應體資料。

http.createServer(function (request, response) {
    var i = 1024,
        data = '';

    while (i--) {
        data += '.';
    }

    if ((request.headers['accept-encoding'] || '').indexOf('gzip') !== -1) {
        zlib.gzip(data, function (err, data) {
            response.writeHead(200, {
                'Content-Type': 'text/plain',
                'Content-Encoding': 'gzip'
            });
            response.end(data);
        });
    } else {
        response.writeHead(200, {
            'Content-Type': 'text/plain'
        });
        response.end(data);
    }
}).listen(8080);

接著我們看一個使用zlib模組解壓HTTP響應體資料的例子。這個例子中,判斷了服務端響應是否使用gzip壓縮,並在壓縮的情況下使用zlib模組解壓響應體資料。

var options = {
        hostname: 'www.example.com',
        port: 80,
        path: '/',
        method: 'GET',
        headers: {
            'Accept-Encoding': 'gzip, deflate'
        }
    };

http.request(options, function (response) {
    var body = [];

    response.on('data', function (chunk) {
        body.push(chunk);
    });

    response.on('end', function () {
        body = Buffer.concat(body);

        if (response.headers['content-encoding'] === 'gzip') {
            zlib.gunzip(body, function (err, data) {
                console.log(data.toString());
            });
        } else {
            console.log(data.toString());
        }
    });
}).end();

  • Net

net模組可用於建立Socket伺服器或Socket客戶端。由於Socket在前端領域的使用範圍還不是很廣,這裡先不涉及到WebSocket的介紹,僅僅簡單演示一下如何從Socket層面來實現HTTP請求和響應。

首先我們來看一個使用Socket搭建一個很不嚴謹的HTTP伺服器的例子。這個HTTP伺服器不管收到啥請求,都固定返回相同的響應。

net.createServer(function (conn) {
    conn.on('data', function (data) {
        conn.write([
            'HTTP/1.1 200 OK',
            'Content-Type: text/plain',
            'Content-Length: 11',
            '',
            'Hello World'
        ].join('\n'));
    });
}).listen(8080);

接著我們來看一個使用Socket發起HTTP客戶端請求的例子。這個例子中,Socket客戶端在建立連線後傳送了一個HTTP GET請求,並通過data事件監聽函式來獲取伺服器響應。

var options = {
        port: 8080,
        host: 'www.example.com'
    };

var client = net.connect(options, function () {
        client.write([
            'GET / HTTP/1.1',
            'User-Agent: curl/7.26.0',
            'Host: www.baidu.com',
            'Accept: */*',
            '',
            ''
        ].join('\n'));
    });

client.on('data', function (data) {
    console.log(data.toString());
    client.end();
});

二、程序管理

NodeJS可以感知和控制自身程序的執行環境和狀態,也可以建立子程序並與其協同工作,這使得NodeJS可以把多個程式組合在一起共同完成某項工作,並在其中充當膠水和排程器的作用。

1、小試牛刀

nodejs利用終端命令搞定目錄拷貝,示例程式碼:

var child_process = require('child_process');
var util = require('util');

function copy(source, target, callback) {
    child_process.exec(
        util.format('cp -r %s/* %s', source, target), callback);
}

copy('a', 'b', function (err) {
    // ...
});

從以上程式碼中可以看到,子程序是非同步執行的,通過回撥函式返回執行結果。


2、常用

  • Process

任何一個程序都有啟動程序時使用的命令列引數,有標準輸入標準輸出,有執行許可權,有執行環境和執行狀態。在NodeJS中,可以通過process物件感知和控制NodeJS自身程序的方方面面。另外需要注意的是,process不是內建模組,而是一個全域性物件,因此在任何地方都可以直接使用。
  • Child Process

使用child_process模組可以建立和控制子程序。該模組提供的API中最核心的是.spawn,其餘API都是針對特定使用場景對它的進一步封裝,算是一種語法糖。
  • Cluster

cluster模組是對child_process模組的進一步封裝,專用於解決單程序NodeJS Web伺服器無法充分利用多核CPU的問題。使用該模組可以簡化多程序伺服器程式的開發,讓每個核上執行一個工作程序,並統一通過主程序監聽埠和分發請求。
  • 如何獲取命令列引數

在NodeJS中可以通過process.argv獲取命令列引數。但是比較意外的是,node執行程式路徑和主模組檔案路徑固定佔據了argv[0]和argv[1]兩個位置,而第一個命令列引數從argv[2]開始。為了讓argv使用起來更加自然,可以按照以下方式處理。

function main(argv) {
    // ...
}

main(process.argv.slice(2));

  • 如何退出程式

通常一個程式做完所有事情後就正常退出了,這時程式的退出狀態碼為0。或者一個程式執行時發生了異常後就掛了,這時程式的退出狀態碼不等於0。如果我們在程式碼中捕獲了某個異常,但是覺得程式不應該繼續執行下去,需要立即退出,並且需要把退出狀態碼設定為指定數字,比如1,就可以按照以下方式:

try {
    // ...
} catch (err) {
    // ...
    process.exit(1);
}

  • 如何控制輸入輸出

NodeJS程式的標準輸入流(stdin)、一個標準輸出流(stdout)、一個標準錯誤流(stderr)分別對應process.stdin、process.stdout和process.stderr,第一個是隻讀資料流,後邊兩個是隻寫資料流,對它們的操作按照對資料流的操作方式即可。例如,console.log可以按照以下方式實現。

function log() {
    process.stdout.write(
        util.format.apply(util, arguments) + '\n');
}

  • 如何降權

在Linux系統下,我們知道需要使用root許可權才能監聽1024以下埠。但是一旦完成埠監聽後,繼續讓程式執行在root許可權下存在安全隱患,因此最好能把許可權降下來。以下是這樣一個例子。

http.createServer(callback).listen(80, function () {
    var env = process.env,
        uid = parseInt(env['SUDO_UID'] || process.getuid(), 10),
        gid = parseInt(env['SUDO_GID'] || process.getgid(), 10);

    process.setgid(gid);
    process.setuid(uid);
});

上例中有幾點需要注意:

如果是通過sudo獲取root許可權的,執行程式的使用者的UID和GID儲存在環境變數SUDO_UID和SUDO_GID裡邊。如果是通過chmod +s方式獲取root許可權的,執行程式的使用者的UID和GID可直接通過process.getuid和process.getgid方法獲取。

process.setuid和process.setgid方法只接受number型別的引數。

降權時必須先降GID再降UID,否則順序反過來的話就沒許可權更改程式的GID了。
 

  • 如何建立子程序

以下是一個建立NodeJS子程序的例子。

var child = child_process.spawn('node', [ 'xxx.js' ]);

child.stdout.on('data', function (data) {
    console.log('stdout: ' + data);
});

child.stderr.on('data', function (data) {
    console.log('stderr: ' + data);
});

child.on('close', function (code) {
    console.log('child process exited with code ' + code);
});

上例中使用了.spawn(exec, args, options)方法,該方法支援三個引數。第一個引數是執行檔案路徑,可以是執行檔案的相對或絕對路徑,也可以是根據PATH環境變數能找到的執行檔名。第二個引數中,陣列中的每個成員都按順序對應一個命令列引數。第三個引數可選,用於配置子程序的執行環境與行為。

另外,上例中雖然通過子程序物件的.stdout和.stderr訪問子程序的輸出,但通過options.stdio欄位的不同配置,可以將子程序的輸入輸出重定向到任何資料流上,或者讓子程序共享父程序的標準輸入輸出流,或者直接忽略子程序的輸入輸出。
 

  • 程序間如何通訊

在Linux系統下,程序之間可以通過訊號互相通訊。以下是一個例子。

/* parent.js */
var child = child_process.spawn('node', [ 'child.js' ]);

child.kill('SIGTERM');

/* child.js */
process.on('SIGTERM', function () {
    cleanUp();
    process.exit(0);
});

在上例中,父程序通過.kill方法向子程序傳送SIGTERM訊號,子程序監聽process物件的SIGTERM事件響應訊號。不要被.kill方法的名稱迷惑了,該方法本質上是用來給程序傳送訊號的,程序收到訊號後具體要做啥,完全取決於訊號的種類和程序自身的程式碼。

另外,如果父子程序都是NodeJS程序,就可以通過IPC(程序間通訊)雙向傳遞資料。以下是一個例子。

/* parent.js */
var child = child_process.spawn('node', [ 'child.js' ], {
        stdio: [ 0, 1, 2, 'ipc' ]
    });

child.on('message', function (msg) {
    console.log(msg);
});

child.send({ hello: 'hello' });

/* child.js */
process.on('message', function (msg) {
    msg.hello = msg.hello.toUpperCase();
    process.send(msg);
});

可以看到,父程序在建立子程序時,在options.stdio欄位中通過ipc開啟了一條IPC通道,之後就可以監聽子程序物件的message事件接收來自子程序的訊息,並通過.send方法給子程序傳送訊息。在子程序這邊,可以在process物件上監聽message事件接收來自父程序的訊息,並通過.send方法向父程序傳送訊息。資料在傳遞過程中,會先在傳送端使用JSON.stringify方法序列化,再在接收端使用JSON.parse方法反序列化。
 

  • 如何守護子程序 

守護程序一般用於監控工作程序的執行狀態,在工作程序不正常退出時重啟工作程序,保障工作程序不間斷執行。以下是一種實現方式。 

/* daemon.js */
function spawn(mainModule) {
    var worker = child_process.spawn('node', [ mainModule ]);

    worker.on('exit', function (code) {
        if (code !== 0) {
            spawn(mainModule);
        }
    });
}

spawn('worker.js');
可以看到,工作程序非正常退出時,守護程序立即重啟工作程序。

三、非同步程式設計

NodeJS最大的賣點——事件機制和非同步IO,對開發者並不是透明的。開發者需要按非同步方式編寫程式碼才用得上這個賣點,而這一點也遭到了一些NodeJS反對者的抨擊。但不管怎樣,非同步程式設計確實是NodeJS最大的特點,沒有掌握非同步程式設計就不能說是真正學會了NodeJS。本章將介紹與非同步程式設計相關的各種知識。


1、回撥

在程式碼中,非同步程式設計的直接體現就是回撥。非同步程式設計依託於回撥來實現,但不能說使用了回撥後程序就非同步化了。我們首先可以看看以下程式碼。

function heavyCompute(n, callback) {
    var count = 0,
        i, j;

    for (i = n; i > 0; --i) {
        for (j = n; j > 0; --j) {
            count += 1;
        }
    }

    callback(count);
}

heavyCompute(10000, function (count) {
    console.log(count);
});

console.log('hello');

-- Console ------------------------------
100000000
hello

可以看到,以上程式碼中的回撥函式仍然先於後續程式碼執行。JS本身是單執行緒執行的,不可能在一段程式碼還未結束執行時去執行別的程式碼,因此也就不存在非同步執行的概念。
但是,如果某個函式做的事情是建立一個別的執行緒或程序,並與JS主執行緒並行地做一些事情,並在事情做完後通知JS主執行緒,那情況又不一樣了。我們接著看看以下程式碼。

setTimeout(function () {
    console.log('world');
}, 1000);

console.log('hello');

-- Console ------------------------------
hello
world

這次可以看到,回撥函式後於後續程式碼執行了。如同上邊所說,JS本身是單執行緒的,無法非同步執行,因此我們可以認為setTimeout這類JS規範之外的由執行環境提供的特殊函式做的事情是建立一個平行執行緒後立即返回,讓JS主程序可以接著執行後續程式碼,並在收到平行程序的通知後再執行回撥函式。除了setTimeout、setInterval這些常見的,這類函式還包括NodeJS提供的諸如fs.readFile之類的非同步API。

另外,我們仍然回到JS是單執行緒執行的這個事實上,這決定了JS在執行完一段程式碼之前無法執行包括回撥函式在內的別的程式碼。也就是說,即使平行執行緒完成工作了,通知JS主執行緒執行回撥函數了,回撥函式也要等到JS主執行緒空閒時才能開始執行。以下就是這麼一個例子。

function heavyCompute(n) {
    var count = 0,
        i, j;

    for (i = n; i > 0; --i) {
        for (j = n; j > 0; --j) {
            count += 1;
        }
    }
}

var t = new Date();

setTimeout(function () {
    console.log(new Date() - t);
}, 1000);

heavyCompute(50000);

-- Console ------------------------------
8520
 

可以看到,本來應該在1秒後被呼叫的回撥函式因為JS主執行緒忙於執行其它程式碼,實際執行時間被大幅延遲。


2、程式碼設計模式

  • 函式返回值

使用一個函式的輸出作為另一個函式的輸入是很常見的需求,在同步方式下一般按以下方式編寫程式碼:

var output = fn1(fn2('input'));
// Do something.
而在非同步方式下,由於函式執行結果不是通過返回值,而是通過回撥函式傳遞,因此一般按以下方式編寫程式碼:

fn2('input', function (output2) {
    fn1(output2, function (output1) {
        // Do something.
    });
});

可以看到,這種方式就是一個回撥函式套一個回撥函多,套得太多了很容易寫出>形狀的程式碼。
 

  • 遍歷陣列

在遍歷陣列時,使用某個函式依次對資料成員做一些處理也是常見的需求。如果函式是同步執行的,一般就會寫出以下程式碼:

var len = arr.length,
    i = 0;

for (; i < len; ++i) {
    arr[i] = sync(arr[i]);
}

// All array items have processed.
如果函式是非同步執行的,以上程式碼就無法保證迴圈結束後所有陣列成員都處理完畢了。如果陣列成員必須一個接一個序列處理,則一般按照以下方式編寫非同步程式碼:

(function next(i, len, callback) {
    if (i < len) {
        async(arr[i], function (value) {
            arr[i] = value;
            next(i + 1, len, callback);
        });
    } else {
        callback();
    }
}(0, arr.length, function () {
    // All array items have processed.
}));

可以看到,以上程式碼在非同步函式執行一次並返回執行結果後才傳入下一個陣列成員並開始下一輪執行,直到所有陣列成員處理完畢後,通過回撥的方式觸發後續程式碼的執行。

如果陣列成員可以並行處理,但後續程式碼仍然需要所有陣列成員處理完畢後才能執行的話,則非同步程式碼會調整成以下形式:

(function (i, len, count, callback) {
    for (; i < len; ++i) {
        (function (i) {
            async(arr[i], function (value) {
                arr[i] = value;
                if (++count === len) {
                    callback();
                }
            });
        }(i));
    }
}(0, arr.length, 0, function () {
    // All array items have processed.
}));

可以看到,與非同步序列遍歷的版本相比,以上程式碼並行處理所有陣列成員,並通過計數器變數來判斷什麼時候所有陣列成員都處理完畢了。
 

  • 異常處理

JS自身提供的異常捕獲和處理機制——try..catch..,只能用於同步執行的程式碼。以下是一個例子。

function sync(fn) {
    return fn();
}

try {
    sync(null);
    // Do something.
} catch (err) {
    console.log('Error: %s', err.message);
}

-- Console ------------------------------
Error: object is not a function

可以看到,異常會沿著程式碼執行路徑一直冒泡,直到遇到第一個try語句時被捕獲住。但由於非同步函式會打斷程式碼執行路徑,非同步函式執行過程中以及執行之後產生的異常冒泡到執行路徑被打斷的位置時,如果一直沒有遇到try語句,就作為一個全域性異常丟擲。以下是一個例子。

function async(fn, callback) {
    // Code execution path breaks here.
    setTimeout(function () {
        callback(fn());
    }, 0);
}

try {
    async(null, function (data) {
        // Do something.
    });
} catch (err) {
    console.log('Error: %s', err.message);
}

-- Console ------------------------------
/home/user/test.js:4
        callback(fn());
                 ^
TypeError: object is not a function
    at null._onTimeout (/home/user/test.js:4:13)
    at Timer.listOnTimeout [as ontimeout] (timers.js:110:15)

因為程式碼執行路徑被打斷了,我們就需要在異常冒泡到斷點之前用try語句把異常捕獲住,並通過回撥函式傳遞被捕獲的異常。於是我們可以像下邊這樣改造上邊的例子。

function async(fn, callback) {
    // Code execution path breaks here.
    setTimeout(function () {
        try {
            callback(null, fn());
        } catch (err) {
            callback(err);
        }
    }, 0);
}

async(null, function (err, data) {
    if (err) {
        console.log('Error: %s', err.message);
    } else {
        // Do something.
    }
});

-- Console ------------------------------
Error: object is not a function

可以看到,異常再次被捕獲住了。在NodeJS中,幾乎所有非同步API都按照以上方式設計,回撥函式中第一個引數都是err。因此我們在編寫自己的非同步函式時,也可以按照這種方式來處理異常,與NodeJS的設計風格保持一致。

有了異常處理方式後,我們接著可以想一想一般我們是怎麼寫程式碼的。基本上,我們的程式碼都是做一些事情,然後呼叫一個函式,然後再做一些事情,然後再呼叫一個函式,如此迴圈。如果我們寫的是同步程式碼,只需要在程式碼入口點寫一個try語句就能捕獲所有冒泡上來的異常,示例如下。

function main() {
    // Do something.
    syncA();
    // Do something.
    syncB();
    // Do something.
    syncC();
}

try {
    main();
} catch (err) {
    // Deal with exception.
}

但是,如果我們寫的是非同步程式碼,就只有呵呵了。由於每次非同步函式呼叫都會打斷程式碼執行路徑,只能通過回撥函式來傳遞異常,於是我們就需要在每個回撥函式裡判斷是否有異常發生,於是只用三次非同步函式呼叫,就會產生下邊這種程式碼。

function main(callback) {
    // Do something.
    asyncA(function (err, data) {
        if (err) {
            callback(err);
        } else {
            // Do something
            asyncB(function (err, data) {
                if (err) {
                    callback(err);
                } else {
                    // Do something
                    asyncC(function (err, data) {
                        if (err) {
                            callback(err);
                        } else {
                            // Do something
                            callback(null);
                        }
                    });
                }
            });
        }
    });
}

main(function (err) {
    if (err) {
        // Deal with exception.
    }
});

可以看到,回撥函式已經讓程式碼變得複雜了,而非同步方式下對異常的處理更加劇了程式碼的複雜度。如果NodeJS的最大賣點最後變成這個樣子,那就沒人願意用NodeJS了,因此接下來會介紹NodeJS提供的一些解決方案。
 

3、域(Domain)

NodeJS提供了domain模組,可以簡化非同步程式碼的異常處理。在介紹該模組之前,我們需要首先理解“域”的概念。簡單的講,一個域就是一個JS執行環境,在一個執行環境中,如果一個異常沒有被捕獲,將作為一個全域性異常被丟擲。NodeJS通過process物件提供了捕獲全域性異常的方法,示例程式碼如下
 

process.on('uncaughtException', function (err) {
    console.log('Error: %s', err.message);
});

setTimeout(function (fn) {
    fn();
});

-- Console ------------------------------
Error: undefined is not a function

雖然全域性異常有個地方可以捕獲了,但是對於大多數異常,我們希望儘早捕獲,並根據結果決定程式碼的執行路徑。我們用以下HTTP伺服器程式碼作為例子:

function async(request, callback) {
    // Do something.
    asyncA(request, function (err, data) {
        if (err) {
            callback(err);
        } else {
            // Do something
            asyncB(request, function (err, data) {
                if (err) {
                    callback(err);
                } else {
                    // Do something
                    asyncC(request, function (err, data) {
                        if (err) {
                            callback(err);
                        } else {
                            // Do something
                            callback(null, data);
                        }
                    });
                }
            });
        }
    });
}

http.createServer(function (request, response) {
    async(request, function (err, data) {
        if (err) {
            response.writeHead(500);
            response.end();
        } else {
            response.writeHead(200);
            response.end(data);
        }
    });
});

以上程式碼將請求物件交給非同步函式處理後,再根據處理結果返回響應。這裡採用了使用回撥函式傳遞異常的方案,因此async函式內部如果再多幾個非同步函式呼叫的話,程式碼就變成上邊這副鬼樣子了。為了讓程式碼好看點,我們可以在每處理一個請求時,使用domain模組建立一個子域(JS子執行環境)。在子域內執行的程式碼可以隨意丟擲異常,而這些異常可以通過子域物件的error事件統一捕獲。於是以上程式碼可以做如下改造:
 

function async(request, callback) {
    // Do something.
    asyncA(request, function (data) {
        // Do something
        asyncB(request, function (data) {
            // Do something
            asyncC(request, function (data) {
                // Do something
                callback(data);
            });
        });
    });
}

http.createServer(function (request, response) {
    var d = domain.create();

    d.on('error', function () {
        response.writeHead(500);
        response.end();
    });

    d.run(function () {
        async(request, function (data) {
            response.writeHead(200);
            response.end(data);
        });
    });
});

可以看到,我們使用.create方法建立了一個子域物件,並通過.run方法進入需要在子域中執行的程式碼的入口點。而位於子域中的非同步函式回撥函式由於不再需要捕獲異常,程式碼一下子瘦身很多。

注意

無論是通過process物件的uncaughtException事件捕獲到全域性異常,還是通過子域物件的error事件捕獲到了子域異常,在NodeJS官方文件裡都強烈建議處理完異常後立即重啟程式,而不是讓程式繼續執行。按照官方文件的說法,發生異常後的程式處於一個不確定的執行狀態,如果不立即退出的話,程式可能會發生嚴重記憶體洩漏,也可能表現得很奇怪

但這裡需要澄清一些事實。JS本身的throw..try..catch異常處理機制並不會導致記憶體洩漏,也不會讓程式的執行結果出乎意料,但NodeJS並不是存粹的JS。NodeJS裡大量的API內部是用C/C++實現的,因此NodeJS程式的執行過程中,程式碼執行路徑穿梭於JS引擎內部和外部,而JS的異常丟擲機制可能會打斷正常的程式碼執行流程,導致C/C++部分的程式碼表現異常,進而導致記憶體洩漏等問題。

因此,使用uncaughtException或domain捕獲異常,程式碼執行路徑裡涉及到了C/C++部分的程式碼時,如果不能確定是否會導致記憶體洩漏等問題,最好在處理完異常後重啟程式比較妥當。而使用try語句捕獲異常時一般捕獲到的都是JS本身的異常,不用擔心上訴問題。

參考原文:http://nqdeng.github.io/7-days-nodejs/#4.2.2,尊重原創,感謝原文作者