[譯] NodeJS 錯誤處理最佳實踐

NodeJS的錯誤處理讓人痛苦,在很長的一段時間裏,大量的錯誤被聽任無論。可是要想創建一個健壯的Node.js程序就必須正確的處理這些錯誤,並且這並不難學。若是你實在沒有耐心,那就直接繞過長篇大論跳到「總結」部分吧。程序員

原文數據庫

這篇文章會回答NodeJS初學者的若干問題:編程

  • 我寫的函數裏何時該拋出異常,何時該傳給callback,何時觸發EventEmitter等等。
  • 個人函數對參數該作出怎樣的假設?我應該檢查更加具體的約束麼?例如參數是否非空,是否大於零,是否是看起來像個IP地址,等等等。
  • 我該如何處理那些不符合預期的參數?我是應該拋出一個異常,仍是把錯誤傳遞給一個callback。
  • 我該怎麼在程序裏區分不一樣的異常(好比「請求錯誤」和「服務不可用」)?
  • 我怎麼才能提供足夠的信息讓調用者知曉錯誤細節。
  • 我該怎麼處理未預料的出錯?我是應該用 try/catch ,domains 仍是其它什麼方式呢?

這篇文章能夠劃分紅互相爲基礎的幾個部分:緩存

  • 背景:但願你所具有的知識。
  • 操做失敗和程序員的失誤:介紹兩種基本的異常。
  • 編寫新函數的實踐:關於怎麼讓函數產生有用報錯的基本原則。
  • 編寫新函數的具體推薦:編寫能產生有用報錯的、健壯的函數須要的一個檢查列表
  • 例子:以connect函數爲例的文檔和序言。
  • 總結:全文至此的觀點總結。
  • 附錄:Error對象屬性約定:用標準方式提供一個屬性列表,以提供更多信息。

背景

本文假設:安全

你已經熟悉了JavaScript、Java、 Python、 C++ 或者相似的語言中異常的概念,並且你知道拋出異常和捕獲異常是什麼意思。
你熟悉怎麼用NodeJS編寫代碼。你使用異步操做的時候會很自在,並能用callback(err,result)模式去完成異步操做。你得知道下面的代碼不能正確處理異常的緣由是什麼[腳註1]服務器

function myApiFunc(callback)
{
/*
 * This pattern does NOT work!
 */
try {
  doSomeAsynchronousOperation(function (err) {
    if (err)
      throw (err);
    /* continue as normal */
  });
} catch (ex) {
  callback(ex);
}
}

你還要熟悉三種傳遞錯誤的方式: - 做爲異常拋出。 - 把錯誤傳給一個callback,這個函數正是爲了處理異常和處理異步操做返回結果的。 - 在EventEmitter上觸發一個Error事件。網絡

接下來咱們會詳細討論這幾種方式。這篇文章不假設你知道任何關於domains的知識。閉包

最後,你應該知道在JavaScript裏,錯誤和異常是有區別的。錯誤是Error的一個實例。錯誤被建立而且直接傳遞給另外一個函數或者被拋出。若是一個錯誤被拋出了那麼它就變成了一個異常[腳註2]。舉個例子:app

throw new Error('something bad happened');dom

可是使用一個錯誤而不拋出也是能夠的

callback(new Error('something bad happened'));

這種用法更常見,由於在NodeJS裏,大部分的錯誤都是異步的。實際上,try/catch惟一經常使用的是在JSON.parse和相似驗證用戶輸入的地方。接下來咱們會看到,其實不多要捕獲一個異步函數裏的異常。這一點和Java,C++,以及其它嚴重依賴異常的語言很不同。

操做失敗和程序員的失誤

把錯誤分紅兩大類頗有用[腳註3]:

  • 操做失敗是正確編寫的程序在運行時產生的錯誤。它並非程序的Bug,反而常常是其它問題:系統自己(內存不足或者打開文件數過多),系統配置(沒有到達遠程主機的路由),網絡問題(端口掛起),遠程服務(500錯誤,鏈接失敗)。例子以下:
  • 鏈接不到服務器
  • 沒法解析主機名
  • 無效的用戶輸入
  • 請求超時
  • 服務器返回500
  • 套接字被掛起
  • 系統內存不足
  • 程序員失誤是程序裏的Bug。這些錯誤每每能夠經過修改代碼避免。它們永遠都無法被有效的處理。
  • 讀取 undefined 的一個屬性
  • 調用異步函數沒有指定回調
  • 該傳對象的時候傳了一個字符串
  • 該傳IP地址的時候傳了一個對象

人們把操做失敗和程序員的失誤都稱爲「錯誤」,但其實它們很不同。操做失敗是全部正確的程序應該處理的錯誤情形,只要被妥善處理它們不必定會預示着Bug或是嚴重的問題。「文件找不到」是一個操做失敗,可是它並不必定意味着哪裏出錯了。它可能只是表明着程序若是想用一個文件得事先建立它。

