検証における例外のスローを通知に置き換える

データの検証を行う場合、通常は検証の失敗を通知するために例外を使用すべきではありません。ここでは、そのようなコードを通知パターンを使用するようにリファクタリングする方法について説明します。

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です

これは、検証に対する一般的なアプローチです。いくつかのデータ(ここでは問題のクラス内のいくつかのフィールド)に対して一連のチェックを実行します。これらのチェックのいずれかが失敗した場合、エラーメッセージとともに例外をスローします。

このアプローチには、いくつかの問題があります。まず、このような場合に例外を使用することに満足していません。例外は、問題のコードの予期される動作範囲外のものを通知します。しかし、外部入力に対していくつかのチェックを実行している場合、これは一部のメッセージが失敗することを予想しているためです。そして、失敗が予想される動作である場合、例外を使用すべきではありません。

失敗が予想される動作である場合、例外を使用すべきではありません

このようなコードの2番目の問題は、最初に検出したエラーで失敗することですが、通常は最初のエラーだけでなく、受信したすべてのエラーを報告する方が良いです。そうすることで、クライアントは、ユーザーがコンピュータとモグラたたきゲームをしているような印象を与えるのではなく、1回のインタラクションですべてのエラーを表示して修正することを選択できます。

このような検証の問題を報告するのに最適な方法は、通知パターンです。通知とは、エラーを収集するオブジェクトであり、検証の失敗ごとにエラーが通知に追加されます。検証メソッドは通知を返し、それを調べて詳細情報を取得できます。簡単な使用例は、チェックのためにこのようなコードを使用します。

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

その後、aNotification.hasErrors()のような簡単な呼び出しで、エラーがある場合に反応できます。通知の他のメソッドでは、エラーに関する詳細を調べることができます。[1]

このリファクタリングを適用するタイミング

ここで強調しておきたいのは、コードベース全体から例外を削除することを提唱しているわけではないということです。例外は、例外的な動作を処理し、ロジックのメインフローから分離するための非常に便利な手法です。このリファクタリングは、例外によって通知される結果が実際には例外的ではなく、プログラムのメインロジックを通じて処理する必要がある場合にのみ使用するのに適しています。ここで見ている例である検証は、その一般的なケースです。

例外を検討する際に役立つ経験則は、Pragmatic Programmersによるものです。

私たちは、例外はプログラムの通常のフローの一部としてめったに使用すべきではないと考えています。例外は予期しないイベントのために予約されるべきです。キャッチされない例外がプログラムを終了すると仮定し、「すべての例外ハンドラーを削除した場合、このコードは引き続き実行されますか?」と自問してください。答えが「いいえ」の場合、例外は例外的な状況以外で使用されている可能性があります。

-- Dave Thomas and Andy Hunt

この重要な帰結は、特定のタスクに例外を使用するかどうかはコンテキストに依存するということです。したがって、プラグが言うように、存在しないファイルを読み取ることは、状況によっては例外である場合とそうでない場合があります。unixシステムの/etc/hostsなど、よく知られたファイル場所を読み取ろうとしている場合は、ファイルが存在すると想定できる可能性が高いため、例外をスローするのが合理的です。一方、ユーザーがコマンドラインで入力したパスからファイルを読み取ろうとしている場合は、ファイルが存在しない可能性が高いと予想する必要があり、エラーの非例外的な性質を伝達する別のメカニズムを使用する必要があります。

検証の失敗に例外を使用することが賢明な場合があります。これは、処理の初期段階で既に検証されていると予想されるデータがあるが、プログラミングエラーによって無効なデータが紛れ込むのを防ぐために、検証チェックを再度実行したい状況です。

この記事は、生の入力を検証するコンテキストで、例外を通知に置き換えることについて説明します。通知が例外をスローするよりも適切な選択である他の状況でも、この手法が役立つ可能性がありますが、ここでは検証ケースに焦点を当てます。これは一般的なケースであるためです。

開始点

これまでは、コードの全体的な形状にのみ関心があったため、例のドメインについては言及していません。しかし、例をさらに詳しく調べていくと、ドメインを理解する必要があります。この場合、劇場で座席を予約するJSONメッセージを受信するコードです。コードは、gsonライブラリを使用してJSONから入力される予約リクエストクラスにあります。

gson.fromJson(jsonString, BookingRequest.class)

Gsonはクラスを受け取り、JSONドキュメント内のキーに一致するフィールドを探し、一致するフィールドを入力します。

予約リクエストには、ここで検証する2つの要素、パフォーマンスの日付と要求されている座席数のみが含まれています。

クラス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();
  }
  …

実際のクラスを使用することで、意図をより明確にすることができます。読者は、イディオムとその完全な意味との間のメンタルマップを実行する必要がありません。

checkメソッドの分割

私の最初のステップは、checkメソッドを2つの部分に分割することです。1つは最終的に通知のみを処理し、例外をスローしない内側の部分と、checkメソッドの現在の動作(検証の失敗がある場合に例外をスローする)を保持する外側の部分です。

これを行うための最初のステップは、checkメソッドの本体全体を検証メソッドに抽出するという、メソッドの抽出を珍しい方法で使用することです。

クラス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にしました。これは、将来のほとんどの呼び出し元がcheckメソッドよりもこのメソッドを使用することを好むと予想しているためです。

元のメソッドを分割することで、検証チェックを失敗への対応方法の決定から分離できます。

