以通知取代驗證中的例外拋出

如果您正在驗證某些資料,通常不應該使用例外來表示驗證失敗。在此,我將說明如何將此類程式碼重構成使用通知模式。

2014 年 12 月 9 日



我最近查看了一些程式碼,用於對一些輸入的 JSON 訊息進行一些基本驗證。它看起來像這樣。

public void check() {
   if (date == null) throw new IllegalArgumentException("date is missing");
   LocalDate parsedDate;
   try {
     parsedDate = LocalDate.parse(date);
   }
   catch (DateTimeParseException e) {
     throw new IllegalArgumentException("Invalid format for date", e);
   }
   if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
   if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
   if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive");
 }

此範例的程式碼為 Java

這是進行驗證的常見方式。您對某些資料執行一系列檢查(這裡僅為相關類別中的一些欄位)。如果其中任何一個檢查失敗,您就會擲回一個包含錯誤訊息的例外。

對於這種方法,我有一些問題。首先,我不滿意使用例外來處理類似的事情。例外表示超出相關程式碼預期行為範圍之外的事情。但是,如果您對外部輸入執行一些檢查,這是因為您預期某些訊息會失敗 - 如果失敗是預期的行為,那麼您就不應該使用例外。

如果失敗是預期的行為,那麼您就不應該使用例外

像這樣的程式碼的第二個問題是,它會在偵測到第一個錯誤時失敗,但通常最好回報輸入資料中的所有錯誤,而不仅仅是第一個。這樣一來,客戶端就可以選擇顯示所有錯誤,讓使用者一次修正,而不是讓她覺得自己正在和電腦玩打地鼠遊戲。

我處理像這樣回報驗證問題的首選方式是通知模式。通知是一個收集錯誤的物件,每次驗證失敗都會將一個錯誤新增到通知中。驗證方法會傳回一個通知,然後你可以詢問它以取得更多資訊。以下程式碼顯示了一個檢查的簡單用法。

private void validateNumberOfSeats(Notification note) {
  if (numberOfSeats < 1) note.addError("number of seats must be positive");
  // more checks like this
}

然後,我們可以執行一個簡單的呼叫,例如 aNotification.hasErrors(),以在有任何錯誤時做出反應。通知中的其他方法可以深入探討錯誤的更多詳細資訊。[1]

何時使用此重構

我必須在此強調,我並非主張在整個程式碼庫中擺脫例外。例外是一種非常有用的技術,可以用來處理例外行為並讓它遠離邏輯主流程。只有當例外所發出的結果並非真正例外時,才建議使用這種重構,因此應該透過程式的邏輯主流程來處理。我這裡看到的範例,驗證,就是一個常見的案例。

在考慮例外時,一個有用的經驗法則來自實用程式設計師

我們相信例外很少應該用作程式正常流程的一部分:例外應該保留給意外事件。假設一個未捕捉的例外會終止你的程式,並問問自己:「如果我移除所有例外處理常式,這個程式碼還會執行嗎?」如果答案是「不會」,那麼例外可能用於非例外情況。

-- 戴夫·湯瑪斯和安迪·杭特

這項重要的後果是,是否針對特定任務使用例外取決於背景。因此,正如實用主義者所說,在 Unix 系統上從不存在的檔案中讀取資料,是否為例外取決於情況。如果你嘗試讀取一個眾所周知的檔案位置,例如 /etc/hosts,那麼你很可能會假設檔案應該存在,因此擲出例外是合理的。另一方面,如果你嘗試從使用者在命令列中輸入的路徑讀取檔案,那麼你應該預期檔案可能不存在,並且應該使用另一種機制,一種傳達錯誤的非例外性質的機制。

在驗證失敗時使用例外情況可能是明智的。這會發生在您有預期在處理過程中已驗證的資料,但您想要再次執行驗證檢查以防範程式設計錯誤讓某些無效資料溜過的情況。

本文探討在驗證原始輸入的背景下用通知取代例外情況。您也可能在其他情況中發現此技術很有用,在這些情況中,通知會比擲回例外情況來得更好,但我在此專注於驗證案例,因為這是一個常見的案例。

起點

