Node.js之異常處理

xiaoqb發表於2016-01-20

   記得剛剛開始學Node.js時自己嘗試著寫了一個簡單的http伺服器,跟以前接觸過的php相比感覺更自由,編起碼來也更爽了。但是某天發現稍微一個很小的錯誤就導致整個http程式掛掉了,頓時有種不靠譜的感覺啊,跟php比起來感覺Node.js容錯能力確實弱了很多,起碼一個php檔案出錯也不會導致所有的服務都掛掉。

   

      後來接觸到Node.js web開發框架後感覺也不是那麼輕易就讓整個程式都掛掉的,於是便想研究下Node.js究竟是如何來處理各種異常從而避免整個程式掛掉的。

     

     當我們的程式執行在Node.js程式裡不小心丟擲一個異常時便會觸發process物件的_fatalException方法,並將異常物件err傳進去,_fatalException方法主要做以下一些處理:

 

當process物件上有繫結domain時便呼叫domain物件的_errorHandler方法來處理,

 

if (process.domain && process.domain._errorHandler)
        caught = process.domain._errorHandler(er)

 

_errorHandler會返回一個布林值來通知當前程式domain是否有對該異常進行處理,如果domain沒有做處理,此時process物件便會觸發一個繫結到process上的uncaughtException事件來處理該異常,並且同樣會返回一個布林值來通知當前程式是否有對異常進行處理。

 

if (!caught)
        caught = process.emit(`uncaughtException`, er);

 

走到這個地步時如果異常還沒被正常的處理那麼此時process就有點不高興了,既然你們都不處理那我就準備讓你們全部掛掉吧!(確實太狠了點啊),這個時候悲劇即將發生。。。

if (!caught) {
        try {
          if (!process._exiting) {
            process._exiting = true;
            process.emit(`exit`, 1);
          }
        } catch (er) {
        }
}

 

如果異常都被妥妥的處理掉了那麼Node.js程式便會處理當前事件的收尾的工作,比如呼叫process.nextTick傳進去的回撥函式在這個時候就準備被呼叫了,然後繼續執行事件佇列裡的下一個事件

t = setImmediate(process._tickCallback)

總結下來Node.js中異常處理流程大概就是這樣的:

alt

 

這整個過程中有個很重要的處理環節沒有加上去,那就是上面提到的domain物件。
首先簡單介紹下domain物件的使用場景以及基本使用方法:

 

當我們開啟一個Node.js的http伺服器時不可避免的會出現各種我們沒有預期到的異常,並且我們預先寫好的try catch也無法捕捉。這時最關鍵的是如何保證整個服務程式不會掛掉,並且能夠很友好的反饋給瀏覽器端的使用者。儘管process物件提供了一個uncaughtException事件方法讓我們可以處理異常並且保證當前的服務程式不會掛掉,但由於丟失了當前的上下文,說得直接點就是丟失了response物件很難向使用者及時並且友好的輸出錯誤提示,此時便陷入了使用者會一直傻傻的等待伺服器超時(早就關閉網站了)的尷尬場景。

 

有了domain模組我們便可以很方便的處理上面描述的場景了,剛剛開始接觸domain這個模組時真不知道是個啥東西,名字都叫的怪怪的。後來去翻了先官網上有關domain的文件才知道這貨到底有啥作用,我們就依照官網的示例來說明domain如何處理上述場景:

 

