把錯誤分成兩大類很有用[腳注3]:
系統(tǒng)內(nèi)存不足
程序員失誤?是程序里的Bug。這些錯誤往往可以通過修改代碼避免。它們永遠都沒法被有效的處理。
讀取 undefined 的一個屬性
調(diào)用異步函數(shù)沒有指定回調(diào)
該傳對象的時候傳了一個字符串
人們把操作失敗和程序員的失誤都稱為“錯誤”,但其實它們很不一樣。操作失敗是所有正確的程序應(yīng)該處理的錯誤情形,只要被妥善處理它們不一定會預示著Bug或是嚴重的問題?!拔募也坏健笔且粋€操作失敗,但是它并不一定意味著哪里出錯了。它可能只是代表著程序如果想用一個文件得事先創(chuàng)建它。
與之相反,程序員失誤是徹徹底底的Bug。這些情形下你會犯錯:忘記驗證用戶輸入,敲錯了變量名,諸如此類。這樣的錯誤根本就沒法被處理,如果可以,那就意味著你用處理錯誤的代碼代替了出錯的代碼。
這樣的區(qū)分很重要:操作失敗是程序正常操作的一部分。而由程序員的失誤則是Bug。
有的時候,你會在一個Root問題里同時遇到操作失敗和程序員的失誤。HTTP服務(wù)器訪問了未定義的變量時奔潰了,這是程序員的失誤。當前連接著的客戶端會在程序崩潰的同時看到一個ECONNRESET
錯誤,在NodeJS里通常會被報成“Socket Hang-up”。對客戶端來說,這是一個不相關(guān)的操作失敗, 那是因為正確的客戶端必須處理服務(wù)器宕機或者網(wǎng)絡(luò)中斷的情況。
類似的,如果不處理好操作失敗, 這本身就是一個失誤。舉個例子,如果程序想要連接服務(wù)器,但是得到一個ECONNREFUSED
錯誤,而這個程序沒有監(jiān)聽套接字上的?error
事件,然后程序崩潰了,這是程序員的失誤。連接斷開是操作失?。ㄒ驗檫@是任何一個正確的程序在系統(tǒng)的網(wǎng)絡(luò)或者其它模塊出問題時都會經(jīng)歷的),如果它不被正確處理,那它就是一個失誤。
理解操作失敗和程序員失誤的不同, 是搞清怎么傳遞異常和處理異常的基礎(chǔ)。明白了這點再繼續(xù)往下讀。
就像性能和安全問題一樣,錯誤處理并不是可以憑空加到一個沒有任何錯誤處理的程序中的。你沒有辦法在一個集中的地方處理所有的異常,就像你不能在一個集中的地方解決所有的性能問題。你得考慮任何會導致失敗的代碼(比如打開文件,連接服務(wù)器,F(xiàn)ork子進程等)可能產(chǎn)生的結(jié)果。包括為什么出錯,錯誤背后的原因。之后會提及,但是關(guān)鍵在于錯誤處理的粒度要細,因為哪里出錯和為什么出錯決定了影響大小和對策。
你可能會發(fā)現(xiàn)在棧的某幾層不斷地處理相同的錯誤。這是因為底層除了向上層傳遞錯誤,上層再向它的上層傳遞錯誤以外,底層沒有做任何有意義的事情。通常,只有頂層的調(diào)用者知道正確的應(yīng)對是什么,是重試操作,報告給用戶還是其它。但是那并不意味著,你應(yīng)該把所有的錯誤全都丟給頂層的回調(diào)函數(shù)。因為,頂層的回調(diào)函數(shù)不知道發(fā)生錯誤的上下文,不知道哪些操作已經(jīng)成功執(zhí)行,哪些操作實際上失敗了。
我們來更具體一些。對于一個給定的錯誤,你可以做這些事情:
直接處理。有的時候該做什么很清楚。如果你在嘗試打開日志文件的時候得到了一個ENOENT
錯誤,很有可能你是第一次打開這個文件,你要做的就是首先創(chuàng)建它。更有意思的例子是,你維護著到服務(wù)器(比如數(shù)據(jù)庫)的持久連接,然后遇到了一個“socket hang-up”的異常。這通常意味著要么遠端要么本地的網(wǎng)絡(luò)失敗了。很多時候這種錯誤是暫時的,所以大部分情況下你得重新連接來解決問題。(這和接下來的重試不大一樣,因為在你得到這個錯誤的時候不一定有操作正在進行)
把出錯擴散到客戶端。如果你不知道怎么處理這個異常,最簡單的方式就是放棄你正在執(zhí)行的操作,清理所有開始的,然后把錯誤傳遞給客戶端。(怎么傳遞異常是另外一回事了,接下來會討論)。這種方式適合錯誤短時間內(nèi)無法解決的情形。比如,用戶提交了不正確的JSON,你再解析一次是沒什么幫助的。
重試操作。對于那些來自網(wǎng)絡(luò)和遠程服務(wù)的錯誤,有的時候重試操作就可以解決問題。比如,遠程服務(wù)返回了503(服務(wù)不可用錯誤),你可能會在幾秒種后重試。如果確定要重試,你應(yīng)該清晰的用文檔記錄下將會多次重試,重試多少次直到失敗,以及兩次重試的間隔。?另外,不要每次都假設(shè)需要重試。如果在棧中很深的地方(比如,被一個客戶端調(diào)用,而那個客戶端被另外一個由用戶操作的客戶端控制),這種情形下快速失敗讓客戶端去重試會更好。如果棧中的每一層都覺得需要重試,用戶最終會等待更長的時間,因為每一層都沒有意識到下層同時也在嘗試。
直接崩潰。對于那些本不可能發(fā)生的錯誤,或者由程序員失誤導致的錯誤(比如無法連接到同一程序里的本地套接字),可以記錄一個錯誤日志然后直接崩潰。其它的比如內(nèi)存不足這種錯誤,是JavaScript這樣的腳本語言無法處理的,崩潰是十分合理的。(即便如此,在child_process.exec
這樣的分離的操作里,得到ENOMEM
錯誤,或者那些你可以合理處理的錯誤時,你應(yīng)該考慮這么做)。在你無計可施需要讓管理員做修復的時候,你也可以直接崩潰。如果你用光了所有的文件描述符或者沒有訪問配置文件的權(quán)限,這種情況下你???么都做不了,只能等某個用戶登錄系統(tǒng)把東西修好。
對于程序員的失誤沒有什么好做的。從定義上看,一段本該工作的代碼壞掉了(比如變量名敲錯),你不能用更多的代碼再去修復它。一旦你這樣做了,你就使用錯誤處理的代碼代替了出錯的代碼。
有些人贊成從程序員的失誤中恢復,也就是讓當前的操作失敗,但是繼續(xù)處理請求。這種做法不推薦??紤]這樣的情況:原始代碼里有一個失誤是沒考慮到某種特殊情況。你怎么確定這個問題不會影響其他請求呢?如果其它的請求共享了某個狀態(tài)(服務(wù)器,套接字,數(shù)據(jù)庫連接池等),有極大的可能其他請求會不正常。
典型的例子是REST服務(wù)器(比如用Restify搭的),如果有一個請求處理函數(shù)拋出了一個ReferenceError
(比如,變量名打錯)。繼續(xù)運行下去很有肯能會導致嚴重的Bug,而且極其難發(fā)現(xiàn)。例如:
null
,undefined
或者其它無效值,結(jié)果就是下一個請求也失敗了。最好的從失誤恢復的方法是立刻崩潰。你應(yīng)該用一個restarter 來啟動你的程序,在奔潰的時候自動重啟。如果restarter 準備就緒,崩潰是失誤來臨時最快的恢復可靠服務(wù)的方法。
奔潰應(yīng)用程序唯一的負面影響是相連的客戶端臨時被擾亂,但是記住:
如果出現(xiàn)服務(wù)器經(jīng)常崩潰導致客戶端頻繁掉線的問題,你應(yīng)該把經(jīng)歷集中在造成服務(wù)器崩潰的Bug上,把它們變成可捕獲的異常,而不是在代碼明顯有問題的情況下盡可能地避免崩潰。調(diào)試這類問題最好的方法是,把 NodeJS 配置成出現(xiàn)未捕獲異常時把內(nèi)核文件打印出來。在 GNU/Linux 或者 基于 illumos 的系統(tǒng)上使用這些內(nèi)核文件,你不僅查看應(yīng)用崩潰時的堆棧記錄,還可以看到傳遞給函數(shù)的參數(shù)和其它的 JavaScript 對象,甚至是那些在閉包里引用的變量。即使沒有配置 code dumps,你也可以用堆棧信息和日志來開始處理問題。
最后,記住程序員在服務(wù)器端的失誤會造成客戶端的操作失敗,還有客戶端必須處理好服務(wù)器端的奔潰和網(wǎng)絡(luò)中斷。這不只是理論,而是實際發(fā)生在線上環(huán)境里。
更多建議: