Статьи

Замена исключений уведомлениями об ошибках во время проверки ввода в Java

В моей предыдущей статье я писал о проекте проверки входных данных, который заменяет блоки, которые трудно поддерживать и проверять, если иное. Однако, как отметили некоторые читатели, у него есть недостаток — если во входных данных имеется более одной ошибки проверки, то пользователю придется отправить запрос несколько раз, чтобы найти все из них. С точки зрения удобства использования это не очень хороший дизайн.

Альтернативой выбрасыванию исключений при обнаружении ошибки проверки является возврат объекта Notification, содержащего ошибки. Это позволит нам запускать все правила проверки для пользовательского ввода и отслеживать все нарушения одновременно. Мартин Фаулер написал статью, подробно описывающую подход. Я настоятельно рекомендую вам пойти дальше и прочитать его, если вы еще этого не сделали.

В этой статье я проведу рефакторинг моей предыдущей реализации, чтобы использовать объект уведомления об ошибках для проверки пользовательских входов.

В качестве первого шага я создам объект ErrorNotification, который инкапсулирует ошибки моего приложения —

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
public class ErrorNotification {
  private List<String> errors = new ArrayList<>();
 
  public void addError(String message) {
    this.errors.add(message);
  }
 
  public boolean hasError() {
    return !this.errors.isEmpty();
  }
 
  public String getAllErrors() {
    return this.errors.stream()
        .collect(joining(", "));
  }
}

Затем я изменю интерфейс OrderItemValidator, чтобы он возвращал объект ErrorNotification —

1
2
3
public interface OrderItemValidator {
  ErrorNotification validate(OrderItem orderItem);
}

а затем измените все реализации для адаптации к новому типу возврата.

Первоначально я изменю все реализации, чтобы они возвращали пустой объект ошибки, чтобы избавиться от ошибок компиляции. Например, я изменю ItemDescriptionValidator следующим образом —

01
02
03
04
05
06
07
08
09
10
11
12
13
class ItemDescriptionValidator implements OrderItemValidator {
 
  @Override
  public ErrorNotification validate(OrderItem orderItem) {
    ErrorNotification errorNotification = new ErrorNotification();
    Optional.ofNullable(orderItem)
        .map(OrderItem::getDescription)
        .map(String::trim)
        .filter(description -> !description.isEmpty())
        .orElseThrow(() -> new IllegalArgumentException("Item description should be provided"));
    return errorNotification;
  }
}

После исправления ошибок компиляции я начну заменять исключения уведомлениями в каждом валидаторе. Для этого я сначала модифицирую соответствующие тесты, чтобы отразить мои намерения, а затем модифицирую валидаторы для прохождения тестов.

Давайте начнем с класса ItemDescriptionValidatorTest

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class ItemDescriptionValidatorTest {
 
  @Test
  public void validate_descriptionIsNull_invalid() {
    ItemDescriptionValidator validator = new ItemDescriptionValidator();
 
    ErrorNotification errorNotification = validator.validate(new OrderItem());
 
    assertThat(errorNotification.getAllErrors()).isEqualTo("Item description should be provided");
  }
 
  @Test
  public void validate_descriptionIsBlank_invalid() {
    OrderItem orderItem = new OrderItem();
    orderItem.setDescription("     ");
    ItemDescriptionValidator validator = new ItemDescriptionValidator();
 
    ErrorNotification errorNotification = validator.validate(new OrderItem());
 
    assertThat(errorNotification.getAllErrors()).isEqualTo("Item description should be provided");
  }
 
  @Test
  public void validate_descriptionGiven_valid() {
    OrderItem orderItem = new OrderItem();
    orderItem.setDescription("dummy description");
    ItemDescriptionValidator validator = new ItemDescriptionValidator();
 
    ErrorNotification errorNotification = validator.validate(orderItem);
 
    assertThat(errorNotification.getAllErrors()).isEmpty();
  }
}

Когда я запускаю эти тесты, только один из них проходит, и два из них терпят неудачу, что ожидается. Теперь я изменю код валидатора, чтобы пройти тесты —

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
class ItemDescriptionValidator implements OrderItemValidator {
  static final String MISSING_ITEM_DESCRIPTION = "Item description should be provided";
 
  @Override
  public ErrorNotification validate(OrderItem orderItem) {
    ErrorNotification errorNotification = new ErrorNotification();
    Optional.ofNullable(orderItem)
        .map(OrderItem::getDescription)
        .map(String::trim)
        .filter(description -> !description.isEmpty())
        .ifPresentOrElse(
            description -> {},
            () -> errorNotification.addError(MISSING_ITEM_DESCRIPTION)
        );
    return errorNotification;
  }
}

Мне немного неудобно с использованием метода ifPresentOrElse выше. Основная причина, по которой я его здесь использую, заключается в том, что в Optional нет чего-то похожего на метод ifNotPresent , который позволил бы мне предпринимать действия только тогда, когда значение отсутствует (просьба к читателям — если вы знаете лучший способ сделайте это, пожалуйста, прокомментируйте в!).

После этого рефакторинга все тесты в классе ItemValidatorTest проходят в плавающем цвете. Большой!