この時点では、コードの動作はまったく変更されていません。通知にはエラーが含まれておらず、失敗した検証チェックは引き続き例外をスローし、導入した新しいメカニズムは無視されます。しかし、例外のスローを通知の操作に置き換える準備が整いました。

ただし、その前に、エラーメッセージについて説明する必要があります。リファクタリングを行う場合、ルールは観察可能な動作の変化を避けることです。このような状況では、このようなルールは、どの動作が観察可能かという問題にすぐにつながります。明らかに、正しい例外のスローは外部プログラムが観察するものですが、エラーメッセージについてどの程度気にするでしょうか?通知は最終的に複数のエラーを収集し、次のようなものでそれらを単一のメッセージにまとめることができます。

クラスNotification…

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

しかし、プログラムのより高いレベルが最初に検出されたエラーからのメッセージのみを取得することに依存していた場合、それは問題になります。その場合は、次のようなものが必要になります。

クラスNotification…

  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;
  }

明らかな動きですが、これはコードを壊すため、悪い動きです。nullの日付を関数に渡すと、通知にエラーが追加されますが、それを嬉々として解析しようとしてnullポインター例外がスローされます。これは私たちが探していた例外ではありません。

したがって、非自明ですが、この場合に効果的なことは、逆に戻ることです。

クラス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;
  }

前のチェックはnullチェックであるため、nullポインター例外を作成しないように条件文を使用する必要があります。

クラス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");
  }

リファクタリングを行う際は、常に動作を保持する最小限のステップを踏むように努める必要があります

コードをグリーンに保つために後退する私の決定は、リファクタリングの重要な要素の例です。リファクタリングとは、動作を保持する一連の変換を通してコードを再構成する特定のテクニックです。したがって、リファクタリングを行う際は、常に動作を保持する最小限のステップを踏むように努めるべきです。これにより、デバッガーに閉じ込められるようなエラーの可能性を減らすことができます。

日付の検証

日付の検証については、まずメソッド抽出から始めようと思います。

クラス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で自動メソッド抽出を使用すると、結果として得られたコードに通知引数が含まれていませんでした。そのため、手動で追加する必要がありました。

さて、日付の検証を遡って開始する時間です。

クラス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");
  }

2番目のステップでは、スローされた例外に原因例外が含まれているため、エラー処理が複雑になります。それを処理するには、原因例外を受け入れるように通知を変更する必要があります。通知にエラーを追加するためにスローを変更する途中なので、コードが赤色になっています。したがって、原因例外を含めるために通知を準備している間、validateDateメソッドを上記の状態のままにするために、行っていることを元に戻します。

まず、原因を受け取る新しいaddErrorメソッドを追加し、元のメソッドを調整して新しいメソッドを呼び出すことで通知を変更します。[2]

クラスNotification…

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

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

これは、原因例外を受け入れますが、無視することを意味します。どこかに格納するには、エラーレコードを単純な文字列から、少しだけ単純なオブジェクトに変更する必要があります。

クラスNotification…

  private static class Error {
    String message;
    Exception cause;

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

私は通常、Javaの非プライベートフィールドは嫌いですが、これはプライベートな内部クラスなので問題ありません。このエラークラスを通知の外部に公開する場合は、それらのフィールドをカプセル化します。

これでクラスができたので、文字列ではなくそれを使用するように通知を変更する必要があります。

クラスNotification…

  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");

すでに抽出されたメソッド内にあるので、returnを使用して残りの検証を簡単に中止できます。

そして最後の変更は簡単です。

クラス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");
  }

スタックを上に移動する

新しいメソッドができたので、次のタスクは元のcheckメソッドの呼び出し元を見て、代わりに新しいvalidateメソッドを使用するように調整することを検討することです。これには、検証がアプリケーションのフローにどのように適合するかをより広く検討する必要があります。そのため、このリファクタリングの範囲外です。しかし、中期的な目標は、検証の失敗が予想される状況で例外を使用しないようにすることです。

多くの場合、これにより、checkメソッドを完全に取り除くことができるはずです。その場合、そのメソッドに対するテストはすべて、validateメソッドを使用するように変更する必要があります。また、通知を使用して複数のエラーを適切に収集するためのテストを調査することもできます。


脚注

1: もう1つの一般的な検証アプローチは、入力が有効かどうかを示すブール値を返すだけです。これにより、呼び出し元が異なる動作を簡単に呼び出すことができますが、「エラーが発生しました」という役に立たない情報以外に診断情報を提供する方法はありません。

2: これは、チェーンコンストラクタと呼ばれることもあります。また、部分適用の一例として考えることもできます。ただし、機能的なプログラマーは、Javaプログラムのスラム街でそのような用語を使用することを容認しないでしょう。

参考文献

例外をいつ使用するかについては、多くのことが書かれています。ご想像のとおり、この件についてさらに読むための私の最初の提案は、The Pragmatic Programmerです。Code Completeでも、健全な議論がされています。これらの両方の本は、プロのプログラマーなら誰でも知っているはずです。

また、Avdi GrimmのExceptional Rubyでのエラー条件の処理方法に関する議論も楽しめました。直接的にはRubyの本ですが、そのアドバイスのほとんどは、あらゆるプログラミング環境に適用できます。

フレームワーク

多くのフレームワークは、通知パターンを使用する何らかの検証機能を提供します。Javaの世界では、Java Bean Validationの取り組みと、Springの検証があります。これらのフレームワークは、検証を開始するためのインターフェースを提供し、エラーを収集するために通知を使用します(Bean Validationの場合は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日: 初版公開