天天看點

Node.js錯誤處理最佳實踐 附錄:Error 對象屬性命名約定

錯誤處理讓人痛苦,長久以來Node.js很容易通過而不處理大量錯誤。但是要想建立一個健壯的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]。舉個例子:

throw new Error('something bad happened');
           

但是使用一個錯誤而不抛出也是可以的

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. 一些請求間共享的狀态可能會被變成

    null

    undefined

    或者其它無效值,結果就是下一個請求也失敗了。
  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)

    的形式被調用,這種情況下, err和 result必然有一個是非空的,取決于操作是成功還是失敗。
  • 更複雜的情形是,函數沒有用 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帶來的損失降到最小,并且儲存了開發者可以用來調試這個問題的資訊(例如,調用堆棧,如果用核心檔案還可以得到參數和記憶體分布)。

那麼 

domains

 和 

process.on('uncaughtException')

 呢?

操作失敗總是可以被顯示的機制所處理的:捕獲一個異常,在回調裡處理錯誤,或者處理EventEmitter的“error”事件等等。

Domains

以及程序級别的

‘uncaughtException’

主要是用來從未料到的程式錯誤恢複的。由于上面我們所讨論的原因,這兩種方式都不鼓勵。

編寫新函數的具體建議

我們已經談論了很多指導原則,現在讓我們具體一些。

  1. 你的函數做什麼得很清楚。

這點非常重要。每個接口函數的文檔都要很清晰的說明: - 預期參數 - 參數的類型 - 參數的額外限制(例如,必須是有效的IP位址)

如果其中有一點不正确或者缺少,那就是一個程式員的失誤,你應該立刻抛出來。

此外,你還要記錄:

  • 調用者可能會遇到的操作失敗(以及它們的

    name

  • 怎麼處理操作失敗(例如是抛出,傳給回調函數,還是被 EventEmitter 發出)
  • 傳回值
  1. 使用 Error 對象或它的子類,并且實作 Error 的協定。

你的所有錯誤要麼使用 Error 類要麼使用它的子類。你應該提供

name

message

屬性,

stack

也是(注意準确)。

  1. 在程式裡通過 Error 的 

    name

     屬性區分不同的錯誤。

當你想要知道錯誤是何種類型的時候,用name屬性。 JavaScript内置的供你重用的名字包括“RangeError”(參數超出有效範圍)和“TypeError”(參數類型錯誤)。而HTTP異常,通常會用RFC指定的名字,比如“BadRequestError”或者“ServiceUnavailableError”。

不要想着給每個東西都取一個新的名字。如果你可以隻用一個簡單的InvalidArgumentError,就不要分成 InvalidHostnameError,InvalidIpAddressError,InvalidDnsError等等,你要做的是通過增加屬性來說明那裡出了問題(下面會講到)。

  1. 用詳細的屬性來增強 Error 對象。

舉個例子,如果遇到無效參數,把 

propertyName

 設成參數的名字,把 

propertyValue

 設成傳進來的值。如果無法連到伺服器,用 

remoteIp

 屬性指明嘗試連接配接到的 IP。如果發生一個系統錯誤,在

syscal

 屬性裡設定是哪個系統調用,并把錯誤代碼放到

errno

屬性裡。具體你可以檢視附錄,看有哪些樣例屬性可以用。

至少需要這些屬性:

name

:用于在程式裡區分衆多的錯誤類型(例如參數非法和連接配接失敗)

message

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

stack

:一般來講不要随意擾亂堆棧資訊。甚至不要增強它。V8引擎隻有在這個屬性被讀取的時候才會真的去運算,以此大幅提高處理異常時候的性能。如果你讀完再去增強它,結果就會多付出代價,哪怕調用者并不需要堆棧資訊。

你還應該在錯誤資訊裡提供足夠的消息,這樣調用者不用分析你的錯誤就可以建立自己的錯誤。它們可能會本地化這個錯誤資訊,也可能想要把大量的錯誤聚集到一起,再或者用不同的方式顯示錯誤資訊(比如在網頁上的一個表格裡,或者高亮顯示使用者錯誤輸入的字段)。

  1. 若果你傳遞一個底層的錯誤給調用者,考慮先包裝一下。

經常會發現一個異步函數

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

    屬性(但是不要在原始的異常上修改)。淺拷貝其它的像是

    syscall

    errno

    這類的屬性。最好是直接拷貝除了 

    name

    message

    stack

    以外的所有屬性,而不是寫死等待拷貝的屬性清單。不要理會

    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

    ,還是異步,用 callback 和 EventEmitter)。
  • 缺少參數或者參數無效是程式員的失誤,一旦發生總是應該抛出異常。函數的作者認為的可接受的參數可能會有一個灰色地帶,但是如果傳遞的是一個文檔裡寫明接收的參數以外的東西,那就是一個程式員失誤。
  • 傳遞錯誤的時候用标準的 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官方技術部落格。

繼續閱讀