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

周見智的部落格發表於2015-01-29

目錄

  • 介紹
  • 做最壞的打算
    • 提前檢查
    • 不要信任外部資料
    • 可信任的裝置:攝像頭、滑鼠以及鍵盤
    •  “寫操作”同樣可能失效
  • 安全程式設計
    • 不要丟擲“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在另一個程式集中。在開發的機器上執行正常,但是在測試機器上卻總是丟擲“資料不合法!”的異常,儘管每次輸入的資料都是合法的。

你們能說說這是為什麼嗎?

public class MyClass
{
    public static string ValidateNumber(string userInput)
    {
        try
        {
            int val = GenericLibrary.ConvertToInt(userInput);
            return "Valid number";
        }
        catch (Exception)
        {
            return "Invalid number";
        }
    }
}

public class GenericLibrary
{
    public static int ConvertToInt(string userInput)
    {
        return Convert.ToInt32(userInput);
    }
}

這個問題的原因就是異常處理不太具體。根據MSDN上的介紹,Convert.ToInt32方法僅僅會丟擲ArgumentException、FormatException以及OverflowException三個異常。所以,我們應該僅僅處理這三個異常。

問題發生在我們程式安裝的步驟上,我們沒有將第二個程式集(GenericLibrary.dll)打包進去。所以程式執行後,ConvertToInt方法會丟擲FileNotFoundException異常,但是我們捕獲的異常是Exception,所以會提示“資料不合法”。

不要中止異常上拋

最壞的情況是,你編寫catch(Exception)這樣的程式碼,並且在catch塊中啥也不幹。請不要這樣做。

清理程式碼要放在finally塊中

大多數時候,我們只處理某一些特定的異常,其它異常不負責處理。那麼我們的程式碼中就應該多一些finally塊(就算髮生了不處理的異常,也可以在finally塊中做一些事情,譯者注),比如清理資源的程式碼、關閉流或者回復狀態等。請把這當作習慣。

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

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

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關鍵字也能夠防止資源洩漏。

不要使用特殊返回值去表示方法中發生的異常

因為這樣做有很多問題:

1)直接丟擲異常更快,因為使用特殊的返回值表示異常時,我們每次呼叫完方法時,都需要去檢查返回結果,並且這至少要多佔用一個暫存器。降低程式碼執行速度。

2)特殊返回值能,並且很可能被忽略

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

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

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

不要使用“丟擲異常”的方式去表示資源不存在

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

我看到了.NET Framework中很多獲取資源的API方法使用了特殊返回值,比如Assembly.GetManifestStream方法,當找不到資源時(異常),它會返回null(不會丟擲異常)。

不要將“丟擲異常”作為函式執行結果的一種

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

可以使用“丟擲異常”的方式去著重說明不能被忽略的錯誤

我可以舉個現實中的例子。我為我的Grivo(我的一個產品)開發了一個用來登入的API(Login),如果使用者登入失敗,或者使用者並沒有呼叫Login方法,那麼他們呼叫其他方法時都會失敗。我在設計Login方法的時候這樣做的:如果使用者登入失敗,它會丟擲一個異常,而並不是簡單的返回false。正因為這樣,呼叫者(使用者)才不會忽略(他還沒登入)這個事實。

不要清空了堆疊跟蹤(stack trace)資訊

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

錯誤的做法:

try
{
    // Some code that throws an exception
}
catch (Exception ex)
{
    // some code that handles the exception
    throw ex;
}

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

try
{
    // Some code that throws an exception
}
catch (Exception ex)
{
    // some code that handles the exception
    throw;
}

有什麼變化沒?我們使用“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語句。你每次在釋放一個物件的非託管資源時,不得不這樣去做:

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

如果你不按照上面那種方式呼叫DIspose方法的話,很可能會出現錯誤(有關Dispose方法的呼叫,請關注新書。譯者注)。

不要使用非結構化異常處理(On Error Goto)

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

總結

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

相關文章