與之相反,程序員失誤是不折不扣的Bug。這些情形下你會犯錯:忘記驗證用戶輸入,敲錯了變量名,諸如此類。這樣的錯誤根本就無法被處理,若是能夠,那就意味着你用處理錯誤的代碼代替了出錯的代碼。

這樣的區分很重要:操做失敗是程序正常操做的一部分。而由程序員的失誤則是Bug。

有的時候,你會在一個Root問題裏同時遇到操做失敗和程序員的失誤。HTTP服務器訪問了未定義的變量時奔潰了,這是程序員的失誤。當前鏈接着的客戶端會在程序崩潰的同時看到一個ECONNRESET錯誤,在NodeJS裏一般會被報成「Socket Hang-up」。對客戶端來講,這是一個不相關的操做失敗, 那是由於正確的客戶端必須處理服務器宕機或者網絡中斷的狀況。

相似的,若是不處理好操做失敗, 這自己就是一個失誤。舉個例子,若是程序想要鏈接服務器,可是獲得一個ECONNREFUSED錯誤,而這個程序沒有監聽套接字上的error事件,而後程序崩潰了,這是程序員的失誤。鏈接斷開是操做失敗(由於這是任何一個正確的程序在系統的網絡或者其它模塊出問題時都會經歷的),若是它不被正確處理,那它就是一個失誤。

理解操做失敗和程序員失誤的不一樣, 是搞清怎麼傳遞異常和處理異常的基礎。明白了這點再繼續往下讀。

處理操做失敗

就像性能和安全問題同樣,錯誤處理並非能夠憑空加到一個沒有任何錯誤處理的程序中的。你沒有辦法在一個集中的地方處理全部的異常,就像你不能在一個集中的地方解決全部的性能問題。你得考慮任何會致使失敗的代碼(好比打開文件,鏈接服務器,Fork子進程等)可能產生的結果。包括爲何出錯,錯誤背後的緣由。以後會說起,可是關鍵在於錯誤處理的粒度要細,由於哪裏出錯和爲何出錯決定了影響大小和對策。

你可能會發如今棧的某幾層不斷地處理相同的錯誤。這是由於底層除了向上層傳遞錯誤,上層再向它的上層傳遞錯誤之外,底層沒有作任何有意義的事情。一般,只有頂層的調用者知道正確的應對是什麼,是重試操做,報告給用戶仍是其它。可是那並不意味着,你應該把全部的錯誤全都丟給頂層的回調函數。由於,頂層的回調函數不知道發生錯誤的上下文,不知道哪些操做已經成功執行,哪些操做實際上失敗了。

咱們來更具體一些。對於一個給定的錯誤,你能夠作這些事情:

  • 直接處理。有的時候該作什麼很清楚。若是你在嘗試打開日誌文件的時候獲得了一個ENOENT錯誤,頗有可能你是第一次打開這個文件,你要作的就是首先建立它。更有意思的例子是,你維護着到服務器(好比數據庫)的持久鏈接,而後遇到了一個「socket hang-up」的異常。這一般意味着要麼遠端要麼本地的網絡失敗了。不少時候這種錯誤是暫時的,因此大部分狀況下你得從新鏈接來解決問題。(這和接下來的重試不大同樣,由於在你獲得這個錯誤的時候不必定有操做正在進行)
  • 把出錯擴散到客戶端。若是你不知道怎麼處理這個異常,最簡單的方式就是放棄你正在執行的操做,清理全部開始的,而後把錯誤傳遞給客戶端。(怎麼傳遞異常是另一回事了,接下來會討論)。這種方式適合錯誤短期內沒法解決的情形。好比,用戶提交了不正確的JSON,你再解析一次是沒什麼幫助的。
  • 重試操做。對於那些來自網絡和遠程服務的錯誤,有的時候重試操做就能夠解決問題。好比,遠程服務返回了503(服務不可用錯誤),你可能會在幾秒種後重試。若是肯定要重試,你應該清晰的用文檔記錄下將會屢次重試,重試多少次直到失敗,以及兩次重試的間隔。 另外,不要每次都假設須要重試。若是在棧中很深的地方(好比,被一個客戶端調用,而那個客戶端被另一個由用戶操做的客戶端控制),這種情形下快速失敗讓客戶端去重試會更好。若是棧中的每一層都以爲須要重試,用戶最終會等待更長的時間,由於每一層都沒有意識到下層同時也在嘗試。
  • 直接崩潰。對於那些本不可能發生的錯誤,或者由程序員失誤致使的錯誤(好比沒法鏈接到同一程序裏的本地套接字),能夠記錄一個錯誤日誌而後直接崩潰。其它的好比內存不足這種錯誤,是JavaScript這樣的腳本語言沒法處理的,崩潰是十分合理的。(即使如此,在child_process.exec這樣的分離的操做裏,獲得ENOMEM錯誤,或者那些你能夠合理處理的錯誤時,你應該考慮這麼作)。在你機關用盡須要讓管理員作修復的時候,你也能夠直接崩潰。若是你用光了全部的文件描述符或者沒有訪問配置文件的權限,這種狀況下你什麼都作不了,只能等某個用戶登陸系統把東西修好。
  • 記錄錯誤,其餘什麼都不作。有的時候你什麼都作不了,沒有操做能夠重試或者放棄,沒有任何理由崩潰掉應用程序。舉個例子吧,你用DNS跟蹤了一組遠程服務,結果有一個DNS失敗了。除了記錄一條日誌而且繼續使用剩下的服務之外,你什麼都作不了。可是,你至少得記錄點什麼(凡事都有例外。若是這種狀況每秒發生幾千次,而你又無法處理,那每次發生都記錄可能就不值得了,可是要週期性的記錄)。

(沒有辦法)處理程序員的失誤

對於程序員的失誤沒有什麼好作的。從定義上看,一段本該工做的代碼壞掉了(好比變量名敲錯),你不能用更多的代碼再去修復它。一旦你這樣作了,你就使用錯誤處理的代碼代替了出錯的代碼。

有些人同意從程序員的失誤中恢復,也就是讓當前的操做失敗,可是繼續處理請求。這種作法不推薦。考慮這樣的狀況:原始代碼裏有一個失誤是沒考慮到某種特殊狀況。你怎麼肯定這個問題不會影響其餘請求呢?若是其它的請求共享了某個狀態(服務器,套接字,數據庫鏈接池等),有極大的可能其餘請求會不正常。

典型的例子是REST服務器(好比用Restify搭的),若是有一個請求處理函數拋出了一個ReferenceError(好比,變量名打錯)。繼續運行下去頗有肯能會致使嚴重的Bug,並且極其難發現。例如:

  1. 一些請求間共享的狀態可能會被變成nullundefined或者其它無效值,結果就是下一個請求也失敗了。
  2. 數據庫(或其它)鏈接可能會被泄露,下降了可以並行處理的請求數量。最後只剩下幾個可用鏈接會很壞,將致使請求由並行變成串行被處理。
  3. 更糟的是, postgres 鏈接會被留在打開的請求事務裏。這會致使 postgres 「持有」表中某一行的舊值,由於它對這個事務可見。這個問題會存在好幾周,形成表無限制的增加,後續的請求全都被拖慢了,從幾毫秒到幾分鐘[腳註4]。雖然這個問題和 postgres 緊密相關,可是它很好的說明了程序員一個簡單的失誤會讓應用程序陷入一種很是可怕的狀態。
  4. 鏈接會停留在已認證的狀態,而且被後續的鏈接使用。結果就是在請求裏搞錯了用戶。
  5. 套接字會一直打開着。通常狀況下NodeJS 會在一個空閒的套接字上應用兩分鐘的超時,但這個值能夠覆蓋,這將會泄露一個文件描述符。若是這種狀況不斷髮生,程序會由於用光了全部的文件描述符而強退。即便不覆蓋這個超時時間,客戶端會掛兩分鐘直到 「hang-up」 錯誤的發生。這兩分鐘的延遲會讓問題難於處理和調試。
  6. 不少內存引用會被遺留。這會致使泄露,進而致使內存耗盡,GC須要的時間增長,最後性能急劇降低。這點很是難調試,並且很須要技巧與致使形成泄露的失誤聯繫起來。

最好的從失誤恢復的方法是馬上崩潰。你應該用一個restarter 來啓動你的程序,在奔潰的時候自動重啓。若是restarter 準備就緒,崩潰是失誤來臨時最快的恢復可靠服務的方法。

奔潰應用程序惟一的負面影響是相連的客戶端臨時被擾亂,可是記住:

  • 從定義上看,這些錯誤屬於Bug。咱們並非在討論正常的系統或是網絡錯誤,而是程序裏實際存在的Bug。它們應該在線上很罕見,而且是調試和修復的最高優先級。
  • 上面討論的種種情形裏,請求沒有必要必定得成功完成。請求可能成功完成,可能讓服務器再次崩潰,可能以某種明顯的方式不正確的完成,或者以一種很難調試的方式錯誤的結束了。
  • 在一個完備的分佈式系統裏,客戶端必須可以經過重連和重試來處理服務端的錯誤。無論 NodeJS 應用程序是否被容許崩潰,網絡和系統的失敗已是一個事實了。
  • 若是你的線上代碼如此頻繁地崩潰讓鏈接斷開變成了問題,那麼正真的問題是你的服務器Bug太多了,而不是由於你選擇出錯就崩潰。

