.NET中異常處理的最佳實踐(譯)
原文 http://www.cnblogs.com/xiaozhi_5638/p/4259115.html
本文翻譯自CodeProject上的一篇文章, 原文地址 。
目錄
- 介紹
- 做最壞的打算
- 提前檢查
- 不要信任外部數據
- 可信任的設備:攝像頭、鼠標以及鍵盤
- “寫操作”同樣可能失效
- 安全編程
- 不要拋出“new Exception()”
- 不要將重要的異常信息存儲在Message屬性中
- 每個線程要包含一個try/catch塊
- 捕獲異常后要記錄下來
- 不要只記錄Exception.Message的值,還需要記錄Exception.ToString()
- 要捕獲具體的異常
- 不要中止異常上拋
- 清理代碼要放在finally塊中
- 不要忘記使用using
- 不要使用特殊返回值去表示方法中發生的異常
- 不要使用“拋出異常”的方式去表示資源不存在
- 不要將“拋出異常”作為函數執行結果的一種
- 可以使用“拋出異常”的方式去著重說明不能被忽略的錯誤
- 不要清空了堆棧跟蹤(stack trace)信息
- 異常類應標記為Serializable
- 使用”拋出異常”代替Debug.Assert
- 每個異常類至少包含三個構造方法
- 不要重復造輪子
- VB.NET
- 模擬C#中的using語句
- 不要使用非結構化異常處理(On Error goto)
- 總結
介紹
“我的軟件程序從來都不會出錯”。你們相信嗎?我幾乎可以肯定所有人都會大喊我是個騙子。“軟件程序幾乎不可能沒有bug!”
事實上,開發一個可信任、健全的軟件程序并不是不可能的事情。注意我這里并不是指那些用于控制核電站的軟件,而是指一些常見的商業軟件,這些軟件 可能運行在服務器上,又或者PC機上,它們可以連續工作幾個星期甚至幾個月都不會出現重大問題。可以猜到,我剛才的意思是指軟件有一個比較低的出錯率,你 可以迅速找到出錯的原因并快速修復,并且出現的錯誤并不會造成重大的數據損壞。
換句話說,我的意思是指軟件比較穩定。
軟件中有bug是可以理解的。但是如果是經常出現的bug,并且因為沒有足夠的提示信息導致你不能迅速修復它,那么這種情況是不可被原諒的。
為了更好地理解我上面所說的話,我舉個例子:我經常看見無數的商業軟件在遇到硬盤不足時給出這樣的錯誤提示:
“更新客戶資料失敗,請與系統管理員聯系然后重試”。
除了這些外,其他任何信息都沒有被記錄。要搞清楚到底什么原因引起的這個錯誤是一件非常耗時的過程,在真正找到問題原因之前,程序員可能需要做各種各樣的猜測。
注意在這篇文章中,我主要講怎樣更好地處理.NET編程中的異常,并沒有打算討論怎樣顯示合適的“錯誤提示信息”,因為我覺得這個工作屬于UI界 面開發者,并且它大部分依賴于UI界面類型以及最終使用軟件的用戶。比如一個面向普通用戶的文本編輯器的“錯誤提示信息”應該完全不同于一個Socket 通信框架,因為后者直接用戶是程序員。
做最壞的打算
遵守一些基本的設計原則可以讓你的程序更加健全,并且當錯誤發生時,能夠提升用戶體驗。我這里說到的“提升用戶體驗”并不是指錯誤的提示窗體能夠 讓用戶高興,而是指發生的錯誤不會損壞原有數據,不會讓整個電腦崩潰。如果你的程序遇到硬盤不足的錯誤,但是程序不會造成其他任何負面效果(僅僅提示錯誤 信息,不會引起其他問題,譯者注),那么這時候就提升了用戶體驗。
- 提前檢查
強類型檢查和驗證是避免bug發生的有力方法。你越早發現問題,就越早修復問題。幾個月后再想搞清楚“為什么InvoiceItems表中的 ProductID欄會存在一個CustomerID數據?”是一件不太容易并且相當惱火的事情。如果你使用一個類代替基本類型(如int、 string)去存儲客戶(Customer)的數據的話,編譯器就不會允許剛才那件事情(指將CustomerID和ProductID混淆,譯者注) 發生。
- 不要信任外部數據
外部數據是不可靠的,我們的軟件程序在使用它們之前必須嚴格檢查。無論這些外部數據來自于注冊表、數據庫、硬盤、socket還是你用鍵盤編寫的 文件,所有這些外部數據在使用前必須嚴格進行檢查。很多時候,我看到一些程序完全信任配置文件,因為開發這些程序的程序員總是認為沒有人會編輯配置文件并 損壞它。
- 可信任的設備:攝像頭、鼠標以及鍵盤
當你需要用到外部數據時,你可能會遇到以下情況:
1)沒有足夠的安全權限
2)數據不存在
3)數據不完整
4)數據完整,但是格式不對
不管數據源是注冊表中的某個鍵、一個文件、socket套接字、數據庫、Web服務或者串口,以上情況均可能發生。所有的外部數據總會有失效的可能。
- “寫操作”同樣可能失效
不可信任的數據源同樣也是一種不可信任的數據倉庫。當你存儲數據時,相似情況依舊可能會發生:
1)沒有足夠的安全權限
2)設備不存在
3)沒有足夠的空間
4)存儲設備發生了物理錯誤
這就是為什么一些壓縮軟件在工作時創建了一個臨時文件,當工作完成后再重命名,而不是直接修改源文件。原因是如果硬盤損壞(或者軟件異常)可能導致原始數據丟失。(譯者遇見過這種情況,備份數據時斷電,結果原來的舊版備份被損壞了,譯者注)
安全編程
我的一個朋友告訴我:一個好的程序員從來不會在他的程序中編寫糟糕的代碼。我覺得這只是成為一個好程序員的必要條件而不是充分條件。下面我整理了一些當你進行異常處理時,可能會編寫的“糟糕代碼”:
- 不要拋出“new Exception()”
請別這樣做。Exception是一個非常抽象的異常類,捕獲這類異常通常會產生很多負面影響。通常情況下應該定義我們自己的異常類,并且需要區分系統(framework)拋出的異常和我們自己拋出的異常。
- 不要將重要的異常信息存儲在Message屬性中
異常都封裝在類中。當你需要返回異常信息時,請將信息存儲在一些單獨的屬性中(而不要放在Message屬性中),否則人們很難從Message 屬性中解析出他們需要的信息。比如當你僅僅需要糾正一下拼寫錯誤,如果你將錯誤信息和其它提示內容一起以String的形式寫在了Message屬性中, 那么別人該怎樣簡單地獲取他們要的錯誤信息呢?你很難想象到他們要做多少努力。
- 每個線程要包含一個try/catch塊
一般異常處理都放在了程序中一個比較集中的地方。每個線程都需要有一個try/catch塊,否則你會漏掉某些異常從而出現難以理解的問題。當一 個程序開啟了多個線程去處理后臺任務時,通常你會創建一個類型來存儲各個線程執行的結果。這時候請不要忘記了為類型增加一個字段來存儲每個線程可能發生的 異常,否則的話,主線程不會知道其他線程的異常情況。在一些“即發即忘”的場合(意思主線程開啟線程后不再關心線程的運行情況,譯者注),你可能需要將主 線程中的異常處理邏輯復制一份到你的子線程中去。
- 捕獲異常后要記錄下來
不管你的程序是使用何種方式記錄日志——log4net、EIF、Event Log、TraceListeners或者文本文件等,這些都不重要。重要的是:當你遇到異常后,應該在某個地方將它記錄在日志中。但是請僅僅記錄一次, 否則的話,你最后會得到一個非常大的日志文件,包含了許多重復信息。
- 不要只記錄Exception.Message的值,還需要記錄Exception.ToString()
當我們談到記錄日志時,不要忘了我們應該記錄Exception.ToString()的值,而不是Exception.Message。因為 Exception.ToString()包含了“堆棧跟蹤”(stack trace)信息,內部異常信息以及Message。通常這些信息非常重要,而如果你只記錄Exception.Message的話,你只可能看到類似 “對象引用未指向堆中實例”這樣的提示。
- 要捕獲具體的異常
如果你要捕獲異常,請盡可能的捕獲具體異常(而非Exception)。
我經常看見初學者說,一段好的代碼就是不能拋出異常的代碼。其實這說法是錯誤的,好的代碼在必要時應該拋出相應的異常,并且好的代碼只能捕獲它知道該怎么處理的異常(注意這句話,譯者注)。
下面的代碼作為對這條規則的說明。我敢打賭編寫下面這段代碼的那個家伙看見了會殺了我的,但是它確實是摘取自真實編程工作中的一段代碼。
第一個類MyClass在一個程序集中,第二個類GenericLibrary在另一個程序集中。在開發的機器上運行正常,但是在測試機器上卻總是拋出“數據不合法!”的異常,盡管每次輸入的數據都是合法的。
你們能說說這是為什么嗎?
這個問題的原因就是異常處理不太具體。根據MSDN上的介紹,Convert.ToInt32方法僅僅會拋出ArgumentException、FormatException以及OverflowException三個異常。所以,我們應該僅僅處理這三個異常。
問題發生在我們程序安裝的步驟上,我們沒有將第二個程序集(GenericLibrary.dll)打包進去。所以程序運行 后,ConvertToInt方法會拋出FileNotFoundException異常,但是我們捕獲的異常是Exception,所以會提示“數據不 合法”。
- 不要中止異常上拋
最壞的情況是,你編寫catch(Exception)這樣的代碼,并且在catch塊中啥也不干。請不要這樣做。
- 清理代碼要放在finally塊中
大多數時候,我們只處理某一些特定的異常,其它異常不負責處理。那么我們的代碼中就應該多一些finally塊(就算發生了不處理的異常,也可以在finally塊中做一些事情,譯者注),比如清理資源的代碼、關閉流或者回復狀態等。請把這當作習慣。
有一件大家容易忽略的事情是:怎樣讓我們的try/catch塊同時具備易讀性和健壯性。舉個例子,假設你需要從一個臨時文件中讀取數據并且返回一個字符串。無論什么情況發生,我們都得刪除這個臨時文件,因為它是臨時性的。
讓我們先看看最簡單的不使用try/catch塊的代碼:
這段代碼有一個問題,ReadToEnd方法有可能拋出異常,那么臨時文件就無法刪除了。所以有些人修改代碼為:
這段代碼變得復雜一些,并且它包含了重復性的代碼。
那么現在讓我們看看更簡介更健壯的使用try/finally的方式:
變量fileContents去哪里了?它不再需要了,因為返回點在清理代碼前面。這是讓代碼在方法返回后才執行的好處:你可以清理那些返回語句需要用到的資源(方法返回時需要用到的資源,所以資源只能在方法返回后才能釋放,譯者注)。
- 不要忘記使用using
僅僅調用對象的Dispose()方法是不夠的。即使異常發生時,using關鍵字也能夠防止資源泄漏。( 關于對象的Dispose()方法的用法,可以關注 我的書 ,有一章專門介紹。譯者注 )
- 不要使用特殊返回值去表示方法中發生的異常
因為這樣做有很多問題:
1)直接拋出異常更快,因為使用特殊的返回值表示異常時,我們每次調用完方法時,都需要去檢查返回結果,并且這至少要多占用一個寄存器。降低代碼運行速度。
2)特殊返回值能,并且很可能被忽略
3)特殊返回值不能包含堆棧跟蹤(stack trace)信息,不能返回異常的詳細信息
4)很多時候,不存在一個特殊值去表示方法中發生的異常,比如,除數為零的情況:
- 不要使用“拋出異常”的方式去表示資源不存在
微軟建議在某些特定場合,方法可以通過返回一些特定值來表示方法在執行過程中發生了預計之外的事情。我知道我上面提到的規則恰恰跟這條建議相反, 我也不喜歡這樣搞。但是一些API確實使用了某些特殊返回值來表示方法中的異常,并且工作得很好,所以我還是覺得你們可以謹慎地遵循這條建議。
我看到了.NET Framework中很多獲取資源的API方法使用了特殊返回值,比如Assembly.GetManifestStream方法,當找不到資源時(異常),它會返回null(不會拋出異常)。
- 不要將“拋出異常”作為函數執行結果的一種
這是一個非常糟糕的設計。代碼中包含太多的try/catch塊會使代碼難以理解,恰當的設計完全可以滿足一個方法返回各種不同的執行結果(絕不可能到了 必須使用拋出異常的方式才能說明方法執行結果的地步,譯者注),如果你確實需要通過拋出異常來表示方法的執行結果,那只能說明你這個方法做了太多事情,必 須進行拆分。( 這里原文的意思是,除非確實有異常發生,否則一個方法不應該僅僅是為了說明執行結果而拋出異常,也就是說,不能無病呻呤,譯者注 )
- 可以使用“拋出異常”的方式去著重說明不能被忽略的錯誤
我可以舉個現實中的例子。我為我的Grivo(我的一個產品)開發了一個用來登錄的API(Login),如果用戶登錄失敗,或者用戶并沒有調用 Login方法,那么他們調用其他方法時都會失敗。我在設計Login方法的時候這樣做的:如果用戶登錄失敗,它會拋出一個異常,而并不是簡單的返回 false。正因為這樣,調用者(用戶)才不會忽略(他還沒登錄)這個事實。
- 不要清空了堆棧跟蹤(stack trace)信息
堆棧跟蹤信息是異常發生時最重要的信息,我們經常需要在catch塊中處理一些異常,有時候還需要重新上拋異常(re-throw)。下面來看看兩種方法(一種錯誤的一種正確的):
錯誤的做法:
為什么錯了?因為當我們檢查堆棧跟蹤信息時,異常錯誤源變成了“thorw ex;”,這隱藏了真正異常拋出的位置。試一下下面這種做法:
有什么變化沒?我們使用“throw;”代替了“throw ex;”,后者會清空原來的堆棧跟蹤信息。如果我們在拋出異常時沒有指定具體的異常(簡單的throw),那么它會默認地將原來捕獲的異常繼續上拋。這樣 的話,上層代碼捕獲的異常還是最開始我們通過catch捕獲的同一個異常。
- 異常類應標記為Serializable
很多時候,我們的異常需要能被序列化。當我們派生一個新的異常類型時,請不要忘了給它加上Serializable屬性。誰會知道我們的異常類會不會用在Remoting Call或者Web Services中呢?
- 使用”拋出異常”代替Debug.Assert
當我們發布程序后,不要忘了Debug.Assert將會被忽略。我們在代碼中做一些檢查或者驗證工作時,最好使用拋出異常的方式代替輸出Debug信息。
將輸出Debug信息這種方式用到單元測試或者那些只需要測試當軟件真正發布后確保不會出錯的場合。
- 每個異常類至少包含三個構造方法
做這件事相當簡單(直接從其他的類型粘貼拷貝相同的代碼即可),如果你不這樣做,那么別人在使用你編寫的異常類型時,很難遵守上面給出的一些規則的。
我指的哪些構造方法呢?這三個構造方法可以參見這里。
不要重復造輪子
已經有很多在異常處理方面做得比較好的框架或庫,微軟提供的有兩個:
Exception Management Application Block
Microsoft Enterprise Instrumentation Framework
注意,如果你不遵守我上面提到的一些規則,這些庫對你來講可能沒什么用。
VB.NET
如果你已經讀完整篇文章,你就會發現所有的示例代碼都是用C#編寫的。那是因為C#是我比較喜歡的.NET語言,并且VB.NET有它自己的一些特殊規則。
- 模擬C#中的using語句
不幸的是,VB.NET中并沒有using語句。你每次在釋放一個對象的非托管資源時,不得不這樣去做:
如果你不按照上面那種方式調用DIspose方法的話,很可能會出現錯誤( 有關Dispose方法的調用,請關注 新書 。譯者注 )。
- 不要使用非結構化異常處理(On Error Goto)
非結構化異常處理也叫“On Error Goto”,Djikstra(艾茲赫爾·戴克斯特拉)在1974年說過“goto語句有害無益”,這已經是30年之前了!請刪除你代碼中的所有goto式的語句,我向你保證,他們萬害無一益。( 艾茲赫爾·戴克斯特拉提出了“goto有害論”、信號量和PV原語,解決了有趣的哲學家就餐問題。《軟件故事》一書中講Fortran語言時提到過他。譯者注 )
總結
我希望本篇文章能夠讓一部分人能夠提高他們的編碼質量,也希望這篇文章是討論怎樣有效地進行異常處理的開始,并讓我們編寫的程序更加健壯。
譯者話:
我有一個缺點,不知道有沒有網友跟我一樣。我是個慢熱型的人,對技術也一樣,好多東西流行顛峰時期過去了我才開始有所感覺。主要一是因為我對新鮮 東西不太感冒;二是我總感覺原來學習的東西還沒有掌握好就換,有點半途而廢的意思。其實我也知道這樣非常不好,畢竟IT行業是個快速發展的行業,一沒跟上 步伐就落后了。
正是遇見這樣相互矛盾的情況,我在學習知識的時候都是重點學習技術間的通性,所謂通性,即十年、二十年甚至三十年不太會變、不太會沒落的東西,如 果你現在從事的公司實際開發過程中一直使用某一套框架,你要是死抓著“怎樣使用這個框架做出好的系統”不放,那么過幾年你可能就落伍了。而如果你研究研究 編程中的共性,比如協議、系統間的交互原理等,這些在每個網絡通信系統中都會用到,無論是貌似已經過時了的PC程序,還是Web程序,還是當前流行的移動 APP,都會用到,而且基本原理都是一樣的。看得多了,就發現新東西出來好像是換湯不換藥的感覺(稍微夸張:-))
因此,我給那些跟我一樣,不太跟隨新鮮事物的人、或者那些長期從事某一類固定開發工作的人的建議是:找準技術間的共性,不要停留在技術表面,除非你對新鮮事物足夠感興趣,并且有充分精力。
以上這些話也是我們公司開討論會時分享的。