.NET中異常處理最佳實踐

簡介

“我的軟件從來不出錯”你能相信嗎?我幾乎聽到你們全部尖叫說我是個說慌者。“從不出錯的軟件從某種程度上講是不可能的!”
   
和普通人的觀念相反,創造可信賴的,健壯的軟件並不是一件不可能的事情。請注意,我並沒有提及意欲控制核電站的無漏洞軟件。我提到的僅僅是可以在無人看管的服務器或者客戶端機器上運行的普通的商業軟件,在長時間(幾個星期或是幾個月)可以無重大故障的工作。可預測的,我的意思是它擁有低出錯率,你可以迅速理解出錯原因然後快速搞定它,同是,它從不因爲外部錯誤而毀壞數據。換句話說,軟件是穩定的。軟件中有漏洞是可以原諒的,甚至是被期望的。不可原諒的是您無法解決一個復發的漏洞,僅僅是因爲您沒用足夠的信息。
   
爲了更好的理解我所說的,我看過不計其數的商業軟件,DBMS是這樣報告空間不足的錯誤的:“不能更新用戶操作,請與系統管理員聯繫後在嘗試。”
   
雖然,用這則消息向一個商業用戶報告一種未知的資源失敗也許是恰當的,通常它應該是可以用來調試錯誤原因的全部調試信息。但是,如果沒用東西被記入日誌,瞭解當時的狀況將會是一件非常費時的事情,通常,程序員會猜測許多可能的原因直到他們找到真正的錯誤。 
在這篇文章中注意到這些,我將會集中精力介紹如何去更好的利用.NET的異常機制:我不會去討論怎樣正確的報告錯誤信息,因爲我認爲這個問題應該屬於UI(用戶界面)領域,同時它十分依賴正在開發的接口和所要面對的聽衆;一面向青少年的博客文章編輯者應該用一種與直接面向編程人員的socket server完全不同的方式來報告出錯信息。
做好最壞的打算
  
幾個基礎設計概念將會使你的程序更加健壯,同時提高用戶處理意外錯誤的經驗。“提高用戶處理意外錯誤的經驗”是什麼意思呢?它是用戶不會被你提供的令人驚異的對話框嚇的發抖。它更多的是不要使用戶誤用數據,搞垮計算機,以確保計算機運轉的更加安全。如果你的程序可以無損壞的處理空間不足錯誤,你將會增加用戶的經驗。

及早檢查
強大的類型檢查和確認是防止意外異常,確保文檔記錄及代碼檢查的有力工具。發現問題的殺傷力越早,這個問題就越容易解決。設法在數月後去瞭解InvoiceItems 表中Customerid在Productid欄目中的作用幾乎是不可能的,這不是開玩笑。如果你使用類而不是原始數據類型(比如 int ,string 等)來存儲客戶數據,編譯器將不會給你處理上述事件的機會。

不要相信外部數據
外部數據是不可相信的。不管這些數據是來自寄存器,數據庫,硬盤,socket,你所書寫的文件抑或是鍵盤,它們都必須被廣泛的檢查。所有的外部數據都應該被檢查,只有這樣你纔可以信任它。我經常發現信任配置文件的程序,原因是它們的編寫者從沒想過有人會編輯或誤用它。

唯一可信賴的外部設施是:顯示器,鼠標和鍵盤
當你需要外部數據時,你可能遇到以下的情形:
◆沒有足夠的安全權限
◆信息不在那兒
◆信息不完整
◆信息是完整的但是不可用的
不管它是註冊信息,文件,SOCKET,數據庫,網站服務抑或是串行端口,所有的外部數據源遲早都會出錯。爲安全錯誤做好準備才能把損失降到最小。

書寫同樣可能出錯
不可靠的數據源同樣是不可靠的數據倉庫。當你保存數據時,類似的情況可能發生:
◆沒有足夠的安全權限
◆設備不在那兒
◆設備沒有足夠的空間
◆設備由物理錯誤
以下便是壓縮程序開始時創建臨時文件,壓縮完後重命名它而不是取代原來文件的原因:如果硬盤(或壓縮軟件)因某種原因出錯,你將會丟失你的原始數據。
代碼的安全性
我的一個好朋友經常說:“一個好的程序員從不在他的項目中引用不安全的代碼”。我不認爲這是一個好的程序員的全部,但是的確幾乎是這樣。下面,我編譯了一些常見的“不安全代碼”,這些代碼在經過異常處理後可以引用到你的項目中。
不要拋出new Exception()
不要拋出 new Exception()。Exception是一個非常大的類,如果沒有side-effect,很難去捕獲。引用你自己的異常類,但是使它繼承自AppliationException。通過這種方法,你可以設計一個專門的異常捕獲程序去捕獲框架拋出的異常,同時設計另一個異常捕獲程序來處理自己拋出的異常。
修訂記錄:在以下的評論部分中,David Levitt寫信告訴我說,儘管Microsoft公司在MSDN doc中依然鼓吹使用System.ApplicationException做爲基礎類,但這已經不是一個好的習慣,就像Brad Adams在他的博客中所說的那樣。這個方法是儘可能創造淺且寬泛的異常類層次,就像你經常處理類層次結構那樣。我不馬上改變文章內容的原因是在此介紹之前,我需要做更多的研究。做完這項研究後,我依然不能決定淺的類層次結構在異常處理中是否是個好辦法,所以,在此處我給出了兩種觀點。但是,無論你做什麼,不要拋出new Exception(),不要在需要時繼承你自己的異常類。
#p#
不要把重要的異常信息放在message中
異常是類。當你返回異常信息時,要創建存儲數據的區域。如果你沒有這樣做,人們爲了得到所需要得信息將需要解析Message。現在,想象如果你需要局部化甚至僅僅想糾正一個錯誤信息中的拼寫錯誤,會對被調用的代碼造成什麼影響。你也許永遠都不知道你這樣做會損壞多少代碼。

每個線程要有單獨的catch (Exception ex)語句
在你的應用程序中,普通的異常處理應該被集中解決。每個線程需要一個單獨的try/catch模塊,否則,你將會丟失異常導致非常難處理的問題的出現。當一個應用程序啓動若干線程去做一些後臺處理時,通常你需要創建一個用來存儲處理結果的類。不要忘記添加用來存儲可能發生的異常的區域,否則在主線程中你將無法與之通信。在"fire and forget"情況下,你可能需要在線程處理中複製主應用程序異常處理。

一般的異常捕獲應該被記錄
你究竟使用什麼工具來記錄日誌——log4net, EIF, Event Log, TraceListeners,text files等等都無關緊要。真正重要的是:如果你捕獲一個異常,一定要在某處加以記錄。但是僅記錄一次——通常代碼與記錄異常的catch模塊一起被丟掉,然後你以一個龐大的日誌結束,此日誌擁有太多重複信息。

要記錄Exception的全部信息而不僅是Message
在我們談論記錄日誌時,不要忘記你應該經常性的記錄Exception.ToString(),而不僅是Exception.Message。Exception.ToString()將會給你一個堆棧跟蹤內部的異常和信息(messae)。通常,這個信息是及其珍貴的,如果你僅記錄Exception.Message,你將會僅僅獲得一些諸如“Object reference not set to an instance of an object”的信息。