若是出現服務器常常崩潰致使客戶端頻繁掉線的問題,你應該把經歷集中在形成服務器崩潰的Bug上,把它們變成可捕獲的異常,而不是在代碼明顯有問題的狀況下儘量地避免崩潰。調試這類問題最好的方法是,把 NodeJS 配置成出現未捕獲異常時把內核文件打印出來。在 GNU/Linux 或者 基於 illumos 的系統上使用這些內核文件,你不只查看應用崩潰時的堆棧記錄,還能夠看到傳遞給函數的參數和其它的 JavaScript 對象,甚至是那些在閉包裏引用的變量。即便沒有配置 code dumps,你也能夠用堆棧信息和日誌來開始處理問題。

最後,記住程序員在服務器端的失誤會形成客戶端的操做失敗,還有客戶端必須處理好服務器端的奔潰和網絡中斷。這不僅是理論,而是實際發生在線上環境裏。

編寫函數的實踐

咱們已經討論瞭如何處理異常,那麼當你在編寫新的函數的時候,怎麼才能向調用者傳遞錯誤呢?

最最重要的一點是爲你的函數寫好文檔,包括它接受的參數(附上類型和其它約束),返回值,可能發生的錯誤,以及這些錯誤意味着什麼。 若是你不知道會致使什麼錯誤或者不瞭解錯誤的含義,那你的應用程序正常工做就是一個巧合。 因此,當你編寫新的函數的時候,必定要告訴調用者可能發生哪些錯誤和錯誤的含義。

Throw, Callback 仍是 EventEmitter
函數有三種基本的傳遞錯誤的模式。

  • throw以同步的方式傳遞異常--也就是在函數被調用處的相同的上下文。若是調用者(或者調用者的調用者)用了try/catch,則異常能夠捕獲。若是全部的調用者都沒有用,那麼程序一般狀況下會崩潰(異常也可能會被domains或者進程級的uncaughtException捕捉到,詳見下文)。
  • Callback是最基礎的異步傳遞事件的一種方式。用戶傳進來一個函數(callback),以後當某個異步操做完成後調用這個 callback。一般 callback 會以callback(err,result)的形式被調用,這種狀況下, errresult必然有一個是非空的,取決於操做是成功仍是失敗。
  • 更復雜的情形是,函數沒有用 Callback 而是返回一個 EventEmitter 對象,調用者須要監聽這個對象的 error事件。這種方式在兩種狀況下頗有用。
  • 當你在作一個可能會產生多個錯誤或多個結果的複雜操做的時候。好比,有一個請求一邊從數據庫取數據一邊把數據發送回客戶端,而不是等待全部的結果一塊兒到達。在這個例子裏,沒有用 callback,而是返回了一個 EventEmitter,每一個結果會觸發一個row 事件,當全部結果發送完畢後會觸發end事件,出現錯誤時會觸發一個error事件。

用在那些具備複雜狀態機的對象上,這些對象每每伴隨着大量的異步事件。例如,一個套接字是一個EventEmitter,它可能會觸發「connect「,」end「,」timeout「,」drain「,」close「事件。這樣,很天然地能夠把」error「做爲另一種能夠被觸發的事件。在這種狀況下,清楚知道」error「還有其它事件什麼時候被觸發很重要,同時被觸發的還有什麼事件(例如」close「),觸發的順序,還有套接字是否在結束的時候處於關閉狀態。

在大多數狀況下,咱們會把 callback 和 event emitter 歸到同一個「異步錯誤傳遞」籃子裏。若是你有傳遞異步錯誤的須要,你一般只要用其中的一種而不是同時使用。

那麼,何時用throw,何時用callback,何時又用 EventEmitter 呢?這取決於兩件事:

  • 這是操做失敗仍是程序員的失誤?
  • 這個函數自己是同步的仍是異步的。

直到目前,最多見的例子是在異步函數裏發生了操做失敗。在大多數狀況下,你須要寫一個以回調函數做爲參數的函數,而後你會把異常傳遞給這個回調函數。這種方式工做的很好,而且被普遍使用。例子可參照 NodeJS 的fs模塊。若是你的場景比上面這個還複雜,那麼你可能就得換用 EventEmitter 了,不過你也仍是在用異步方式傳遞這個錯誤。

其次常見的一個例子是像JSON.parse 這樣的函數同步產生了一個異常。對這些函數而言,若是遇到操做失敗(好比無效輸入),你得用同步的方式傳遞它。你能夠拋出(更加常見)或者返回它。