到目前為止,我尚未提到範例網域,因為我只對程式碼的廣泛架構感興趣。但隨著我們進一步探討範例,我需要與網域互動。在這個案例中,這是一些接收 JSON 訊息來預訂戲院座位的程式碼。此程式碼位於預訂要求類別中,並使用 gson 函式庫從 JSON 填入資料。

gson.fromJson(jsonString, BookingRequest.class)

Gson 會採用一個類別,尋找與 JSON 文件中的金鑰相符的任何欄位,然後填入相符的欄位。

預訂要求僅包含我們在此驗證的兩個元素,即演出日期和要求的座位數

類別 BookingRequest…

  private Integer numberOfSeats; 
  private String date;

驗證檢查是我上面顯示的那些

類別 BookingRequest…

  public void check() {
     if (date == null) throw new IllegalArgumentException("date is missing");
     LocalDate parsedDate;
     try {
       parsedDate = LocalDate.parse(date);
     }
     catch (DateTimeParseException e) {
       throw new IllegalArgumentException("Invalid format for date", e);
     }
     if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
     if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
     if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive");
   }

建立通知

為了使用通知,您必須建立通知物件。通知可以非常簡單,有時只用一串字串就能搞定。

通知會收集錯誤

List<String> notification = new ArrayList<>();
if (numberOfSeats < 5) notification.add("number of seats too small");
// do some more checks

// then later…
if ( ! notification.isEmpty()) // handle the error condition

雖然簡單的串列慣用語會產生模式的輕量級實作,但我通常喜歡做更多的事情,改為建立一個簡單的類別。

public class Notification {
  private List<String> errors = new ArrayList<>();

  public void addError(String message) { errors.add(message); }
  public boolean hasErrors() {
    return ! errors.isEmpty();
  }
  …

透過使用真正的類別,我可以讓我的意圖更清楚 - 讀者不必在慣用語和它的完整意義之間進行心智對應。

拆分檢查方法

我的第一步是將檢查方法拆分為兩個部分,一個內部部分最終只會處理通知且不會擲回任何例外情況,以及一個外部部分,它會保留檢查方法的目前行為,也就是在有任何驗證失敗時擲回例外情況。

我執行此操作的第一步是使用Extract Method,以不尋常的方式將檢查方法的整個主體萃取到驗證方法中。

類別 BookingRequest…

  public void check() {
    validation();
  }

  public void validation() {
    if (date == null) throw new IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      throw new IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
    if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
    if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive");
  }

然後我調整驗證方法以建立並傳回通知。

類別 BookingRequest…

  public Notification validation() {
    Notification note = new Notification();
    if (date == null) throw new IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      throw new IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
    if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
    if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive");
    return note;
  }

現在我可以測試通知,如果它包含錯誤,就擲回例外情況。

類別 BookingRequest…

  public void check() {
    if (validation().hasErrors()) 
      throw new IllegalArgumentException(validation().errorMessage());
  }

我讓驗證方法公開,因為我預期未來大多數呼叫者會偏好使用此方法,而不是檢查方法。

拆分原始方法讓我可以將驗證檢查與如何回應失敗的決定分開。

在這個時間點,我完全沒有變更程式碼的行為,通知不會包含任何錯誤,而任何失敗的驗證檢查會繼續擲回例外情況,並忽略我放入的新機制。但我現在已經準備好開始用處理通知取代例外情況擲回。

不過,在我繼續之前,我需要說明一下錯誤訊息。當我們進行重構時,規則是避免可觀察行為的變更。在這種情況下,此規則會立即引發一個問題,即什麼行為是可觀察的?顯然,拋出正確的例外狀況是外部程式會觀察到的,但他們在多大程度上關心錯誤訊息?通知最終會收集多個錯誤,並可以將它們總結成一個訊息,如下所示

類別通知…

  public String errorMessage() {
    return errors.stream()
      .collect(Collectors.joining(", "));
  }

但是,如果程式的高階層依賴於僅從偵測到的第一個錯誤取得訊息,這將會是一個問題,在這種情況下,您需要類似以下的內容

類別通知…

  public String errorMessage() { return errors.get(0); }

您不僅必須查看呼叫函式,還必須查看任何例外狀況處理常式,才能找出這種情況下的正確回應。

雖然我應該沒有在這個時候引入任何問題,但我肯定會在進行下一次變更之前編譯並測試。僅僅因為沒有任何明智的人會搞砸這些變更,並不表示我不會搞砸它。

驗證數字

現在要做的事情很明顯,就是取代第一個驗證

類別 BookingRequest…

