天天看點

.NET中異常處理的最佳實踐(譯)

目錄

介紹

做最壞的打算

提前檢查

不要信任外部資料

可信任的裝置:攝像頭、滑鼠以及鍵盤

 “寫操作”同樣可能失效

安全程式設計

不要抛出“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服務或者序列槽,以上情況均可能發生。所有的外部資料總會有失效的可能。

“寫操作”同樣可能失效

不可信任的資料源同樣也是一種不可信任的資料倉庫。當你存儲資料時,相似情況依舊可能會發生:

  2)裝置不存在

  3)沒有足夠的空間

  4)儲存設備發生了實體錯誤

這就是為什麼一些壓縮軟體在工作時建立了一個臨時檔案,當工作完成後再重命名,而不是直接修改源檔案。原因是如果硬碟損壞(或者軟體異常)可能導緻原始資料丢失。(譯者遇見過這種情況,備份資料時斷電,結果原來的舊版備份被損壞了,譯者注)

我的一個朋友告訴我:一個好的程式員從來不會在他的程式中編寫糟糕的代碼。我覺得這隻是成為一個好程式員的必要條件而不是充分條件。下面我整理了一些當你進行異常處理時,可能會編寫的“糟糕代碼”:

請别這樣做。exception是一個非常抽象的異常類,捕獲這類異常通常會産生很多負面影響。通常情況下應該定義我們自己的異常類,并且需要區分系統(framework)抛出的異常和我們自己抛出的異常。

異常都封裝在類中。當你需要傳回異常資訊時,請将資訊存儲在一些單獨的屬性中(而不要放在message屬性中),否則人們很難從message屬性中解析出他們需要的資訊。比如當你僅僅需要糾正一下拼寫錯誤,如果你将錯誤資訊和其它提示内容一起以string的形式寫在了message屬性中,那麼别人該怎樣簡單地擷取他們要的錯誤資訊呢?你很難想象到他們要做多少努力。

一般異常處理都放在了程式中一個比較集中的地方。每個線程都需要有一個try/catch塊,否則你會漏掉某些異常進而出現難以了解的問題。當一個程式開啟了多個線程去處理背景任務時,通常你會建立一個類型來存儲各個線程執行的結果。這時候請不要忘記了為類型增加一個字段來存儲每個線程可能發生的異常,否則的話,主線程不會知道其他線程的異常情況。在一些“即發即忘”的場合(意思主線程開啟線程後不再關心線程的運作情況,譯者注),你可能需要将主線程中的異常處理邏輯複制一份到你的子線程中去。

不管你的程式是使用何種方式記錄日志——log4net、eif、event log、tracelisteners或者文本檔案等,這些都不重要。重要的是:當你遇到異常後,應該在某個地方将它記錄在日志中。但是請僅僅記錄一次,否則的話,你最後會得到一個非常大的日志檔案,包含了許多重複資訊。

當我們談到記錄日志時,不要忘了我們應該記錄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塊中做一些事情,譯者注),比如清理資源的代碼、關閉流或者回複狀态等。請把這當作習慣。

有一件大家容易忽略的事情是:怎樣讓我們的try/catch塊同時具備易讀性和健壯性。舉個例子,假設你需要從一個臨時檔案中讀取資料并且傳回一個字元串。無論什麼情況發生,我們都得删除這個臨時檔案,因為它是臨時性的。

讓我們先看看最簡單的不使用try/catch塊的代碼:

這段代碼有一個問題,readtoend方法有可能抛出異常,那麼臨時檔案就無法删除了。是以有些人修改代碼為:

這段代碼變得複雜一些,并且它包含了重複性的代碼。

那麼現在讓我們看看更簡介更健壯的使用try/finally的方式:

變量filecontents去哪裡了?它不再需要了,因為傳回點在清理代碼前面。這是讓代碼在方法傳回後才執行的好處:你可以清理那些傳回語句需要用到的資源(方法傳回時需要用到的資源,是以資源隻能在方法傳回後才能釋放,譯者注)。