對於給定的函數,若是有一個異步傳遞的異常,那麼全部的異常都應該被異步傳遞。可能有這樣的狀況,請求一到來你就知道它會失敗,而且知道不是由於程序員的失誤。可能的情形是你緩存了返回給最近請求的錯誤。雖然你知道請求必定失敗,可是你仍是應該用異步的方式傳遞它。

通用的準則就是 你便可以同步傳遞錯誤(拋出),也能夠異步傳遞錯誤(經過傳給一個回調函數或者觸發EventEmitter的 error事件),可是不用同時使用。以這種方式,用戶處理異常的時候能夠選擇用回調函數仍是用try/catch,可是不須要兩種都用。具體用哪個取決於異常是怎麼傳遞的,這點得在文檔裏說明清楚。

差點忘了程序員的失誤。回憶一下,它們實際上是Bug。在函數開頭經過檢查參數的類型(或是其它約束)就能夠被當即發現。一個退化的例子是,某人調用了一個異步的函數,可是沒有傳回調函數。你應該馬上把這個錯拋出,由於程序已經出錯而在這個點上最好的調試的機會就是獲得一個堆棧信息,若是有內核信息就更好了。

由於程序員的失誤永遠不該該被處理,上面提到的調用者只能用try/catch或者回調函數(或者 EventEmitter)其中一種處理異常的準則並無由於這條意見而改變。若是你想知道更多,請見上面的 (不要)處理程序員的失誤。

下表以 NodeJS 核心模塊的常見函數爲例,作了一個總結,大體按照每種問題出現的頻率來排列:

函數 類型 錯誤 錯誤類型 傳遞方式 調用者
fs.stat 異步 file not found 操做失敗 callback handle
JSON.parse 同步 bad user input 操做失敗 throw try/catch
fs.stat 異步 null for filename 失誤 throw none (crash)

異步函數裏出現操做錯誤的例子(第一行)是最多見的。在同步函數裏發生操做失敗(第二行)比較少見,除非是驗證用戶輸入。程序員失誤(第三行)除非是在開發環境下,不然永遠都不該該出現。

吐槽:程序員失誤仍是操做失敗?

你怎麼知道是程序員的失誤仍是操做失敗呢?很簡單,你本身來定義而且記在文檔裏,包括容許什麼類型的函數,怎樣打斷它的執行。若是你獲得的異常不是文檔裏能接受的,那就是一個程序員失誤。若是在文檔裏寫明接受可是暫時處理不了的,那就是一個操做失敗。

你得用你的判斷力去決定你想作到多嚴格,可是咱們會給你必定的意見。具體一些,想象有個函數叫作「connect」,它接受一個IP地址和一個回調函數做爲參數,這個回調函數會在成功或者失敗的時候被調用。如今假設用戶傳進來一個明顯不是IP地址的參數,好比「bob」,這個時候你有幾種選擇:

  • 在文檔裏寫清楚只接受有效的IPV4的地址,當用戶傳進來「bob」的時候拋出一個異常。強烈推薦這種作法。
  • 在文檔裏寫上接受任何string類型的參數。若是用戶傳的是「bob」,觸發一個異步錯誤指明沒法鏈接到「bob」這個IP地址。

這兩種方式和咱們上面提到的關於操做失敗和程序員失誤的指導原則是一致的。你決定了這樣的輸入算是程序員的失誤仍是操做失敗。一般,用戶輸入的校驗是很鬆的,爲了證實這點,能夠看Date.parse這個例子,它接受不少類型的輸入。可是對於大多數其它函數,咱們強烈建議你偏向更嚴格而不是更鬆。你的程序越是猜想用戶的本意(使用隱式的轉換,不管是JavaScript語言自己這麼作仍是有意爲之),就越是容易猜錯。本意是想讓開發者在使用的時候不用更加具體,結果卻耗費了人家好幾個小時在Debug上。再說了,若是你以爲這是個好主意,你也能夠在將來的版本里讓函數不那麼嚴格,可是若是你發現因爲猜想用戶的意圖致使了不少惱人的bug,要修復它的時候想保持兼容性就不大可能了。

因此若是一個值怎麼都不多是有效的(本該是string卻獲得一個undefined,本該是string類型的IP但明顯不是),你應該在文檔裏寫明是這不容許的而且馬上拋出一個異常。只要你在文檔裏寫的清清楚楚,那這就是一個程序員的失誤而不是操做失敗。當即拋出能夠把Bug帶來的損失降到最小,而且保存了開發者能夠用來調試這個問題的信息(例如,調用堆棧,若是用內核文件還能夠獲得參數和內存分佈)。

那麼 domainsprocess.on('uncaughtException') 呢?

操做失敗老是能夠被顯示的機制所處理的:捕獲一個異常,在回調裏處理錯誤,或者處理EventEmitter的「error」事件等等。Domains以及進程級別的‘uncaughtException’主要是用來從未料到的程序錯誤恢復的。因爲上面咱們所討論的緣由,這兩種方式都不鼓勵。

