nodejs中錯誤捕獲的一些最佳實踐

  • 2019 年 12 月 4 日
  • 筆記

本文作者:IMWeb yisbug 原文出處:IMWeb社區 未經同意,禁止轉載

本文內容大部分來自 https://www.joyent.com/node-js/production/design/errors ,原文比較長,感覺也有點啰嗦,所以根據個人理解猜測梳理出本文,如果有錯誤歡迎指出,謝謝!

很多人其實不是很重視錯誤處理,但對於構建一個健壯的nodejs應用,錯誤處理是非常重要的一件事情,希望本文可以給你一些啟發。

先拋出幾個問題:

  1. 應該用哪種方式暴露錯誤?throwcallback(err, result)Event Emitter或者其他方式?
  2. 如何假設函數的參數?是否應該檢測類型正確?非null,IP,QQ號碼?
  3. 函數參數不符合預期該怎麼處理?
  4. 應該如何區分不同類型的錯誤?例如Bad RequestService Unavailable
  5. 應該如何提供有用的錯誤資訊?
  6. 應該如何捕獲錯誤?使用try/catch,還是domains或者其他方式?

一些基礎知識

關於Errorthrowtry...catch的一些基礎知識鏈接

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/throw https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/try…catch

node.js v7.2.0 domainprocess https://nodejs.org/api/domain.html https://nodejs.org/api/process.html

verror模組: rich JavaScript errors https://github.com/joyent/node-verror

拋出錯誤的幾種方式:

