NodeJS錯誤處理最佳實踐

jopen 10年前發布 | 61K 次閱讀 NodeJS Node.js 開發

王龑 — APRIL 13, 2015

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]。舉個例子:

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 發出)
    • 返回值
  2. 使用Error對象或它的子類,并且實現 Error 的協議。
    你的所有錯誤要么使用Error類要么使用它的子類。你應該提供name和message屬性,stack也是(注意準確)。

  3. 在程序里通過Error的name屬性區分不同的錯誤。
    當你想要 知道錯誤是何種類型的時候,用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引擎只有在這個屬性被讀取的時候才會真的去運算,以此大幅提高處理異常時候的性能。如果你讀完再去增強它,結果就會多付出代價,哪怕調用者并不需要堆棧信息。

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

  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官方技術博客。

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!