  public Notification validation() {
    Notification note = new Notification();
    if (date == null) note.addError("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      throw new IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
    if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
    if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive");
    return note;
  }

一個明顯的舉動,但卻是一個壞舉動,因為這會中斷程式碼。如果我們將空日期傳遞到函式中,它會將錯誤新增到通知中,但隨後會愉快地嘗試解析它並拋出空指標例外狀況,這不是我們正在尋找的例外狀況。

因此,在這種情況下,不顯而易見但更有效的方法是倒退。

類別 BookingRequest…

  public Notification validation() {
    Notification note = new Notification();
    if (date == null) throw new IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      throw new IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
    if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
    if (numberOfSeats < 1) note.addError("number of seats must be positive");
    return note;
  }

先前的檢查是空檢查,因此我們需要使用條件式來避免建立空指標例外狀況。

類別 BookingRequest…

  public Notification validation() {
    Notification note = new Notification();
    if (date == null) throw new IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      throw new IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
    if (numberOfSeats == null) note.addError("number of seats cannot be null");
    else if (numberOfSeats < 1) note.addError("number of seats must be positive");
    return note;
  }

我看到下一個檢查涉及不同的欄位。連同必須在先前的重構中引入條件式,現在我認為這個驗證方法變得太複雜了,可以分解。因此,我提取數字驗證部分。

類別 BookingRequest…

  public Notification validation() {
    Notification note = new Notification();
    if (date == null) throw new IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      throw new IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
    validateNumberOfSeats(note);
    return note;
  }

  private void validateNumberOfSeats(Notification note) {
    if (numberOfSeats == null) note.addError("number of seats cannot be null");
    else if (numberOfSeats < 1) note.addError("number of seats must be positive");
  }

查看數字的提取驗證,我並不喜歡它的結構。我不喜歡在驗證中使用 if-then-else 區塊,因為它很容易導致過度巢狀的程式碼。我比較喜歡線性程式碼,一旦無法繼續執行,就會中止,我們可以使用防護子句來執行此操作。因此,我套用 將巢狀條件式替換為防護子句

類別 BookingRequest…

  private void validateNumberOfSeats(Notification note) {
    if (numberOfSeats == null) {
      note.addError("number of seats cannot be null");
      return;
    }
    if (numberOfSeats < 1) note.addError("number of seats must be positive");
  }

當我們重構時,我們應該總是嘗試採取最小的步驟,以保留行為

我決定回頭保持程式碼綠色,這是重構中一個關鍵元素的範例。重構是一種透過一系列保留行為的轉換來重組程式碼的特定技術。因此,當我們重構時,我們應始終嘗試採取最小的步驟來保留行為。透過這麼做,我們可以減少會讓我們陷入除錯器的錯誤機會

驗證日期

對於日期驗證,我想從 Extract Method 開始

類別 BookingRequest…

  public Notification validation() {
    Notification note = new Notification();
    validateDate(note);
    validateNumberOfSeats(note);
    return note;
  }

  private void validateDate(Notification note) {
    if (date == null) throw new IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      throw new IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
  }

當我在 IDE 中使用自動化 Extract Method 時,產生的程式碼沒有包含通知引數。因此,我必須手動新增它。

現在是開始回滾日期驗證的時候了

類別 BookingRequest…

  private void validateDate(Notification note) {
    if (date == null) throw new IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      throw new IllegalArgumentException("Invalid format for date", e);
    }
    if (parsedDate.isBefore(LocalDate.now())) note.addError("date cannot be before today");
  }

在第二個步驟中,錯誤處理會出現複雜情況,因為拋出的例外狀況包含原因例外狀況。為了處理這個問題,我需要變更通知以接受原因例外狀況。由於我正在將拋出變更為將錯誤新增到通知中,因此我的程式碼是紅色的,所以我取消正在執行的動作,將 validateDate 方法保留在上面的狀態,同時準備通知以包含原因例外狀況。

我透過新增一個新的 addError 方法來修改通知,該方法採用原因,並調整原始方法以呼叫新的方法。 [2]

類別通知…

  public void addError(String message) {
    addError(message, null);
  }