編寫新函數的具體建議

咱們已經談論了不少指導原則,如今讓咱們具體一些。

  1. 你的函數作什麼得很清楚。
    這點很是重要。每一個接口函數的文檔都要很清晰的說明: - 預期參數 - 參數的類型 - 參數的額外約束(例如,必須是有效的IP地址)
    若是其中有一點不正確或者缺乏,那就是一個程序員的失誤,你應該馬上拋出來。
    此外,你還要記錄:

    • 調用者可能會遇到的操做失敗(以及它們的name)
    • 怎麼處理操做失敗(例如是拋出,傳給回調函數,仍是被 EventEmitter 發出)
    • 返回值
  2. 使用 Error 對象或它的子類,而且實現 Error 的協議。
    你的全部錯誤要麼使用Error 類要麼使用它的子類。你應該提供name和message屬性,stack也是(注意準確)。

  3. 在程序裏經過 Errorname屬性區分不一樣的錯誤。
    當你想要知道錯誤是何種類型的時候,用name屬性。 JavaScript內置的供你重用的名字包括「RangeError」(參數超出有效範圍)和「TypeError」(參數類型錯誤)。而HTTP異常,一般會用RFC指定的名字,好比「BadRequestError」或者「ServiceUnavailableError」。

  4. 不要想着給每一個東西都取一個新的名字。若是你能夠只用一個簡單的InvalidArgumentError,就不要分紅 InvalidHostnameError,InvalidIpAddressError,InvalidDnsError等等,你要作的是經過增長屬性來講明那裏出了問題(下面會講到)。

  5. 用詳細的屬性來加強 Error 對象。
    舉個例子,若是遇到無效參數,把 propertyName 設成參數的名字,把 propertyValue 設成傳進來的值。若是沒法連到服務器,用 remoteIp 屬性指明嘗試鏈接到的 IP。若是發生一個系統錯誤,在syscal 屬性裏設置是哪一個系統調用,並把錯誤代碼放到errno屬性裏。具體你能夠查看附錄,看有哪些樣例屬性能夠用。
    至少須要這些屬性:

name:用於在程序裏區分衆多的錯誤類型(例如參數非法和鏈接失敗)

message:一個供人類閱讀的錯誤消息。對可能讀到這條消息的人來講這應該已經足夠完整。若是你從更底層的地方傳遞了一個錯誤,你應該加上一些信息來講明你在作什麼。怎麼包裝異常請往下看。

stack:通常來說不要隨意擾亂堆棧信息。甚至不要加強它。V8引擎只有在這個屬性被讀取的時候纔會真的去運算,以此大幅提升處理異常時候的性能。若是你讀完再去加強它,結果就會多付出代價,哪怕調用者並不須要堆棧信息。

你還應該在錯誤信息裏提供足夠的消息,這樣調用者不用分析你的錯誤就能夠新建本身的錯誤。它們可能會本地化這個錯誤信息,也可能想要把大量的錯誤彙集到一塊兒,再或者用不一樣的方式顯示錯誤信息(好比在網頁上的一個表格裏,或者高亮顯示用戶錯誤輸入的字段)。
6. 若果你傳遞一個底層的錯誤給調用者,考慮先包裝一下。
常常會發現一個異步函數funcA調用另一個異步函數funcB,若是funcB拋出了一個錯誤,但願funcA也拋出如出一轍的錯誤。(請注意,第二部分並不老是跟在第一部分以後。有的時候funcA會從新嘗試。有的時候又但願funcA忽略錯誤由於無事可作。但在這裏,咱們只討論funcA直接返回funcB錯誤的狀況)

在這個例子裏,能夠考慮包裝這個錯誤而不是直接返回它。包裝的意思是繼續拋出一個包含底層信息的新的異常,而且帶上當前層的上下文。用 verror 這個包能夠很簡單的作到這點。

舉個例子,假設有一個函數叫作 fetchConfig,這個函數會到一個遠程的數據庫取得服務器的配置。你可能會在服務器啓動的時候調用這個函數。整個流程看起來是這樣的:

1.加載配置 1.1 鏈接數據庫 1.1.1 解析數據庫服務器的DNS主機名 1.1.2 創建一個到數據庫服務器的TCP鏈接 1.1.3 向數據庫服務器認證 1.2 發送DB請求 1.3 解析返回結果 1.4 加載配置 2 開始處理請求

假設在運行時出了一個問題鏈接不到數據庫服務器。若是鏈接在 1.1.2 的時候由於沒有到主機的路由而失敗了,每一個層都不加處理地都把異常向上拋出給調用者。你可能會看到這樣的異常信息:

myserver: Error: connect ECONNREFUSED

這顯然沒什麼大用。

