mike-neckのブログ

Java or Groovy or Swift or Golang

Validation には例外を使わない

マーチン・ファウラーの文章を読んだのでメモ。

martinfowler.com


バリデーション、つまりユーザーの入力に問題がある場合は処理を中断してユーザーに指摘するというフローは、大抵のアプリケーションに備わっているが、これを例外で実現した場合に、『達人プログラマー』の有名らしい一節「すべての例外ハンドラーを除去しても、このプログラムは動作することができるだろうか?答えが「ノー」であれば、例外ではない状況下で例外が使われている」と相反するらしい(という理解)。

また、次のようなフローを書いた場合、入力エラーが1つしかユーザーに伝えられないが、1回ですべての入力エラーを指摘したほうがユーザー(クライアント)には親切だろう(ネットワーク的に何度もリクエストを送るよりもよいだろう)。

String startDate;
String endDate;

void validate() {
  if (startDate == null) throw new IllegalArgumentException("startDate is required.");
  try {
    LocalDate.parse(startDate);
  } catch(DateTimeFormatException e) {
    throw new IllegalArgumentException("startDate has invalid format.", e);
  }
  if (endDate == null) throw new IllegalArgumentException("endDate is required.");
  try {
    LocalDate.parse(endDate);
  } catch(DateTimeFormatException e) {
    throw new IllegalArgumentException("endDate has invalid format.", e);
  }
}

代わりにどうするかというと、 Notification パターンと呼ぶ、すべての検証結果を溜め込んだオブジェクトを返すようにするらしい。

雑に書いておくとこんな感じ。

List<String> validate() {
  List<String> errors = new ArrayList<>();
  if (startDate == null) errors.add("startDate is required.");
  // ... 以下略
}

いかにもコードなコードよりも文章というか箇条書きのようなコードを好む僕の場合は次のようなコードになると思う。というか、すでにこういうコード書いてる。

ValidationResult validate() {
  return ValidationResult.validateAll(
    startDateIsNotNull(),
    startDateHasWellFormat(),
    endDateIsNotNull(),
    endDateHasWellFormat()
  );
}

Validation startDateIsNotNull() {
  return Validation
    .notNull(() -> startDate)
    .errorMessage(() -> "startDate is required.");
}

interface Validation {
  Optional<String> run();

  static ValidationErrorMessage of(ErrorCondition condition) {
    return errorMessage -> {
      if (condition.hasError()) return errorMessage::get;
      else return Optional::empty;
    };
  }

  static ValidationErrorMessage notNull(NullableSupplier input) {
    return of(input::isNull);
  }
}

interface ErrorCondition {
  boolean hasError();
}

interface ValidationErrorMessage {
  Validation errorMessage(Supplier<String> errorMessage);
}

interface NullableSupplier {
  @Nullable Object get();

  default boolean isNull() { return get() == null; }
}

interface ValidationResult {
  List<String> errors();
  default boolean hasError() { return !errors().isEmpty(); }
  default int errorCount() { return errors().size(); }

  static ValidationResult validateAll(Validation... validations) {
    return () -> Arrays.stream(validations)
      .map(Validation::run)
      .filter(Optional::isPresent)
      .map(o -> o.orElseThrow(IllegalStateException::new))
      .collect(Collectors.toUnmodifiableList());
  }
}