NodeJS錯誤處理最佳實踐

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

  • 我寫的函數裏什麼時候該拋出異常,什麼時候該傳給callback, 什麼時候觸發EventEmitter等等。

  • 我的函數對參數該做出怎樣的假設?我應該檢查更加具體的約束麼?例如參數是否非空,是否大於零,是不是看起來像個IP地址,等等等。

  • 我該如何處理那些不符合預期的參數?我是應該拋出一個異常,還是把錯誤傳遞給一個callback。

  • 我該怎麼在程序裏區分不同的異常(比如“請求錯誤”和“服務不可用”)?

  • 我怎麼才能提供足夠的信息讓調用者知曉錯誤細節。

  • 我該怎麼處理未預料的出錯?我是應該用 try/catchdomains 還是其它什麼方式呢?

這篇文章可以劃分成互相爲基礎的幾個部分:

  • 背景:希望你所具備的知識。

  • 操作失敗和程序員的失誤:介紹兩種基本的異常。

  • 編寫新函數的實踐:關於怎麼讓函數產生有用報錯的基本原則。

  • 編寫新函數的具體推薦:編寫能產生有用報錯的、健壯的函數需要的一個檢查列表

  • 例子:以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. 一些請求間共享的狀態可能會被變成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)的形式被調用,這種情況下, 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操作失敗callbackhandle
JSON.parse同步bad user input操作失敗throwtry/catch
fs.stat異步null for filename失誤thrownone (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 發出)

  • 返回值

  1. 使用 Error 對象或它的子類,並且實現 Error 的協議。

你的所有錯誤要麼使用 Error 類要麼使用它的子類。你應該提供namemessage屬性,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屬性(但是不要在原始的異常上修改)。淺拷貝其它的像是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,還是異步,用 callback 和 EventEmitter)。

  • 缺少參數或者參數無效是程序員的失誤,一旦發生總是應該拋出異常。函數的作者認爲的可接受的參數可能會有一個灰色地帶,但是如果傳遞的是一個文檔裏寫明接收的參數以外的東西,那就是一個程序員失誤。

  • 傳遞錯誤的時候用標準的 Error 類和它標準的屬性。儘可能把額外的有用信息放在對應的屬性裏。如果有可能,用約定的屬性名(如下)。

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

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

Property nameIntended use
localHostnamethe local DNS hostname (e.g., that you're accepting connections at)
localIpthe local IP address (e.g., that you're accepting connections at)
localPortthe local TCP port (e.g., that you're accepting connections at)
remoteHostnamethe DNS hostname of some other service (e.g., that you tried to connect to)
remoteIpthe IP address of some other service (e.g., that you tried to connect to)
remotePortthe port of some other service (e.g., that you tried to connect to)
paththe name of a file, directory, or Unix Domain Socket (e.g., that you tried to open)
srcpaththe name of a path used as a source (e.g., for a rename or copy)
dstpaththe name of a path used as a destination (e.g., for a rename or copy)
hostnamea DNS hostname (e.g., that you tried to resolve)
ipan IP address (e.g., that you tried to reverse-resolve)
propertyNamean object property name, or an argument name (e.g., for a validation error)
propertyValuean object property value (e.g., for a validation error)
syscallthe name of a system call that failed
errnothe 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官方技術博客

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章