每個線程只能有一個catch (Exception)語句
有很少的異常()遵循這一法則。如果你需要捕獲一個異常,最好使用你爲這段代碼編寫的最明確的異常類。
我經常發覺初學者認爲好的代碼是不拋出異常的代碼。這是錯誤的。好的代碼在需要是拋出異常,同時,僅處理那些它知道如何處理的異常。
作爲這個法則的一個應用,請看以下代碼。我打賭書寫這段代碼的人讀到這兒的時候想殺我,但是這是一則摘自真實世界的例子。事實上,真實世界的代碼要更復雜一些——我爲了說明問題將它大大簡化了。第一個類(MyClass)在一個集合,第二個類(GenericLibrary)在另一個集合,這個集合滿是普通代碼。在開發機上,這段代碼可以正確運行,可是在質量評價(QA)機上,這段代碼經常返回“無效數據(Invalid number)”即使輸入的數據是有效的。
你能說出爲什麼會這樣嗎?
public class MyClass
...{
static string ValidateNumber() static string ValidateNumber(string userInput)
        ...{
                try
                ...{
                        int val = GenericLibrary.ConvertToInt(userInput);
                        return "Valid number";
                }
                catch (Exception)
                ...{
                        return "Invalid number";
                }
        }
}
public class GenericLibrary
...{
static int ConvertToInt() static int ConvertToInt(string userInput)
        ...{
                return Convert.ToInt32(userInput);
        }
}

更多經驗
問題在於過於普通的異常處理者。MSDN的文檔中提及,Convert.ToInt32僅僅拋出ArgumentException,FormatException和OverflowException。所以,這些是唯一應該被處理的異常。
問題在於我們的配置沒有包含第二個集合(GenericLibrary)。現在,當我們調用ConvertToInt時就會有一個FileNotFoundException的產生,同時代碼假定它是由輸入的值無效產生的。
下一次你書寫“catch(Exception ex)”時,儘量描述清楚OutOfMemoryException異常被拋出時,你的代碼該如何處理。
不要總是吞掉異常
你做的最糟糕的事情是在catch (Exception)後加了一個空的模塊。永遠不要這樣做。
#p#

清理代碼應該放在finally模塊中
理論上,由於你並沒有處理許多普通的異常,同時你擁有一箇中央異常處理函數,你的代碼應該有遠比catch模塊多的finally模塊。不要把處理代碼,如關閉流,恢復狀態(就像鼠標指針)放在finally模塊之外。要養成習慣。
人們經常忽略的一件事是try/finally 模塊如何使你的代碼變得更加可讀與健壯。這是處理代碼的巨大作用所在。
做爲一個例子,假設你需要從一個文件中閱讀一些臨時信息,然後以字符串的形式返回它。不管發生什麼,你都必須刪除這一文件,因爲它是臨時的。這樣的返回處理功能需要try/finally模塊來完成。
讓我們看沒有使用try/finally模塊的最簡單的代碼:
string ReadTempFile(string FileName)
...{
        string fileContents;
        using (StreamReader sr = new StreamReader(FileName))
        ...{
                fileContents = sr.ReadToEnd();
        }
        File.Delete(FileName);
        return fileContents;
}

這段代碼在拋出異常時同樣遇到一個問題。比如,ReadToEnd函數:它在硬盤上留下臨時文件。因此,我真實的看到有人想用如下代碼來解決:
string ReadTempFile(string FileName)
...{
        try
        ...{
                string fileContents;
                using (StreamReader sr = new StreamReader(FileName))
                ...{
                        fileContents = sr.ReadToEnd();
                }
                File.Delete(FileName);
                return fileContents;
        }
        catch (Exception)
        ...{
                File.Delete(FileName);
                throw;
        }
}

代碼開始變的複雜的同時也開始複製代碼
現在,我們來看看使用try/finally的方法使代碼變的多麼的整潔和健壯:
string ReadTempFile(string FileName)
...{
        try
        ...{
                using (StreamReader sr = new StreamReader(FileName))
                ...{
                        return sr.ReadToEnd();
                }
        }
        finally
        ...{
                File.Delete(FileName);
        }
}

fileContents變量哪裏去了?它不再需要,因爲我們可以返回內容後使得處理代碼執行。這是擁有可以在函數返回後執行的代碼的優勢之一:你可以清空可能在返回狀態時依然需要的資源。

