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

weixin_34162695發表於2018-01-04

NodeJS 的錯誤處理讓人痛苦,在很長的一段時間裡,大量的錯誤被放任不管。但是要想建立一個健壯的 Node.js 程式就必須正確的處理這些錯誤,而且這並不難學。如果你實在沒有耐心,那就直接繞過長篇大論跳到“總結”部分吧。
英文原文:https://www.joyent.com/node-js/production/design/errors
這篇文章會回答 NodeJS 初學者的若干問題:

  • 我寫的函式裡什麼時候該丟擲異常,什麼時候該傳給 callback,什麼時候觸發 EventEmitter 等等。
  • 我的函式對引數該做出怎樣的假設?我應該檢查更加具體的約束麼?例如引數是否非空,是否大於零,是不是看起來像個 IP 地址,等等等。
  • 我該如何處理那些不符合預期的引數?我是應該丟擲一個異常,還是把錯誤傳遞給一個 callback。
  • 我該怎麼在程式裡區分不同的異常(比如“請求錯誤”和“服務不可用”)?
  • 我怎麼才能提供足夠的資訊讓呼叫者知曉錯誤細節。
  • 我該怎麼處理未預料的出錯?我是應該用 try/catch ,domains 還是其它什麼方式呢?
這篇文章可以劃分成互相為基礎的幾個部分:
  • 背景:希望你所具備的知識。
  • 操作失敗和程式設計師的失誤:介紹兩種基本的異常。
  • 編寫新函式的實踐:關於怎麼讓函式產生有用報錯的基本原則。
  • 編寫新函式的具體推薦:編寫能產生有用報錯的、健壯的函式需要的一個檢查列表。
  • 例子:以 connect 函式為例的文件和序言。
  • 總結:全文至此的觀點總結。
  • 附錄:Error 物件屬性約定:用標準方式提供一個屬性列表,以提供更多資訊。

背景

本文假設:
你已經熟悉了 JavaScript、Java、 Python、 C++ 或者類似的語言中異常的概念,而且你知道丟擲異常和捕獲異常是什麼意思。
你熟悉怎麼用 NodeJS 編寫程式碼。你使用非同步操作的時候會很自在,並能用 callback(err,result) 模式去完成非同步操作。你得知道下面的程式碼不能正確處理異常的原因是什麼?

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

你還要熟悉三種傳遞錯誤的方式:
1.作為異常丟擲。 2. 把錯誤傳給一個 callback,這個函式正是為了處理異常和處理非同步操作返回結果的。 3.在 EventEmitter 上觸發一個 Error 事件。
接下來我們會詳細討論這幾種方式。這篇文章不假設你知道任何關於 domains 的知識。
最後,你應該知道在 JavaScript 裡,錯誤和異常是有區別的。錯誤是 Error 的一個例項。錯誤被建立並且直接傳遞給另一個函式或者被丟擲。如果一個錯誤被丟擲了那麼它就變成了一個異常。舉個例子:

throw new Error(‘something bad happened’);

但是使用一個錯誤而不丟擲也是可以的:

callback(new Error(‘something bad happened’));

這種用法更常見,因為在 NodeJS 裡,大部分的錯誤都是非同步的。實際上,try/catch 唯一常用的是在 JSON.parse 和類似驗證使用者輸入的地方。接下來我們會看到,其實很少要捕獲一個非同步函式裡的異常。這一點和 Java,C++,以及其它嚴重依賴異常的語言很不一樣。

操作失敗和程式設計師的失誤

