.Net 異常最佳做法

初夏的陽光丶發表於2020-08-06

異常資訊原因

異常是易於濫用的那些構造之一。這可能包括不應該在應有的情況下引發異常或在沒有充分理由的情況下捕獲異常。還有一個引發錯誤異常的問題,它不僅無助於我們,而且會使我們困惑。另一方面,存在正確處理異常的問題。如果使用不當,異常處理會變得更糟。所以,在本文中,我將簡單介紹一些有關引發和處理異常的最佳實踐。展示如何丟擲適當的異常可以為我們節省很多除錯方面的麻煩。我還將討論當我們想要查詢錯誤時不良的異常處理如何引起誤導。

丟擲異常

何時丟擲異常

在很多情況下,丟擲異常是有意義的,在這裡,我將對其進行描述並討論為什麼丟擲它們是一個好主意。請注意,本文中的許多示例都經過簡化以證明這一點。例如,沒有使用一種方法來檢索陣列元素。或者在某些情況下,我沒有使用以前提倡的技術來關注當前觀點。因此,自然而然地,示例並不試圖在所有方面都成為異常處理的理想形式,因為這樣會引入額外的元素,從而可能使讀者分心。

1:不可能完成過程並給出結果(快速失敗)

static void FindWinner(int[] winners)
{
    if (winners == null)
    {
        throw new System.ArgumentNullException($"引數 {nameof(winners)} 不能為空", nameof(winners));
    }
    
    OtherMethodThatUsesTheArray(winner);
}

假設我們有上述方法,在這裡我們丟擲一個異常,因為從這種方法中獲得沒有贏家陣列的結果是不可能的。另一個要點是該方法的使用者易於使用。想象一下,我們沒有引發異常,而是將陣列傳遞給OtherMethodThatUsesTheArray method,而該方法引發了NullReferenceException。通過不丟擲異常,除錯變得更加困難。因為此程式碼的偵錯程式必須首先檢視OtherMethodThatUsesTheArray方法,因為這就是錯誤的來源。然後,他找出贏家的論點是產生此錯誤的地方。當我們丟擲異常時,我們確切地知道錯誤的根源,而不必在程式碼庫中追逐錯誤。另一個問題是不必要的資源使用,假設在達到框架發生異常之前,我們進行了一些昂貴的處理。現在,如果該方法在沒有相關引數或您所沒有的情況下無法提供我們的結果,則實際上浪費了很多資源,而實際上該方法最初可能無法成功。請記住,當可能發生錯誤時,我們不會丟擲異常,但是當錯誤阻止流程時,我們會丟擲異常。有時我們也可以避免使用異常,而使用try-stuff模式int.TryParse並且不丟擲異常。

2:給定物件的當前狀態,呼叫物件的成員可能會產生無效的結果,或者可能會完全失敗

void WriteLog(FileStream logFile)
{
    if (!logFile.CanWrite)
    {
        throw new System.InvalidOperationException("日誌檔案不能是隻讀的");
    }
    // Else write data to the log and return.
}

在這裡,傳遞給WriteLog方法的檔案流以不可寫的方式進行建立。在這種情況下,我們知道此方法將不起作用。因為我們無法登入到不可寫的檔案。另一個例子是當我們有一個類,我們希望在收到它時處於特定狀態。通過丟擲異常並節省資源和除錯時間,我們又一次快速失敗。

捕獲通用的非特定異常並引發更特定的異常


void WriteLog(FileStream logFile)
{
    if (!logFile.CanWrite)
    {
        throw new System.InvalidOperationException("日誌檔案不能是隻讀的");
    }
    // Else write data to the log and return.
}