經常使用using
僅僅在一個對象上調用Dispose()函數是遠遠不夠的。關鍵字using將會阻止資源泄漏即使在有異常出現的地方。
不要在錯誤條件下返回特殊值
特殊值存在很多問題:
◆異常使得普通的事件更快,因爲當你從函數返回特殊值時,每一個函數返回需要被檢查,這個過程至少消耗一個進程寄存器或者更多,這些導致了代碼的運行緩慢。
◆特殊值可以或者將被忽略。
◆特殊值不攜帶堆棧追蹤,可以豐富錯誤細節。
◆經常發生的情況是函數沒有恰當的可以反映錯誤情況的值返回。爲表示“被清除”這一錯誤,你該讓如
下函數返回什麼值呢?

int divide() int divide(int x, int y)
{
return x / y;
}

#p#

不要使用異常去暗示資源的丟失
微軟建議在極端的普通情況下你應該使用返回特殊值。我知道我寫的恰恰與之相反,我也不想這樣,但是大多數API一致時生活會變得更加容易,所以我建議你謹慎的遵守這條法則。
我觀察.net框架,注意到幾乎使用這一風格的唯一的API是那些返回一定資源的API(如Assembly.GetMnifestStream 方法)。所有的這些API在缺乏資源的情況下均返回空。

不要把異常處理方法作爲從函數中返回信息的手段
這是一個極差的設計。不僅異常的處理緩慢(就像名字暗示的一樣,他們意味着只被使用在異常情況),而且代碼中許多的try/catch模塊會導致代碼很難維護。恰當的類設計可以提供普通的返回值。如果你確實在危機中想返回數據作爲一個異常,那麼你的方法可能做了太多的工作需要分解。
爲那些不該被忽略的錯誤使用異常
我使用現實世界的例子來說明這個問題。在開發一個API以便人們可以訪問Crivo(我的產品)的時候,你應該做的第一件事是調用Login函數。如果Login失敗,或未被調用,其他的每個函數調用將會失敗。我的選擇是如果Login函數調用失敗就從中拋出一個異常,而不是簡單的返回錯誤,這樣調用程序就不能忽略它。

當再次拋出異常時不要清空堆棧追蹤
堆棧追蹤是一個異常攜帶的最有用的信息之一。經常,我們需要在catch模塊中,放入一些異常處理代碼(如,回滾一個事務)然後再拋出異常。看它正確(錯誤)的處理方法:錯誤的處理方法:

try
{
        // Some code that throws an exception
}
catch (Exception ex)
{
        // some code that handles the exception
        throw ex;
}
爲什麼這個是錯誤的呢?因爲,當你檢查堆棧跟蹤時,異常將會運行到“throw ex”這一行,隱藏了真實的出錯位置。你可以試一下。
try
{
        // Some code that throws an exception
}
catch (Exception ex)
{
        // some code that handles the exception
        throw;
}

觀察以上代碼什麼改變了呢?取代了這個將會拋出新異常同時清空堆棧追蹤的“throw ex;”語句,我們使用了簡單的“throw;”語句。如果你沒有指定這個異常,throw 聲明將會僅僅再次拋出catch聲明捕獲的異常。這將會保證你的堆棧追蹤完整無缺,但是依然允許你在catch模塊中放入代碼。

避免在沒有增加語義值時就改變異常
只有在需要給它增加一些語義值時,你纔可以改變一個異常。比如,你在做一個DBMS連接驅動驅動,以便用戶可以不必擔心特殊的socket錯誤而僅僅需要知道連接失敗。
如果你總是需要這樣做,那麼,請在InnerException成員中保持最初的異常。不要忘記你的異常處理代碼中也許同樣有漏洞,這樣如果你有InnerException,你就會很容易的找到它。

異常應該用[Serializable]標識
大量的情形需要異常是可序列化的。當從另一個異常類繼承的時候,不要忘記增添這一屬性。你將永遠都不知道,你的函數什麼時候將被遠程組件或服務器調用。

有疑惑時,不要斷言,拋出異常
不要忘記Debug.Assert已經從釋放代碼中移除。在檢查和確認的時候,在代碼中拋出異常要比加入聲明好一些。
爲單元測試,內部循環變量,爲那些由運行條件(如果你考慮的話,是非常稀有的條件)決定的永遠不該出錯控制保存聲明。