把錯誤分成兩大類很有用:
  • 操作失敗是正確編寫的程式在執行時產生的錯誤。它並不是程式的 Bug,反而經常是其它問題:系統本身(記憶體不足或者開啟檔案數過多),系統配置(沒有到達遠端主機的路由),網路問題(埠掛起),遠端服務(500錯誤,連線失敗)。例子如下:

    • 連線不到伺服器
    • 無法解析主機名
    • 無效的使用者輸入
    • 請求超時
    • 伺服器返回 500
    • 套接字被掛起
    • 系統記憶體不足
  • 程式設計師失誤是程式裡的 Bug。這些錯誤往往可以通過修改程式碼避免。它們永遠都沒法被有效的處理:

    • 讀取 undefined 的一個屬性
    • 呼叫非同步函式沒有指定回撥
    • 該傳物件的時候傳了一個字串
    • 該傳IP地址的時候傳了一個物件
      人們把操作失敗和程式設計師的失誤都稱為“錯誤”,但其實它們很不一樣。操作失敗是所有正確的程式應該處理的錯誤情形,只要被妥善處理它們不一定會預示著 Bug 或是嚴重的問題。“檔案找不到”是一個操作失敗,但是它並不一定意味著哪裡出錯了。它可能只是代表著程式如果想用一個檔案得事先建立它。
      與之相反,程式設計師失誤是徹徹底底的 Bug。這些情形下你會犯錯:忘記驗證使用者輸入,敲錯了變數名,諸如此類。這樣的錯誤根本就沒法被處理,如果可以,那就意味著你用處理錯誤的程式碼代替了出錯的程式碼。
      這樣的區分很重要:操作失敗是程式正常操作的一部分。而由程式設計師的失誤則是 Bug。
      有的時候,你會在一個 Root 問題裡同時遇到操作失敗和程式設計師的失誤。HTTP 伺服器訪問了未定義的變數時奔潰了,這是程式設計師的失誤。當前連線著的客戶端會在程式崩潰的同時看到一個ECONNRESET錯誤,在 NodeJS 裡通常會被報成“Socket Hang-up”。對客戶端來說,這是一個不相關的操作失敗, 那是因為正確的客戶端必須處理伺服器當機或者網路中斷的情況。
      類似的,如果不處理好操作失敗, 這本身就是一個失誤。舉個例子,如果程式想要連線伺服器,但是得到一個 ECONNREFUSED 錯誤,而這個程式沒有監聽套接字上的error事件,然後程式崩潰了,這是程式設計師的失誤。連線斷開是操作失敗(因為這是任何一個正確的程式在系統的網路或者其它模組出問題時都會經歷的),如果它不被正確處理,那它就是一個失誤。
      理解操作失敗和程式設計師失誤的不同, 是搞清怎麼傳遞異常和處理異常的基礎。明白了這點再繼續往下讀。

處理操作失敗

就像效能和安全問題一樣,錯誤處理並不是可以憑空加到一個沒有任何錯誤處理的程式中的。你沒有辦法在一個集中的地方處理所有的異常,就像你不能在一個集中的地方解決所有的效能問題。你得考慮任何會導致失敗的程式碼(比如開啟檔案,連線伺服器,Fork 子程式等)可能產生的結果。包括為什麼出錯,錯誤背後的原因。之後會提及,但是關鍵在於錯誤處理的粒度要細,因為哪裡出錯和為什麼出錯決定了影響大小和對策。
你可能會發現在棧的某幾層不斷地處理相同的錯誤。這是因為底層除了向上層傳遞錯誤,上層再向它的上層傳遞錯誤以外,底層沒有做任何有意義的事情。通常,只有頂層的呼叫者知道正確的應對是什麼,是重試操作,報告給使用者還是其它。但是那並不意味著,你應該把所有的錯誤全都丟給頂層的回撥函式。因為,頂層的回撥函式不知道發生錯誤的上下文,不知道哪些操作已經成功執行,哪些操作實際上失敗了。
我們來更具體一些。對於一個給定的錯誤,你可以做這些事情:

  • 直接處理。有的時候該做什麼很清楚。如果你在嘗試開啟日誌檔案的時候得到了一個ENOENT錯誤,很有可能你是第一次開啟這個檔案,你要做的就是首先建立它。有的時候該做什麼很清楚。如果你在嘗試開啟日誌檔案的時候得到了一個ENOENT錯誤,很有可能你是第一次開啟這個檔案,你要做的就是首先建立它。
  • 把出錯擴散到客戶端。如果你不知道怎麼處理這個異常,最簡單的方式就是放棄你正在執行的操作,清理所有開始的,然後把錯誤傳遞給客戶端。(怎麼傳遞異常是另外一回事了,接下來會討論)。這種方式適合錯誤短時間內無法解決的情形。比如,使用者提交了不正確的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 核心模組的常見函式為例,做了一個總結,大致按照每種問題出現的頻率來排列:


5341773-efa78d77d33cf784.png
image.png

非同步函式裡出現操作錯誤的例子(第一行)是最常見的。在同步函式裡發生操作失敗(第二行)比較少見,除非是驗證使用者輸入。程式設計師失誤(第三行)除非是在開發環境下,否則永遠都不應該出現。

吐槽:程式設計師失誤還是操作失敗?

你怎麼知道是程式設計師的失誤還是操作失敗呢?很簡單,你自己來定義並且記在文件裡,包括允許什麼型別的函式,怎樣打斷它的執行。如果你得到的異常不是文件裡能接受的,那就是一個程式設計師失誤。如果在文件裡寫明接受但是暫時處理不了的,那就是一個操作失敗。

你得用你的判斷力去決定你想做到多嚴格,但是我們會給你一定的意見。具體一些,想象有個函式叫做“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’主要是用來從未料到的程式錯誤恢復的。由於上面我們所討論的原因,這兩種方式都不鼓勵。

編寫新函式的具體建議

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