關於異常,有一個經驗法則,程式產生的異常越具體,除錯和維護就越容易。換句話說,通過這樣做,我們的程式會產生更準確的錯誤。因此,我們應始終努力盡可能地丟擲更具體的異常。這就是為什麼丟擲異常喜歡System.Exception,System.SystemException,System.NullReferenceException,或者System.IndexOutOfRangeException是不是一個好主意。最好不要使用它們,並且將它們看作是錯誤訊息是由框架生成的訊號,我們將花費大量時間進行除錯。在上面的程式碼中,您看到我們抓住IndexOutOfRangeException並丟擲了一個新的ArgumentOutOfRangeException向我們顯示了實際錯誤的來源。我們還可以使用它來捕獲框架生成的異常並引發新的自定義異常。這使我們可以新增其他資訊,或者可能以不同的方式進行處理。只要確保將原始異常作為內部異常傳遞到自定義異常中,否則stacktrace將丟失。

4:例外情況引發異常

這聽起來似乎很明顯,但有時可能會很棘手。我們的程式中有某些事情會發生,我們不能將它們視為錯誤。因此,我們不會丟擲異常。例如,搜尋查詢可能返回空,或者使用者登入嘗試可能失敗。在這種情況下,最好返回某種有意義的訊息,然後丟擲異常。正如史蒂夫·麥康奈爾(Steve McConnell)在《程式碼完整》一書中所說的那樣,“例外應該保留給真正的例外 ”,而不是期望的例外。

不要使用異常來更改程式的流程

以下面的程式碼為例。

[HttpPost]
public ViewResult CreateProduct(CreateProductViewModel viewModel)
{
    try
    {
        ValidateProductViewModel(viewModel);
        CreateProduct(viewModel);
    }
    catch (ValidationException ex)
    {
        return View(viewModel);
    }
}

我在一些需要處理的舊程式碼中看到了這種模式。如您所見,ValidateProductViewModel 如果檢視模型無效,則由於某種原因該方法將引發異常。然後,如果檢視模型無效,它將捕獲該模型並返回錯誤的檢視。我們最好將上面的程式碼更改為下面的程式碼

[HttpPost]
public ViewResult CreateProduct(CreateProduct viewModel)
{
    bool viewModelIsValid = ValidateProductViewModel(viewModel);
        
    if(!viewModelIsValid) return View(viewModel); 
        
    CreateProduct(viewModel);
    
     return View(viewModel); 
}

在這裡,負責驗證的方法在檢視模型無效的情況下返回布林值,而不是引發異常

不返回錯誤程式碼,而是引發異常

丟擲異常總是比返回錯誤程式碼更安全。原因是如果呼叫程式碼忘記檢查或返回錯誤程式碼並繼續執行該怎麼辦?但是,如果我們丟擲異常,那將不會發生。

確保清除丟擲異常的任何副作用

private static void MakeDeposit(Account account,decimal amount)
{
    try
    {
        account.Deposit(amount);
    }
    catch
    {
        account.RollbackDeposit(amount);
        throw;
    }
}

在這裡,我們知道呼叫deposit方法時可能會發生錯誤。我們應該確保如果發生異常,則對系統的任何更改都會回滾。

try
{
    DBConnection.Save();
}
catch
{
    // 回滾資料庫操作
    DBConnection.Rollback();

    // 重新丟擲異常,讓外界知道錯誤訊息
    throw;
}

您也可以使用事務作用域,而不用這種方式進行處理。請記住,您也可以在“最終阻止”中執行此操作。

如果您捕獲了一個異常並且無法正確處理它,請確保將其重新丟擲

在某些情況下,當我們捕獲到異常但我們不打算處理它時,也許我們只是想記錄它。就像是:

try
{
    conn.Close();
}
catch (InvalidOperationException ex)
{
    _logger.LogError(ex.Message, ex);
    
     //不好的做法,堆疊跟蹤丟失了
    //丟擲ex; 
    
    //優良作法,保持堆疊跟蹤
    丟擲;
    throw;
}