Давайте теперь проведем рефакторинг тестов в классе MenuValidatorTest

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class MenuValidatorTest {
 
  @Test
  public void validate_menuIdInvalid_invalid() {
    OrderItem orderItem = new OrderItem();
    String menuId = "some menu id";
    orderItem.setMenuId(menuId);
    MenuRepository menuRepository = mock(MenuRepository.class);
    when(menuRepository.menuExists(any())).thenReturn(false);
    MenuValidator validator = new MenuValidator(menuRepository);
 
    ErrorNotification errorNotification = validator.validate(orderItem);
 
    assertThat(errorNotification.getAllErrors())
        .isEqualTo(String.format(MenuValidator.INVALID_MENU_ERROR_FORMAT, menuId));
  }
 
  @Test
  public void validate_menuIdNull_invalid() {
    MenuRepository menuRepository = mock(MenuRepository.class);
    when(menuRepository.menuExists(any())).thenReturn(true);
    MenuValidator validator = new MenuValidator(menuRepository);
 
    ErrorNotification errorNotification = validator.validate(new OrderItem());
 
    assertThat(errorNotification.getAllErrors())
        .isEqualTo(MenuValidator.MISSING_MENU_ERROR);
  }
 
  @Test
  public void validate_menuIdIsBlank_invalid() {
    OrderItem orderItem = new OrderItem();
    orderItem.setMenuId("       \t");
    MenuRepository menuRepository = mock(MenuRepository.class);
    when(menuRepository.menuExists(any())).thenReturn(true);
    MenuValidator validator = new MenuValidator(menuRepository);
 
    ErrorNotification errorNotification = validator.validate(orderItem);
 
    assertThat(errorNotification.getAllErrors())
        .isEqualTo(MenuValidator.MISSING_MENU_ERROR);
  }
 
  @Test
  public void validate_menuIdValid_validated() {
    OrderItem orderItem = new OrderItem();
    String menuId = "some menu id";
    orderItem.setMenuId(menuId);
    MenuRepository menuRepository = mock(MenuRepository.class);
    when(menuRepository.menuExists(menuId)).thenReturn(true);
    MenuValidator validator = new MenuValidator(menuRepository);
 
    ErrorNotification errorNotification = validator.validate(orderItem);
 
    assertThat(errorNotification.getAllErrors()).isEmpty();
  }
}

а затем класс MenuValidator

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@RequiredArgsConstructor
class MenuValidator implements OrderItemValidator {
  private final MenuRepository menuRepository;
 
  static final String MISSING_MENU_ERROR = "A menu item must be specified.";
  static final String INVALID_MENU_ERROR_FORMAT = "Given menu [%s] does not exist.";
 
  @Override
  public ErrorNotification validate(OrderItem orderItem) {
    ErrorNotification errorNotification = new ErrorNotification();
    Optional.ofNullable(orderItem.getMenuId())
        .map(String::trim)
        .filter(menuId -> !menuId.isEmpty())
        .ifPresentOrElse(
            validateMenuExists(errorNotification),
            () -> errorNotification.addError(MISSING_MENU_ERROR)
        );
    return errorNotification;
  }
 
  private Consumer<String> validateMenuExists(ErrorNotification errorNotification) {
    return menuId -> {
      if (!menuRepository.menuExists(menuId)) {
        errorNotification.addError(String.format(INVALID_MENU_ERROR_FORMAT, menuId));
      }
    };
  }
}

и так далее.

После изменения каждого из отдельных валидаторов, теперь я изменю составной, чтобы собрать все ошибки для одного элемента заказа —

01
02
03
04
05
06
07
08
09
10
11
12
13
@RequiredArgsConstructor
class OrderItemValidatorComposite implements OrderItemValidator {
  private final List<OrderItemValidator> validators;
 
  @Override
  public ErrorNotification validate(OrderItem orderItem) {
    ErrorNotification errorNotification = new ErrorNotification();
    validators.stream()
        .map(validator -> validator.validate(orderItem))
        .forEach(errorNotification::addAll);
    return errorNotification;
  }
}

Для этого я добавил новый метод в класс ErrorNotification , называемый addAll , который в основном копирует все ошибки из другого объекта ErrorNotification .

Наконец, теперь я изменю метод обслуживания, чтобы собирать все сообщения об ошибках для всех позиций заказа —

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
@Service
@Slf4j
@RequiredArgsConstructor
class OrderService {
  private final OrderItemValidator validator;
 
  void createOrder(OrderDTO orderDTO) {
    ErrorNotification errorNotification = new ErrorNotification();
    orderDTO.getOrderItems()
        .stream()
        .map(validator::validate)
        .forEach(errorNotification::addAll);
    if (errorNotification.hasError()) {
      throw new IllegalArgumentException(errorNotification.getAllErrors());
    }
 
    log.info("Order {} saved", orderDTO);
  }
}

Внесение этого изменения приводит к сбою одного из тестов в OrderServiceIT , так как он специально искал исключение с причиной, установленной в NumberFormatException, когда цена недействительна. После нашего рефакторинга мы можем безопасно удалить эту проверку, поскольку она больше не актуальна.

Полный исходный код этой статьи перенесен в GitHub (конкретный URL коммита здесь ).

Опубликовано на Java Code Geeks с разрешения Саима Ахмеда, партнера нашей программы JCG . См. Оригинальную статью здесь: Замена исключений уведомлениями об ошибках во время проверки ввода в Java

Мнения, высказанные участниками Java Code Geeks, являются их собственными.