你的函式做什麼得很清楚。 這點非常重要。每個介面函式的文件都要很清晰的說明: - 預期引數 - 引數的型別 - 引數的額外約束(例如,必須是有效的IP地址) 如果其中有一點不正確或者缺少,那就是一個程式設計師的失誤,你應該立刻丟擲來。 此外,你還要記錄:

  • 呼叫者可能會遇到的操作失敗(以及它們的 name)
  • 怎麼處理操作失敗(例如是丟擲,傳給回撥函式,還是被 EventEmitter 發出)
  • 返回值
  1. 使用 Error 物件或它的子類,並且實現 Error 的協議。
    你的所有錯誤要麼使用Error 類要麼使用它的子類。你應該提供name和message屬性,stack也是(注意準確)。
  2. 在程式裡通過 Error 的 name屬性區分不同的錯誤。
    當你想要知道錯誤是何種型別的時候,用 name 屬性。 JavaScript 內建的供你重用的名字包括“RangeError”(引數超出有效範圍)和“TypeError”(引數型別錯誤)。而 HTTP 異常,通常會用 RFC 指定的名字,比如“BadRequestError”或者“ServiceUnavailableError”。
  3. 不要想著給每個東西都取一個新的名字。
    如果你可以只用一個簡單的 InvalidArgumentError,就不要分成 InvalidHostnameError,InvalidIpAddressError,InvalidDnsError等等,你要做的是通過增加屬性來說明那裡出了問題(下面會講到)。
  4. 用詳細的屬性來增強 Error 物件。
    舉個例子,如果遇到無效引數,把 propertyName 設成引數的名字,把 propertyValue 設成傳進來的值。如果無法連到伺服器,用 remoteIp 屬性指明嘗試連線到的 IP。如果發生一個系統錯誤,在syscal 屬性裡設定是哪個系統呼叫,並把錯誤程式碼放到 errno屬性裡。具體你可以檢視附錄,看有哪些樣例屬性可以用。
    至少需要這些屬性:
    name:用於在程式裡區分眾多的錯誤型別(例如引數非法和連線失敗)
    message:一個供人類閱讀的錯誤訊息。對可能讀到這條訊息的人來說這應該已經足夠完整。如果你從更底層的地方傳遞了一個錯誤,你應該加上一些資訊來說明你在做什麼。怎麼包裝異常請往下看。
    stack:一般來講不要隨意擾亂堆疊資訊。甚至不要增強它。V8引擎只有在這個屬性被讀取的時候才會真的去運算,以此大幅提高處理異常時候的效能。如果你讀完再去增強它,結果就會多付出代價,哪怕呼叫者並不需要堆疊資訊。

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

  • 若果你傳遞一個底層的錯誤給呼叫者,考慮先包裝一下。 經常會發現一個非同步函式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 。(但是如果程式可以通過其它的屬性區分,不要覺得有責任取一個新的名字)
  • 要麼用原有的名字,要麼顯示地選擇一個更有意義的名字。例如,最底層是 NodeJS 報的一個簡單的Error,但在步驟1中可以是個 IntializationError 。(但是如果程式可以通過其它的屬性區分,不要覺得有責任取一個新的名字)

在 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 外掛的一致。這些大部分不會和某個給定的異常對應,但是出現疑問的時候,你應該包含任何看起來有用的資訊,即從程式設計上也從自定義的錯誤訊息上。


5341773-b575a5095fa22f56.png
image.png

腳註

  1. 人們有的時候會這麼寫程式碼,他們想要在出現非同步錯誤的時候呼叫callback 並把錯誤作為引數傳遞。他們錯誤地認為在自己的回撥函式(傳遞給 doSomeAsynchronousOperation 的函式)裡throw 一個異常,會被外面的 catch 程式碼塊捕獲。try/catch和非同步函式不是這麼工作的。回憶一下,非同步函式的意義就在於被呼叫的時候myApiFunc函式已經返回了。這意味著 try 程式碼塊已經退出了。這個回撥函式是由 Node 直接呼叫的,外面並沒有 try 的程式碼塊。如果你用這個反模式,結果就是丟擲異常的時候,程式崩潰了。
  2. 在 JavaScript 裡,丟擲一個不屬於Error的引數從技術上是可行的,但是應該被避免。這樣的結果使獲得呼叫堆疊沒有可能,程式碼也無法檢查name屬性,或者其它任何能夠說明哪裡有問題的屬性。
  3. 操作失敗和程式設計師的失誤這一概念早在 NodeJS 之前就已經存在存在了。不嚴格地對應者 Java 裡的 checked 和 unchecked 異常,雖然操作失敗被認為是無法避免的,比如 OutOfMemeoryError,被歸為 uncheked 異常。在 C 語言裡有對應的概念,普通異常處理和使用斷言。維基百科上關於斷言的的文章也有關於什麼時候用斷言什麼時候用普通的錯誤處理的類似的解釋。
  4. 如果這看起來非常具體,那是因為我們在產品環境中遇到這樣過這樣的問題。這真的很可怕。

相關文章