如您在上面的程式碼中看到的那樣,我們不僅應該重新丟擲異常,而且還應該以不丟失堆疊跟蹤的方式重新丟擲該異常。在這種情況下,如果使用throw ex,則會丟失堆疊跟蹤,但是如果使用前不帶instace ex的throw,則會保留堆疊跟蹤。

不要將異常用作引數或返回值

在大多數情況下,使用Exception作為引數或返回值沒有意義。也許只有當我們在異常工廠中使用它時,它才有意義。

Exception AnalyzeHttpError(int errorCode) {
    if (errorCode < 400) {
         throw new NotAnErrorException();
    }
    switch (errorCode) {
        case 403:
             return new ForbiddenException();
        case 404:
             return new NotFoundException();
        case 500:
             return new InternalServerErrorException();
        …
        default:
             throw new UnknownHttpErrorCodeException(errorCode);
     }
}

防止程式丟擲異常(如果可能),導致異常昂貴

try
{
    conn.Close();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.GetType().FullName);
    Console.WriteLine(ex.Message);
}

以上面的程式碼為例,我們將用於關閉連線的程式碼放在try塊中。如果連線已經關閉,它將引發異常。但是也許我們可以在不導致程式引發異常的情況下實現相同的目標?

if (conn.State != ConnectionState.Closed)
{
    conn.Close();
}

如您所見,try塊是不必要的,如果連線已經關閉,它將導致程式引發異常,並且異常的開銷比check開銷大。

建立自己的異常類

框架並未涵蓋所有可能發生的異常。有時我們需要建立自己的異常型別。可以將它們定義為類,就像C#中的任何其他類一樣。我們建立自定義異常類通常是因為我們想以不同的方式處理該異常。或那種特殊的異常對我們的應用程式非常關鍵。要建立自定義異常,我們需要建立一個派生自System.Exception的類。

[Serializable()]
public class InvalidDepartmentException : System.Exception
{
    public InvalidDepartmentException() : base() { }
    public InvalidDepartmentException(string message) : base(message) { }
    public InvalidDepartmentException(string message, System.Exception inner) : base(message, inner) { }

    // 當異常從遠端伺服器傳播到客戶端時,需要序列化建構函式
    protected InvalidDepartmentException(SerializationInfo info, StreamingContext context) { }
}

在這裡,派生類定義了四個建構函式。一種預設建構函式,一種用於設定message屬性,一種用於同時設定Message和InnerException。第四個建構函式用於序列化異常。另請注意,異常應可序列化。

處理(捕獲)異常

何時捕獲異常
捕獲異常比丟擲異常更容易被濫用。但是,當應用程式達到維護階段時,這種濫用會導致很多痛苦。在以下部分中,我將描述有關處理異常的一些最佳實踐。

1:當異常處理

我在許多應用程式中看到過,try塊用於抑制異常。但這不是try-catch塊的用途。通過這樣的嘗試,我們改變了系統的行為,使發現錯誤的難度超過了應有的程度。這種現象非常普遍,以至於我們對其濫用有一個術語,即Pokemon異常處理。大多數情況下,發生的事情是try-catch塊吞沒了錯誤,錯誤最終在我們應用程式中的其他位置而不是原始位置出現。真正的痛苦是,大多數時候錯誤訊息根本沒有任何意義,因為它不是原始錯誤的來源。這使得除錯體驗令人沮喪。

2:在實際可以處理異常並從異常中恢復時使用嘗試阻止

這種情況的一個很好的例子是,當程式提示使用者輸入檔案和檔案的路徑時,該路徑不存在。如果應用程式丟擲錯誤,我們可以從中恢復,方法可能是捕獲異常並要求使用者輸入另一個檔案路徑。因此,您應該將捕獲塊的順序從最具體的到最不具體的。

public void OpenFile(string filePath)
{
  try
  {
     File.Open(path);
  }
  catch (FileNotFoundException ex)
  {
     Console.WriteLine("找不到指定的檔案路徑,請輸入其他路徑");
     PromptUserForAnotherFilePath();
  }
}