每一個異常類都應該至少擁有三個初始化構造函數
做到這點是很容易的(僅僅是從其他異常類拷貝和複製定義)然而沒有能這樣不會允許使用你類的用戶遵循以下的幾條原則。
我提到的是那些構造函數呢?是這一頁上最後描述的三個構造函數。
#p#

使用AppDomain.UnhandledException事件時要小心
修訂筆記:在我的博客中,Philip Haack指出了這一重要遺漏。其他錯誤的共同源頭是Application.ThreadException事件。使用它們時有如下諸多告誡:
◆異常通知出現的太晚:當你收到通知時,你的應用程序已經不能對異常作出反應了。
◆異常如果發生在主線程(事實上,是任何由無管理代碼啓動的線程)中,應用程序將會結束。
◆很難編寫可以不間斷工作的普通代碼。引用MSDN的一段話:“這個事件僅僅發生在應用程序啓動時由系統創建的應用程序領域。如果應用程序創建額外的應用程序領域,在哪些應用程序領域中爲這一事件指定代表也是沒有作用的。”
◆當代碼處理這些事件時,除了異常本身你沒有權力使用任何有用信息。你不能關閉數據連接,回滾事務,或其他有用的事情。對初學者來說,使用全局變量的誘惑是巨大的。
確實,你不應該把你全部的異常處理策略放在這些事件的基礎上。想象他們是“安全網“的同時爲未來的測試記錄異常。之後,確保更正那些沒有正確處理異常的代碼。
不要重新創造輪子
有許多很好的框架和庫來處理異常。其中的兩個是微軟提供的,我在這兒介紹以下:
◆異常管理應用模塊
◆微軟企業使用框架
儘管如此,值得注意的是如果你沒有嚴格的按我所說的原則設計,上述的庫則幾乎是沒用的。
VB.NET
如果你通讀了本篇文章,你將會注意到我在此處書寫的所有例子都是C#的。這是因爲C#是我首選的語言,而VB.NET本身只有幾個指導方針。

仿效C#的“using“陳述
不幸的是,VB.NET仍然沒有using陳述。Whidbey擁有,但是直到它被釋放。當你需要處理一個對象的時候,你應該使用如下樣式:
你調用Dispose時,如果你做一些其他事,可能你就在一些錯事,直接導致你的代碼出錯或是資源泄漏。

Dim sw As StreamWriter = Nothing
Try
        sw = New StreamWriter("C:\crivo.txt")
        ' Do something with sw
Finally
        If Not sw is Nothing Then
                sw.Dispose()
        End if
End Finally

不要使用無結構錯誤處理機制
無結構錯誤處理機制同時也被認爲是On Error Goto。 1974年,Djikstra教授在撰寫“Go To statement considered harmful”時,非常擅長於此。不過那已是三十年前的事了!請儘快從你的應用程序中移除所有無結構錯誤處理的痕跡。我敢保證On Error Goto語句會對你不利。
結論
我希望這篇文章可以幫助某些人更好的編碼。不僅僅是一些總結出來的經驗,我希望這篇文章是討論如何在我們的代碼中使用異常,如何使我們的程序更健壯的起跑點。
我不認爲我所寫的這些沒有任何錯誤和有爭議的觀點。非常樂意聽到您關於此話題的意見和建議。
關於作者
  
Daniel Turini:十一歲時開始開發軟件。在過去的二十年中,他開發軟件的同時使用了各種不同的機器和語言,從基於(ZX81,MSX)Z80到大型計算機。他仍然有研究ASM的激情,雖然從沒有使用過它。從專業角度講,Daniel Turini開發系統來管理大型數據庫,這些數據庫主要有Sybase 和SQL Server。他所寫的大部分方法是面向金融市場,集中於信貸系統。
迄今爲止,Daniel Turini已經學習了大約20種計算機語言。他十分迷戀C#語言和.NET框架,他非常善於做服務器端工作和可重複利用的組件。
 
 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章