另外一方面,若是每一層都把下一層返回的異常包裝一下,你能夠獲得更多的信息:

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。

你可能會想跳過其中幾層的封裝來獲得一條不那麼充滿學究氣息的消息:

myserver: failed to load configuration: connection refused from database at 127.0.0.1 port 1234.

不過話又說回來,報錯的時候詳細一點總比信息不夠要好。

若是你決定封裝一個異常了,有幾件事情要考慮:

  • 保持原有的異常完整不變,保證當調用者想要直接用的時候底層的異常還可用。
  • 要麼用原有的名字,要麼顯示地選擇一個更有意義的名字。例如,最底層是 NodeJS 報的一個簡單的Error,但在步驟1中能夠是個 IntializationError 。(可是若是程序能夠經過其它的屬性區分,不要以爲有責任取一個新的名字)
  • 保留原錯誤的全部屬性。在合適的狀況下加強message屬性(可是不要在原始的異常上修改)。淺拷貝其它的像是syscallerrno這類的屬性。最好是直接拷貝除了 namemessagestack之外的全部屬性,而不是硬編碼等待拷貝的屬性列表。不要理會stack,由於即便是讀取它也是相對昂貴的。若是調用者想要一個合併後的堆棧,它應該遍歷錯誤緣由並打印每個錯誤的堆棧。

在Joyent,咱們使用verror 這個模塊來封裝錯誤,由於它的語法簡潔。寫這篇文章的時候,它還不能支持上面的全部功能,可是會被擴展以期支持。

例子

考慮有這樣的一個函數,這個函數會異步地鏈接到一個IPv4地址的TCP端口。咱們經過例子來看文檔怎麼寫:

/*
* Make a TCP connection to the given IPv4 address.  Arguments:
*
*    ip4addr        a string representing a valid IPv4 address
*
*    tcpPort        a positive integer representing a valid TCP port
*
*    timeout        a positive integer denoting the number of milliseconds
*                   to wait for a response from the remote server before
*                   considering the connection to have failed.
*
*    callback       invoked when the connection succeeds or fails.  Upon
*                   success, callback is invoked as callback(null, socket),
*                   where `socket` is a Node net.Socket object.  Upon failure,
*                   callback is invoked as callback(err) instead.
*
* This function may fail for several reasons:
*
*    SystemError    For "connection refused" and "host unreachable" and other
*                   errors returned by the connect(2) system call.  For these
*                   errors, err.errno will be set to the actual errno symbolic
*                   name.
*
*    TimeoutError   Emitted if "timeout" milliseconds elapse without
*                   successfully completing the connection.
*
* All errors will have the conventional "remoteIp" and "remotePort" properties.
* After any error, any socket that was created will be closed.
*/
function connect(ip4addr, tcpPort, timeout, callback)
{
assert.equal(typeof (ip4addr), 'string',
    "argument 'ip4addr' must be a string");
assert.ok(net.isIPv4(ip4addr),
    "argument 'ip4addr' must be a valid IPv4 address");
assert.equal(typeof (tcpPort), 'number',
    "argument 'tcpPort' must be a number");
assert.ok(!isNaN(tcpPort) && tcpPort > 0 && tcpPort < 65536,
    "argument 'tcpPort' must be a positive integer between 1 and 65535");
assert.equal(typeof (timeout), 'number',
    "argument 'timeout' must be a number");
assert.ok(!isNaN(timeout) && timeout > 0,
    "argument 'timeout' must be a positive integer");
assert.equal(typeof (callback), 'function');

/* do work */
}

這個例子在概念上很簡單,可是展現了上面咱們所談論的一些建議:

  • 參數,類型以及其它一些約束被清晰的文檔化。
  • 這個函數對於接受的參數是很是嚴格的,而且會在獲得錯誤參數的時候拋出異常(程序員的失誤)。
  • 可能出現的操做失敗集合被記錄了。經過不一樣的」name「值能夠區分不一樣的異常,而」errno「被用來得到系統錯誤的詳細信息。
  • 異常被傳遞的方式也被記錄了(經過失敗時調用回調函數)。
  • 返回的錯誤有」remoteIp「和」remotePort「字段,這樣用戶就能夠定義本身的錯誤了(好比,一個HTTP客戶端的端口號是隱含的)。
  • 雖然很明顯,可是鏈接失敗後的狀態也被清晰的記錄了:全部被打開的套接字此時已經被關閉。

這看起來像是給一個很容易理解的函數寫了超過大部分人會寫的的超長註釋,但大部分函數實際上沒有這麼容易理解。全部建議都應該被有選擇的吸取,若是事情很簡單,你應該本身作出判斷,可是記住:用十分鐘把預計發生的記錄下來可能以後會爲你或其餘人節省數個小時。