http.createServer(function(req, res) {
    var reqd = domain.create();
    reqd.add(req);
    reqd.add(res);
    reqd.on(`error`, function(er) {
      console.error(`Error`, er, req.url);
      try {
        res.writeHead(500);
        res.end(`Error occurred, sorry.`);
      } catch (er) {
        res.end(`Error sending 500`, er.message, req.url);
      }
 });

當res物件呼叫各種方法產生異常時,之前建立好的domain物件reqd便會收到通知,從而觸發我們預先設定好的處理方法來即使並且友好的輸出給使用者,避免超時這種糟糕的使用者體驗!對於domain物件其他的方法大家可以直接翻看Node.js官網文件的介紹,我這裡就不囉嗦了~

 

下面我們著重的來研究下domain物件為何如此神奇?

當我們require(`domain`)物件時便對event模組的EventEmitter物件產生了影響

EventEmitter.usingDomains = true;

緊跟著對process的domain屬性進行了覆蓋

Object.defineProperty(process, `domain`, {
  enumerable: true,
  get: function() {
    return _domain[0];
  },
  set: function(arg) {
    return _domain[0] = arg;
  }
});

domain模組本身維護著一個存放domain物件的陣列 _domain,再接著就是告訴process物件要使用到domain了

process._setupDomainUse(_domain, _domain_flag);

呼叫這個方法後影響到的地方可不少,之前我們說過Node.js每個事件都會呼叫一下_tickCallback來處理之前呼叫process.nextTick儲存到事件佇列裡的回撥函式,現在Node.js不呼叫了這個了,換成了呼叫_tickDomainCallback方法來代替_tickCallback。繼續我們的domain模組,當建立一個新的domain物件時便初始化了的它的members屬性來存放該domain要守護的物件,對照著上述的程式碼

 

var reqd = domain.create();

此時reqd.members=[], 於是我們呼叫add方法將req已經res物件都新增到domain中,由domain來幫他們處理各種錯誤。

reqd.add(req);
reqd.add(res);

接著告訴domain當req或者res操作出異常時應該如何處理

reqd.on(`error`, function(er) {
      console.error(`Error`, er, req.url);
      try {
        res.writeHead(500);
        res.end(`Error occurred, sorry.`);
      } catch (er) {
        console.error(`Error sending 500`, er, req.url);
      }
});

其實就上面那樣還是沒法捕獲到異常,甚至都無法響應,因為我們還沒呼叫res.write或者res.end方法來向使用者輸出內容,就算我們加上

reqd.add(req);
reqd.add(res);
res.test(`end`);

依然無法像我們預期想象的那樣進入異常處理回撥方法裡,別忘了將可能發生異常的程式碼放入domain.run中來執行,就像這樣的:

var domain=require(`domain`),
	http=require(`http`);

http.createServer(function(req, res) {
    var reqd = domain.create();
    reqd.add(req);
    reqd.add(res);
    reqd.on(`error`, function(er) {
      console.error(`Error`, er, req.url);
      try {
        res.writeHead(500);
        res.end(`Error occurred, sorry.`+ er.message);
      } catch (er) {
        console.error(`Error sending 500`, er, req.url);
      }
    });

    reqd.run(function(){
    	res.test();
    	res.end(`end`);
    });
}).listen(1337);

此時一切都已就緒,萬事俱備只欠東風了,就等著各種異常來臨了。ok, 此時由於res的某個操作(比如呼叫不存在的test方法)導致了一個異常的產生。根據最開始描述的處理流程,這個異常會被Node.js程式傳到process._fatalException中進行處理,如果process上繫結有domain物件則會呼叫domain的_errorHandler方法來處理異常,那_errorHandler究竟如火如荼處理異常的呢?在討論這個問題之前我們先回到上面的reqd.run方法中。呼叫domain物件的run方法時會先進入enter裡做如下處理:

exports.active = process.domain = this;
stack.push(this);
_domain_flag[0] = stack.length;

將當期的domain物件設定成active並且繫結到process上,stack是一個儲存domain物件的堆疊,用於domain巢狀使用的情況,其中_domain_flag是一個用於js與c++進行通訊的物件。緊接著再執行我們的業務程式碼比如res.test()操作,此時便丟擲了一個方法不存在的異常。由於進入enter方法後我們把當前domain物件繫結到了process上,所以異常就交給domain的_errorHandler方法來處理了,回到之前的問題,_errorHandler是如何處理異常的?

首先嚐試著讓之前繫結到domain上的error事件回撥函式來處理該異常並清空當前process的domain屬性,之所以所嘗試是因為回撥函式裡可能又會丟擲新的異常,當然了理想情況就是回撥函式能夠很好的處理掉異常並且不丟擲新的異常,此時整個異常處理流程完美結束。如果有新的異常丟擲,先將對stack堆疊進行出棧操作剔除已經使用過的當期domain物件,然後再看看棧裡邊是否還存在domain物件,有的話就用棧訂上的domain又回到process._fatalException裡繼續處理剛剛回撥函式丟擲的新異常。stack為空的話此時已經沒有domain物件可以來處理異常,至次本次異常處理以失敗結束然後繼續交給最開始講到的uncaughtException事件來處理。當然了呼叫domain.run時並沒有丟擲異常,那麼domain也需要進行出棧操作,來抵消enter方法時的入棧操作以保持stack堆疊的平衡。

 

其實上面的reqd.add(res)和reqd.add(req)是可以不要的,為什麼可以不要呢?在什麼情況下需要什麼情況下又不需要?ok,我們再深入研究一下domain.add是如何工作的。官網中文件有介紹domain.add接收emitter型別的引數,也就是EventEmitter | Timer emitter or timer。為什麼要這樣呢,看下面的一段程式碼

var EventEmitter = require(`events`).EventEmitter;

var e = new EventEmitter();

var timer = setTimeout(function () {
  e.emit(`data`);  
}, 1000);

function next() {
  e.once(`data`, function () {
    throw new Error(`something wrong here`);
  });
}

var d = domain.create();
d.on(`error`, function () {
  console.log(`cache by domain`);
});

d.run(next);

此時next函式裡邊繫結到e物件上的data事件被觸發時domain物件是無法處理的,原因很明顯,data回撥函式的執行已經處理domain.run方法之外。那我就要這個domain來處理錯誤怎麼辦呢,此時domain.add方法就派上用場了,我們只需要簡單的呼叫一下d.add(e)或者d.add(timer)就可以解決這個問題。domain.add方法為什麼可以解決又是如何解決的呢?繼續往下看。

 

當呼叫domain.add(e)時,如果上繫結有domain先移除再繫結新的domain,並將e物件加入新domain的members中,從而保持著對e物件的引用。不管是timer物件還是event物件在觸發回撥函式時都會先判斷是否有繫結domain物件

if (this.domain && this !== process)
    this.domain.enter();
callback();
if (this.domain && this !== process)
    this.domain.exit();

這些操作和domain.run方法相似,先執行enter將domain物件繫結到process上,然後再執行回撥當有異常發生時process會將異常傳到domain上處理,最後再呼叫exit方法將該domain移出stack堆疊。所以上面的程式碼中必須得呼叫下d.add(e)或者d.add(timer)才會讓domain物件捕獲到回撥中的異常。

 

整個Node.js異常處理就講到這裡了,其實在process._fatalException方法中呼叫domain來處理異常之前還進行了一個異常處理操作

var caught = _errorHandler(er);

這個處理主要涉及到Node.js的非同步佇列AsyncQueue在這裡暫不做討論,以後再做進一步的研究,文章有點長感謝能堅持看到結尾的同學們,不要吝嗇你們的贊哦~

該文章來自於阿里巴巴技術協會(ATA

作者:淘傑