nodejs中錯誤捕獲的一些最佳實踐
- 2019 年 12 月 4 日
- 筆記
本文作者:IMWeb yisbug 原文出處:IMWeb社區 未經同意,禁止轉載
本文內容大部分來自 https://www.joyent.com/node-js/production/design/errors ,原文比較長,感覺也有點啰嗦,所以根據個人理解猜測梳理出本文,如果有錯誤歡迎指出,謝謝!
很多人其實不是很重視錯誤處理,但對於構建一個健壯的nodejs應用,錯誤處理是非常重要的一件事情,希望本文可以給你一些啟發。
先拋出幾個問題:
- 應該用哪種方式暴露錯誤?
throw
、callback(err, result)
、Event Emitter
或者其他方式? - 如何假設函數的參數?是否應該檢測類型正確?非null,IP,QQ號碼?
- 函數參數不符合預期該怎麼處理?
- 應該如何區分不同類型的錯誤?例如
Bad Request
、Service Unavailable
- 應該如何提供有用的錯誤資訊?
- 應該如何捕獲錯誤?使用
try/catch
,還是domains
或者其他方式?
一些基礎知識
關於Error
、throw
、try...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 domain
、process
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
還是callbacks
、EventEmitter
,取決於:
- 該錯誤是操作錯誤還是編碼錯誤?
- 該函數是同步還是非同步?
此外,
- 不管是同步(使用throw)或者非同步(使用
callback
或EventEmitter
),只使用一種方式傳遞錯誤,避免同時使用兩種方式。這樣的話,使用者就只需要使用一種方式來捕獲錯誤,例如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發出一個非同步錯誤,提示無法連接該地址。
這兩種做法決定了同樣的輸入會導致編碼錯誤或操作錯誤。對於大多數功能,我們強烈建議更嚴格,因為更寬鬆的限制會更容易導致使用錯誤以及浪費時間。
什麼時候使用domain
和process.on('uncaughtException')
?
操作錯誤一般都可以使用明確的機制來處理(根據具體的錯誤對應處理,使用try...catch
、callback
、EventEmitter
等)。
domain和全局的異常捕獲主要是為了發現和處理未預料到的編碼錯誤。
編寫functions的具體建議
- 清楚function的功能
必須明確幾點:期待的參數、參數類型、額外約束(IP地址、QQ號碼等)
如果任意一點不匹配,則立即拋出throw
異常。
此外,還應該有:使用方可以預料到的操作錯誤、如何捕獲這些錯誤、返回值。
- 所有的erorr都使用Error對象(或者基於Error類的擴展)
所有的error都應該提供name
和message
屬性,並且stack
也應該準確可用。
- 使用
name
屬性來區分錯誤類型
例如RangeError
、TypeError
。 不要為每種錯誤取個名字,例如定義InvalidHostnameError
、InvalidIpAddressError
這種來表示具體的錯誤,對於這種錯誤可以統一用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
。