您可以使用的另一件事是異常過濾器。異常過濾器的工作方式類似於catch塊的if語句。如果檢查結果為true,則執行catch塊,否則將忽略catch塊。

private static void Filter()
{
    try
    {
        A();
    }
    catch (OperationCanceledException exception) when (string.Equals(nameof(ExceptionFilter), exception.Message, StringComparison.Ordinal))
    {
    }
}

3:您想捕獲一個通用異常並丟擲一個具有更多上下文的更具體的異常

int GetInt(int[] array, int index)
{
    try
    {
        return array[index];
    }
    catch(System.IndexOutOfRangeException e)
    {
        throw new System.ArgumentOutOfRangeException("Parameter index is out of range.", e);
    }
}

以上面的程式碼為例。在這裡,我們捕獲IndexOutOfBound 異常並丟擲ArgumentOutOfRangeException。這樣,我們就可以更清楚地瞭解錯誤的來源,並且可以更快地找到問題的根源。

4:您想部分處理異常並將其傳遞給進一步處理

try
{
    // Open File
}
catch (FileNotFoundException e)
{
    _logger.LogError(e);
    // Re-throw the error.
    throw;     
}

在上面的示例中,我們捕獲了異常,記錄了所需的資訊,然後重新丟擲了異常。

僅在應用程式的最高層出現捕獲異常

在每個應用程式中,都有一點應該吞下異常。例如,大多數時候,在Web應用程式中,我們不希望使用者看到異常錯誤。我們希望向使用者展示一些通用資訊,並保留異常資訊以用於除錯。在這種情況下,我們可以將程式碼塊包裝在try-catch塊中。如果發生錯誤,我們將捕獲異常,並記錄錯誤,並將一些通用資訊返回給使用者,例如下面的程式碼。

[HttpPost]
public async Task<IActionResult> UpdatePost(PostViewModel viewModel)
{
    try
    {
         _mediator.Send(new UpdatePostCommand{ PostViewModel = viewModel});
         return View(viewModel);
    }
    catch (Exception e)
    {
        _logger.LogError(e);
       return View(viewModel);
    }
}

請注意,在這種情況下,我們仍然會記錄錯誤,但是錯誤不會使圖層冒泡。因為這是最後一層,所以實際上上面沒有任何層。換句話說,應該使用異常而不是重新丟擲異常的唯一程式碼應該是UI或公共API。一些開發人員傾向於配置某種全域性方式來處理在此層中發生的異常。您可以看一下我以前使用這種技術的帖子。當涉及到異常處理時,最重要的事情是異常處理永遠都不應隱藏問題。

最後

Final塊用於清除try塊中使用的任何剩餘資源,而無需等待執行時的垃圾收集器完成該物件。我們可以使用它來關閉資料庫連線,檔案流等。請注意,FileStream 在呼叫close之前,我們首先檢查object是否為null。不這樣做,finally塊可能會丟擲自己的異常,這根本不是一件好事。

FileStream file = null;
var fileinfo = new FileInfo("C:\\file.txt");
try
{
    file = fileinfo.OpenWrite();
    file.WriteByte(0xF);
}
finally
{
    // Check for null because OpenWrite might have failed.
    if (file != null)
    {
        file.Close();
    }
}

我們可以在有或沒有catch塊的情況下使用它,重要的是,無論是否發生異常,總是總是要執行塊。另一個重要的一點是,由於資料庫連線之類的資源非常昂貴,因此最好儘快在finally塊中關閉它們,而不是等待垃圾收集器為我們完成它。using由於FileStream is正在實施,因此我們也可以使用該語句IDisposable。值得一提的是,using語句只是一種語法糖,可以轉換為嘗試並最終阻止,並且更具可讀性,並且總體而言是更好的選擇。

如有哪裡講得不是很明白或是有錯誤,歡迎指正
如您喜歡的話不妨點個贊收藏一下吧

相關文章