總結

  • 學習了怎麼區分操做失敗,即那些能夠被預測的哪怕在正確的程序裏也沒法避免的錯誤(例如,沒法鏈接到服務器);而程序的Bug則是程序員失誤。
  • 操做失敗能夠被處理,也應當被處理。程序員的失誤沒法被處理或可靠地恢復(本不該該這麼作),嘗試這麼作只會讓問題更難調試。
  • 一個給定的函數,它處理異常的方式要麼是同步(用throw方式)要麼是異步的(用callback或者EventEmitter),不會二者兼具。用戶能夠在回調函數裏處理錯誤,也可使用 try/catch捕獲異常 ,可是不能一塊兒用。實際上,使用throw而且指望調用者使用 try/catch 是很罕見的,由於 NodeJS裏的同步函數一般不會產生運行失敗(主要的例外是相似於JSON.parse的用戶輸入驗證函數)。
  • 在寫新函數的時候,用文檔清楚地記錄函數預期的參數,包括它們的類型、是否有其它約束(例如必須是有效的IP地址),可能會發生的合理的操做失敗(例如沒法解析主機名,鏈接服務器失敗,全部的服務器端錯誤),錯誤是怎麼傳遞給調用者的(同步,用throw,仍是異步,用 callbackEventEmitter)。
  • 缺乏參數或者參數無效是程序員的失誤,一旦發生老是應該拋出異常。函數的做者認爲的可接受的參數可能會有一個灰色地帶,可是若是傳遞的是一個文檔裏寫明接收的參數之外的東西,那就是一個程序員失誤。
  • 傳遞錯誤的時候用標準的 Error 類和它標準的屬性。儘量把額外的有用信息放在對應的屬性裏。若是有可能,用約定的屬性名(以下)。

附錄:Error 對象屬性命名約定

強烈建議你在發生錯誤的時候用這些名字來保持和Node核心以及Node插件的一致。這些大部分不會和某個給定的異常對應,可是出現疑問的時候,你應該包含任何看起來有用的信息,即從編程上也從自定義的錯誤消息上。【表】。

Property name Intended use
localHostname the local DNS hostname (e.g., that you're accepting connections at)
localIp the local IP address (e.g., that you're accepting connections at)
localPort the local TCP port (e.g., that you're accepting connections at)
remoteHostname the DNS hostname of some other service (e.g., that you tried to connect to)
remoteIp the IP address of some other service (e.g., that you tried to connect to)
remotePort the port of some other service (e.g., that you tried to connect to)
path the name of a file, directory, or Unix Domain Socket (e.g., that you tried to open)
srcpath the name of a path used as a source (e.g., for a rename or copy)
dstpath the name of a path used as a destination (e.g., for a rename or copy)
hostname a DNS hostname (e.g., that you tried to resolve)
ip an IP address (e.g., that you tried to reverse-resolve)
propertyName an object property name, or an argument name (e.g., for a validation error)
propertyValue an object property value (e.g., for a validation error)
syscall the name of a system call that failed
errno the symbolic value of errno (e.g., "ENOENT"). Do not use this for errors that don't actually set the C value of errno.Use "name" to distinguish between types of errors.

腳註

  1. 人們有的時候會這麼寫代碼,他們想要在出現異步錯誤的時候調用callback 並把錯誤做爲參數傳遞。他們錯誤地認爲在本身的回調函數(傳遞給 doSomeAsynchronousOperation 的函數)裏throw 一個異常,會被外面的catch代碼塊捕獲。try/catch和異步函數不是這麼工做的。回憶一下,異步函數的意義就在於被調用的時候myApiFunc函數已經返回了。這意味着try代碼塊已經退出了。這個回調函數是由Node直接調用的,外面並無try的代碼塊。若是你用這個反模式,結果就是拋出異常的時候,程序崩潰了。

  2. 在JavaScript裏,拋出一個不屬於Error的參數從技術上是可行的,可是應該被避免。這樣的結果使得到調用堆棧沒有可能,代碼也沒法檢查name屬性,或者其它任何可以說明哪裏有問題的屬性。

  3. 操做失敗和程序員的失誤這一律念早在NodeJS以前就已經存在存在了。不嚴格地對應者Java裏的checked和unchecked異常,雖然操做失敗被認爲是沒法避免的,好比 OutOfMemeoryError,被歸爲uncheked異常。在C語言裏有對應的概念,普通異常處理和使用斷言。維基百科上關於斷言的的文章也有關於何時用斷言何時用普通的錯誤處理的相似的解釋。

  4. 若是這看起來很是具體,那是由於咱們在產品環境中遇到這樣過這樣的問題。這真的很可怕。


本文做者系OneAPM 工程師王龑 ,想閱讀更多好的技術文章,請訪問OneAPM官方技術博客

相關文章
相關標籤/搜索