var myEmitter = new MyEmitter();  doSomeAsynchronousOperation(function (err) {        if (err) throw (err); // 直接throw        if (err) callback(err); // 使用callback,nodejs中常見的非同步處理方式        myEmitter.emit('error', new Error('whoops!')); // error事件  });

捕獲錯誤

try{      var result = JSON.parse(str);  }catch(e){      // 捕獲錯誤  }

分類錯誤

一般來說,我們將錯誤簡單的分為兩種類型:操作錯誤、編碼錯誤。

對於有經驗的人來說,寫程式碼的時候都會處理一些常見的操作錯誤,例如JSON.parse總是會和try...catch一起,例如網路故障、遠程伺服器返回500等。這些錯誤並非bug。

對於程式來說,另外一種錯誤屬於編碼錯誤,這是程式的bug,解決的方式應該是修改程式碼,避免發生。例如read property of "undefined"、調用一個非同步函數但沒有傳入callback、函數參數預期是Object但是傳了一個String等等。

人們在談論錯誤時,總是將這兩種錯誤混在一起,實際上這兩種錯誤是完全不同的。例如File not found是一種操作錯誤,但這不能說明哪裡出錯了,這可能僅僅表示程式應該先創建文件。

有些時候,同一個問題可能會導致多種錯誤。例如nodejs應用因為一個變數undefined導致crash,這是編碼錯誤,客戶端則會接收到ECONNRESET錯誤,這屬於操作錯誤,對於客戶端來說應該可以預期到伺服器的這個錯誤。

如何處理 操作錯誤

  • 對於明確的操作錯誤類型,直接處理掉。

例如嘗試打開一個log文件可能會導致 ENOENT ,那麼創建這個文件即可。

  • 對於預料之外你不知道如何處理的錯誤,比較好的方式是記錄error並crash,傳遞合適的錯誤資訊給客戶端。

如何處理 程式碼錯誤

最好的方式是立即crash。

這種錯誤是程式的bug,一般來說寫再多的程式碼也避免不了。因為在node應用中,我們一般會監控掛掉的進程並自動重啟,所以立即crash是比較好的方式。

調試這類問題的最佳方式,是在捕獲到uncaught exception的時候,記錄相關資訊。

總之記住,server的程式碼錯誤(bug)傳遞到client時會成為一個操作錯誤,例如server捕獲到uncaught exception則返回一個500,客戶端來處理這個操作錯誤。

如何傳遞錯誤?

首先,最重要的是文檔,描述這個函數做了些什麼,接收什麼類型的參數返回什麼,可能會觸發什麼錯誤。

一些基本原則:

  • 同步的函數里,使用throw。使用者使用try...catch即可捕獲錯誤。
  • 非同步函數里,更常用的方式是使用callback(err, result)的方式。
  • 在更複雜的場景里,可以返回一個EventEmitter對象,代替使用callback。使用者可以監聽emitter對象的 error事件。 例如讀取一個數據流,我們可能會同時使用 req.on('data')req.on('error')req.on('timeout')

所以,使用throw還是callbacksEventEmitter,取決於:

  • 該錯誤是操作錯誤還是編碼錯誤?
  • 該函數是同步還是非同步?

此外,

  • 不管是同步(使用throw)或者非同步(使用callbackEventEmitter),只使用一種方式傳遞錯誤,避免同時使用兩種方式。這樣的話,使用者就只需要使用一種方式來捕獲錯誤,例如try...catch或者callback,不需要考慮更多的場景。

下面用一個特例來說明這一點:

// 非同步函數,err是操作錯誤,使用callback傳遞  fs.stat('不存在的文件',function(err){})  // 非同步函數,參數錯誤,會立即拋出異常  fs.stat(null,function(err){})

在上例的第二種情況,會立即返回TypeError: path must be a string or Buffer,也就是說內部使用了throw,這種情況是不是和上面提到的有矛盾?

其實並不是,第二種情況屬於編碼錯誤(fs.stat只接收路徑作為參數但我們給了他一個null),並不是操作錯誤。編碼錯誤永遠不應該被處理。

所以在使用fs.stat的時,使用者仍然只需要處理callback傳遞的錯誤,不需要使用try...catch

錯誤的輸入屬於哪種情況?編碼錯誤還是操作錯誤?

這一點取決於函數申明的可以允許的類型,以及你如何來解釋它們:

  • 如果得到的參數和申明的類型(不一定是指數據類型,也可能是IP地址、QQ號等類型)不一致,那麼屬於編碼錯誤(使用者應該使用符合要求的參數)
  • 如果得到的參數和申明的類型一致,但函數不能處理這種情況,那屬於操作錯誤。

你必須決定限制類型的嚴格程度。

例如需要連接到一個伺服器,函數接收一個ip地址作為參數,那麼有幾種做法:

  • 函數只接收ip地址格式的參數,如果不符合格式,則立即拋出異常。
  • 函數接收任意字元串參數,如果參數不是ip地址格式,則使用callback發出一個非同步錯誤,提示無法連接該地址。

這兩種做法決定了同樣的輸入會導致編碼錯誤或操作錯誤。對於大多數功能,我們強烈建議更嚴格,因為更寬鬆的限制會更容易導致使用錯誤以及浪費時間。

什麼時候使用domainprocess.on('uncaughtException') ?

操作錯誤一般都可以使用明確的機制來處理(根據具體的錯誤對應處理,使用try...catchcallbackEventEmitter等)。

domain和全局的異常捕獲主要是為了發現和處理未預料到的編碼錯誤。

編寫functions的具體建議

  • 清楚function的功能

必須明確幾點:期待的參數、參數類型、額外約束(IP地址、QQ號碼等)

如果任意一點不匹配,則立即拋出throw異常。

此外,還應該有:使用方可以預料到的操作錯誤、如何捕獲這些錯誤、返回值。

  • 所有的erorr都使用Error對象(或者基於Error類的擴展)

所有的error都應該提供namemessage屬性,並且stack也應該準確可用。

  • 使用name屬性來區分錯誤類型

例如RangeErrorTypeError。 不要為每種錯誤取個名字,例如定義InvalidHostnameErrorInvalidIpAddressError這種來表示具體的錯誤,對於這種錯誤可以統一用InvalidArgumentError表示錯誤類型,然後在詳細描述里補充更多資訊。

  • 增加解釋錯誤細節的屬性

例如無法連接到伺服器,可以增加一個remoteIp 屬性表示試圖連接的ip。

  • 如果傳遞一個較低級別的錯誤,考慮重新包裝錯誤。

如果函數調用順序如下:funcA -> funcB -> funcC,funcC返回一個載入配置失敗的錯誤,funcB連接伺服器失敗。

那麼,在funcA中,更希望得到包含這2個錯誤的資訊。所以在funcB中捕獲到funcC的錯誤時,包裝並傳遞這些錯誤是有價值的。

包裝底層的錯誤資訊時,儘可能保留原始的資訊,除了名稱name,但不要改寫原始的error對象。

一個組合多個錯誤的示例:

myserver:      failed to start up:          failed to load configuration:              failed to connect to database server:                  failed to connect to 127.0.0.1 port 1234:                      connect ECONNREFUSED

這裡有一個庫可以幫我們做這件事:

https://github.com/joyent/node-verror

總結

  • 區分錯誤類型,是可預見的還是不可避免的,是操作錯誤還是bug。
  • 操作錯誤應該被處理。編碼錯誤不應該被處理(全局處理並記錄)。
  • 一個函數可能產生的操作錯誤,只應該使用同步(throw)或者非同步一種方式。一般來說,在nodejs中,同步函數導致的操作錯誤是比較少見的,使用try...catch會很少,常見的是用戶輸入驗證如JSON、解析等。
  • 一個函數的參數、類型、預期錯誤、如何捕獲都應該是明確的。
  • 缺少參數、參數無效都屬於編碼錯誤,應該直接拋出異常(throw)。
  • 使用標準的Error類和標準屬性。使用獨立的屬性,添加儘可能多的附加資訊,儘可能使用通用的屬性名稱。

例如一些常見的屬性名稱:

localHostname、localIp、localPort、remoteHostname、remoteIp、remotePort、path、srcpath、dstpath、hostname、ip、propertyName、propertyValue、syscall、errno

最後

  • 不要嘗試用try...catch去捕獲一個非同步函數的錯誤,這樣會什麼也得不到。
  • 如果不是產生錯誤,不要使用throw