因為這樣做有很多問題:

  1)直接抛出異常更快,因為使用特殊的傳回值表示異常時,我們每次調用完方法時,都需要去檢查傳回結果,并且這至少要多占用一個寄存器。降低代碼運作速度。

  2)特殊傳回值能,并且很可能被忽略

  3)特殊傳回值不能包含堆棧跟蹤(stack trace)資訊,不能傳回異常的詳細資訊

  4)很多時候,不存在一個特殊值去表示方法中發生的異常,比如,除數為零的情況:

微軟建議在某些特定場合,方法可以通過傳回一些特定值來表示方法在執行過程中發生了預計之外的事情。我知道我上面提到的規則恰恰跟這條建議相反,我也不喜歡這樣搞。但是一些api确實使用了某些特殊傳回值來表示方法中的異常,并且工作得很好,是以我還是覺得你們可以謹慎地遵循這條建議。

我看到了.net framework中很多擷取資源的api方法使用了特殊傳回值,比如assembly.getmanifeststream方法,當找不到資源時(異常),它會傳回null(不會抛出異常)。

這是一個非常糟糕的設計。代碼中包含太多的try/catch塊會使代碼難以了解,恰當的設計完全可以滿足一個方法傳回各種不同的執行結果(絕不可能到了必須使用抛出異常的方式才能說明方法執行結果的地步,譯者注),如果你确實需要通過抛出異常來表示方法的執行結果,那隻能說明你這個方法做了太多事情,必須進行拆分。(這裡原文的意思是,除非确實有異常發生,否則一個方法不應該僅僅是為了說明執行結果而抛出異常,也就是說,不能無病呻呤,譯者注)

我可以舉個現實中的例子。我為我的grivo(我的一個産品)開發了一個用來登入的api(login),如果使用者登入失敗,或者使用者并沒有調用login方法,那麼他們調用其他方法時都會失敗。我在設計login方法的時候這樣做的:如果使用者登入失敗,它會抛出一個異常,而并不是簡單的傳回false。正因為這樣,調用者(使用者)才不會忽略(他還沒登入)這個事實。

堆棧跟蹤資訊是異常發生時最重要的資訊,我們經常需要在catch塊中處理一些異常,有時候還需要重新上抛異常(re-throw)。下面來看看兩種方法(一種錯誤的一種正确的):

錯誤的做法:

為什麼錯了?因為當我們檢查堆棧跟蹤資訊時,異常錯誤源變成了“thorw ex;”,這隐藏了真正異常抛出的位置。試一下下面這種做法:

有什麼變化沒?我們使用“throw;”代替了“throw ex;”,後者會清空原來的堆棧跟蹤資訊。如果我們在抛出異常時沒有指定具體的異常(簡單的throw),那麼它會預設地将原來捕獲的異常繼續上抛。這樣的話,上層代碼捕獲的異常還是最開始我們通過catch捕獲的同一個異常。

拓展閱讀:

<a target="_blank" href="http://blog.csdn.net/jiankunking/article/details/49463991">c# 異常處理(catch throw)il分析</a>

很多時候,我們的異常需要能被序列化。當我們派生一個新的異常類型時,請不要忘了給它加上serializable屬性。誰會知道我們的異常類會不會用在remoting call或者web services中呢?

當我們釋出程式後,不要忘了debug.assert将會被忽略。我們在代碼中做一些檢查或者驗證工作時,最好使用抛出異常的方式代替輸出debug資訊。

  将輸出debug資訊這種方式用到單元測試或者那些隻需要測試當軟體真正釋出後確定不會出錯的場合。

做這件事相當簡單(直接從其他的類型粘貼拷貝相同的代碼即可),如果你不這樣做,那麼别人在使用你編寫的異常類型時,很難遵守上面給出的一些規則的。

  我指的哪些構造方法呢?這三個構造方法可以參見這裡。

已經有很多在異常處理方面做得比較好的架構或庫,微軟提供的有兩個:

<a target="_blank" href="http://www.microsoft.com/downloads/details.aspx?displaylang=en&amp;familyid=8ca8eb6e-6f4a-43df-adeb-8f22ca173e02">exception management application block</a>

<a target="_blank" href="http://www.microsoft.com/downloads/details.aspx?familyid=80df04bc-267d-4919-8bb4-1f84b7eb1368&amp;displaylang=en">microsoft enterprise instrumentation framework</a>

注意,如果你不遵守我上面提到的一些規則,這些庫對你來講可能沒什麼用。

如果你已經讀完整篇文章,你就會發現所有的示例代碼都是用c#編寫的。那是因為c#是我比較喜歡的.net語言,并且vb.net有它自己的一些特殊規則。

不幸的是,vb.net中并沒有using語句。你每次在釋放一個對象的非托管資源時,不得不這樣去做:

.NET中異常處理的最佳實踐(譯)

非結構化異常處理也叫“on error goto”,djikstra(艾茲赫爾·戴克斯特拉)在1974年說過“goto語句有害無益”,這已經是30年之前了!請删除你代碼中的所有goto式的語句,我向你保證,他們萬害無一益。(艾茲赫爾·戴克斯特拉提出了“goto有害論”、信号量和pv原語,解決了有趣的哲學家就餐問題。《軟體故事》一書中講fortran語言時提到過他。譯者注)

我希望本篇文章能夠讓一部分人能夠提高他們的編碼品質,也希望這篇文章是讨論怎樣有效地進行異常處理的開始,并讓我們編寫的程式更加健壯。

譯者話:

我有一個缺點,不知道有沒有網友跟我一樣。我是個慢熱型的人,對技術也一樣,好多東西流行颠峰時期過去了我才開始有所感覺。主要一是因為我對新鮮東西不太感冒;二是我總感覺原來學習的東西還沒有掌握好就換,有點半途而廢的意思。其實我也知道這樣非常不好,畢竟it行業是個快速發展的行業,一沒跟上步伐就落後了。

.NET中異常處理的最佳實踐(譯)

正是遇見這樣互相沖突的情況,我在學習知識的時候都是重點學習技術間的通性,所謂通性,即十年、二十年甚至三十年不太會變、不太會沒落的東西,如果你現在從事的公司實際開發過程中一直使用某一套架構,你要是死抓着“怎樣使用這個架構做出好的系統”不放,那麼過幾年你可能就落伍了。而如果你研究研究程式設計中的共性,比如協定、系統間的互動原理等,這些在每個網絡通信系統中都會用到,無論是貌似已經過時了的pc程式,還是web程式,還是目前流行的移動app,都會用到,而且基本原理都是一樣的。看得多了,就發現新東西出來好像是換湯不換藥的感覺(稍微誇張:-))

是以,我給那些跟我一樣,不太跟随新鮮事物的人、或者那些長期從事某一類固定開發工作的人的建議是:找準技術間的共性,不要停留在技術表面,除非你對新鮮事物足夠感興趣,并且有充分精力。

以上這些話也是我們公司開讨論會時分享的。

本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接配接,否則保留追究法律責任的權利。 

補充:

關于clr的 “兩輪周遊”異常處理政策。

當應用 程式擁有多層嵌套的異常捕獲結構時,如果最底層(其實在中間層也一樣)發生了異常,clr将優先在引發異常的那一層去搜尋catch語句塊,看看有沒有“相容”

此類型異常的處理代碼 ,如果沒有,就“跳到”上一層去搜尋,如果上一層還沒有,繼續搜尋上一層的“上一層”,由此直到應用 程式的最頂層。

這就是clr處理嵌套異常捕獲結構應用程式的“第一輪”周遊-----查找合适的異常處理程式。

如果在某一層找到了異常處理程式,注意,clr并不會馬上執行之,而是回到"事故現場",再次進行“第二輪”周遊,執行所有“中間”層次的finally 語句塊,然後,執行

找到異常處理程式 ,最後,再從本層開始一直周遊到最頂層,執行所有的finally語句塊。

繼續閱讀