  public void addError(String message, Exception e) {
    errors.add(message);
  }

這表示我們接受原因例外狀況,但忽略它。為了把它放在某個地方,我需要將錯誤記錄從一個簡單字串變更為一個稍微不那麼簡單的物件。

類別通知…

  private static class Error {
    String message;
    Exception cause;

    private Error(String message, Exception cause) {
      this.message = message;
      this.cause = cause;
    }
  }

我通常不喜歡 Java 中的非私有欄位,但由於這是一個私有內部類別,所以我沒問題。如果我要在通知外部公開這個錯誤類別,我會封裝這些欄位。

現在我有這個類別,我需要修改通知以使用它,而不是字串。

類別通知…

  private List<Error> errors = new ArrayList<>();

  public void addError(String message, Exception e) {
    errors.add(new Error(message, e));
  }
  public String errorMessage() {
    return errors.stream()
            .map(e -> e.message)
            .collect(Collectors.joining(", "));
  }

有了新的通知,我現在可以對預訂要求進行變更

類別 BookingRequest…

  private void validateDate(Notification note) {
    if (date == null) throw new IllegalArgumentException("date is missing");
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      note.addError("Invalid format for date", e);
      return;
    }
    if (parsedDate.isBefore(LocalDate.now())) note.addError("date cannot be before today");

由於我已經在一個提取的方法中,因此可以透過回傳來中止其餘的驗證。

最後一個變更很簡單

類別 BookingRequest…

  private void validateDate(Notification note) {
    if (date == null) {
      note.addError("date is missing");
      return;
    }
    LocalDate parsedDate;
    try {
      parsedDate = LocalDate.parse(date);
    }
    catch (DateTimeParseException e) {
      note.addError("Invalid format for date", e);
      return;
    }
    if (parsedDate.isBefore(LocalDate.now())) note.addError("date cannot be before today");
  }

向上移動堆疊

一旦我們有了新的方法,下一個任務就是查看原始檢查方法的呼叫者,並考慮調整它們以改用新的驗證方法。這將需要更廣泛地了解驗證如何融入應用程式的流程,因此它超出了這次重構的範圍。但是,中期目標應該是消除在我們可能預期驗證失敗的任何情況下使用例外狀況。

在許多情況下,這應該可以完全擺脫檢查方法。在這種情況下,應重新製作該方法上的任何測試以使用驗證方法。我們可能還希望調整測試,以使用通知來探查是否正確收集多個錯誤。


腳註

1: 另一種常見的驗證方法是僅傳回一個布林值,表示輸入是否有效。雖然這讓呼叫者可以輕鬆呼叫不同的行為,但它沒有提供任何方法來提供無用的「發生錯誤」以外的診斷。

2: 這有時稱為鏈式建構函式。您也可以將其視為部分應用程式的範例 - 而不是函式程式設計師會容忍在 Java 程式語言的貧民窟中使用此類術語。

進一步閱讀

關於何時使用例外狀況,已經有許多文章寫過。正如您可能猜到的,我建議進一步閱讀的第一個建議是 實用程式設計師。在 完整程式碼 中也有深入的討論。這兩本書任何專業程式設計師都應該很熟悉。

我也很喜歡 Avdi Grimm 在 Exceptional Ruby 中討論如何處理錯誤狀況。儘管這是一本 Ruby 書籍,但它的建議大多適用於任何程式設計環境。

架構

許多架構提供某種使用通知模式的驗證功能。在 Java 世界中,有 Java Bean 驗證 工作和 Spring 的驗證。這些架構提供某種介面來啟動驗證,並使用通知來收集錯誤(bean 驗證的 Set<ConstraintViolation> 和 Spring 的 Errors)。

您應該查看您的語言和平台,看看它們有什麼使用通知的驗證機制。此機制的運作方式的詳細資訊將改變重構的詳細資訊,但一般形狀應該非常相似。

致謝

Andy Slocum、Carlos Villela、Charles Haynes、Dave Elliman、Derek Hammer、Ian Cartwright、Ken McCormack、Kornelis Sietsma、Rob Speller、Stefan Smith 和 Steven Lowe 在我們的內部郵寄清單上對這篇文章的草稿發表評論。

重大修訂

2014 年 12 月 9 日:首次發布