Статьи

Чистый код из окопов

Чистый код из окопов — валидация

Давайте прямо начнем с примера. Рассмотрим простой веб-сервис, который позволяет клиентам оформить заказ в магазине. Очень упрощенная версия контроллера заказов может выглядеть примерно так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
@RestController
@RequestMapping(value = "/",
    consumes = MediaType.APPLICATION_JSON_VALUE,
    produces = MediaType.APPLICATION_JSON_VALUE)
public class OrderController {
  private final OrderService orderService;
 
  public OrderController(OrderService orderService) {
    this.orderService = orderService;
  }
 
  @PostMapping
  public void doSomething(@Valid @RequestBody OrderDTO order) {
    orderService.createOrder(order);
  }
}

И соответствующий класс DTO

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
@Getter
@Setter
@ToString
public class OrderDTO {
 
  @NotNull
  private String customerId;
 
  @NotNull
  @Size(min = 1)
  private List<OrderItem> orderItems;
 
  @Getter
  @Setter
  @ToString
  public static class OrderItem {
    private String menuId;
    private String description;
    private String price;
    private Integer quantity;
  }
}

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

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
@Service
@Slf4j
class OrderService {
  private final MenuRepository menuRepository;
 
  OrderService(MenuRepository menuRepository) {
    this.menuRepository = menuRepository;
  }
 
  void createOrder(OrderDTO orderDTO) {
    orderDTO.getOrderItems()
        .forEach(this::validate);
 
    log.info("Order {} saved", orderDTO);
  }
 
  private void validate(OrderItem orderItem) {
    String menuId = orderItem.getMenuId();
    if (menuId == null || menuId.trim().isEmpty()) {
      throw new IllegalArgumentException("A menu item must be specified.");
    }
    if (!menuRepository.menuExists(menuId.trim())) {
      throw new IllegalArgumentException("Given menu " + menuId + " does not exist.");
    }
 
    String description = orderItem.getDescription();
    if (description == null || description.trim().isEmpty()) {
      throw new IllegalArgumentException("Item description should be provided");
    }
 
    String price = orderItem.getPrice();
    if (price == null || price.trim().isEmpty()) {
      throw new IllegalArgumentException("Price cannot be empty.");
    }
    try {
      new BigDecimal(price);
    } catch (NumberFormatException ex) {
      throw new IllegalArgumentException("Given price is not in valid format", ex);
    }
    if (orderItem.getQuantity() == null) {
      throw new IllegalArgumentException("Quantity must be given");
    }
    if (orderItem.getQuantity() <= 0) {
      throw new IllegalArgumentException("Given quantity "
          + orderItem.getQuantity()
          + " is not valid.");
    }
  }
}

Метод проверки не очень хорошо написан. Это очень сложно проверить. Ввести новое правило проверки в будущем также сложно, как и удаление / изменение любого из существующих. Из моего опыта я видел, что большинство людей пишут несколько общих утверждений для этого типа проверки проверки, обычно в классе интеграционных тестов, касаясь только одного или двух (или более, но не всех) правил проверки. В результате рефакторинг в будущем может быть выполнен только в режимах редактирования и молитвы .

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

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

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
public class MenuValidator implements OrderItemValidator {
  private final MenuRepository menuRepository;
 
  public MenuValidator(MenuRepository menuRepository) {
    this.menuRepository = menuRepository;
  }
 
  @Override
  public void validate(OrderItem orderItem) {
    String menuId = Optional.ofNullable(orderItem.getMenuId())
        .map(String::trim)
        .filter(id -> !id.isEmpty())
        .orElseThrow(() -> new IllegalArgumentException("A menu item must be specified."));
 
    if (!menuRepository.menuExists(menuId)) {
      throw new IllegalArgumentException("Given menu [" + menuId + "] does not exist.");
    }
  }
}

Тогда валидатор описания товара

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

Валидатор цен

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
public class PriceValidator implements OrderItemValidator {
 
  @Override
  public void validate(OrderItem orderItem) {
    String price = Optional.ofNullable(orderItem)
        .map(OrderItem::getPrice)
        .map(String::trim)
        .filter(itemPrice -> !itemPrice.isEmpty())
        .orElseThrow(() -> new IllegalArgumentException("Price cannot be empty."));
 
    try {
      new BigDecimal(price);
    } catch (NumberFormatException ex) {
      throw new IllegalArgumentException("Given price [" + price + "] is not in valid format", ex);
    }
  }
}

И, наконец, валидатор количества

01
02
03
04
05
06
07
08
09
10
11
12
public class QuantityValidator implements OrderItemValidator {
 
  @Override
  public void validate(OrderItem orderItem) {
    Integer quantity = Optional.ofNullable(orderItem)
        .map(OrderItem::getQuantity)
        .orElseThrow(() -> new IllegalArgumentException("Quantity must be given"));
    if (quantity <= 0) {
      throw new IllegalArgumentException("Given quantity " + quantity + " is not valid.");
    }
  }
}

Теперь каждая из этих реализаций валидатора может быть легко протестирована независимо друг от друга. Рассуждение о каждом из них также становится легче. как и в будущем добавление / модификация / удаление.

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

Один из способов — создать список непосредственно в конструкторе OrderService и заполнить его валидаторами. Или мы могли бы использовать Spring для добавления List в OrderService

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
@Service
@Slf4j
class OrderService {
  private final List<OrderItemValidator> validators;
 
  OrderService(List<OrderItemValidator> validators) {
    this.validators = validators;
  }
 
  void createOrder(OrderDTO orderDTO) {
    orderDTO.getOrderItems()
        .forEach(this::validate);
 
    log.info("Order {} saved", orderDTO);
  }
 
  private void validate(OrderItem orderItem) {
    validators.forEach(validator -> validator.validate(orderItem));
  }
}

Чтобы это работало, нам нужно объявить каждую реализацию валидатора как Spring Bean.

Мы могли бы улучшить нашу абстракцию еще дальше. Теперь OrderService принимает список валидаторов. Однако мы можем изменить его так, чтобы он знал только о типе OrderItemValidator и ничего больше. Это дает нам гибкость внедрения одного валидатора или любого состава валидаторов в будущем.

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

Давайте создадим новую реализацию интерфейса валидатора, которая будет составной

01
02
03
04
05
06
07
08
09
10
11
12
class OrderItemValidatorComposite implements OrderItemValidator {
  private final List<OrderItemValidator> validators;
 
  OrderItemValidatorComposite(List<OrderItemValidator> validators) {
    this.validators = validators;
  }
 
  @Override
  public void validate(OrderItem orderItem) {
    validators.forEach(validators -> validators.validate(orderItem));
  }
}

Затем мы создаем новый класс конфигурации Spring, который будет создавать и инициализировать этот композит, а затем представлять его как bean-компонент.

01
02
03
04
05
06
07
08
09
10
11
12
13
@Configuration
class ValidatorConfiguration {
 
  @Bean
  OrderItemValidator orderItemValidator(MenuRepository menuRepository) {
    return new OrderItemValidatorComposite(Arrays.asList(
        new MenuValidator(menuRepository),
        new ItemDescriptionValidator(),
        new PriceValidator(),
        new QuantityValidator()
    ));
  }
}

Затем мы изменим класс OrderService следующим образом

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
@Service
@Slf4j
class OrderService {
  private final OrderItemValidator validator;
 
  OrderService(OrderItemValidator orderItemValidator) {
    this.validator = orderItemValidator;
  }
 
  void createOrder(OrderDTO orderDTO) {
    orderDTO.getOrderItems()
        .forEach(validator::validate);
 
    log.info("Order {} saved", orderDTO);
  }
}

И мы сделали!

Преимуществ такого подхода много. Вся логика проверки была полностью удалена от службы заказа. Тестирование проще. Будущее обслуживание проще. Клиенты знают только об одном типе валидатора и больше ничего.

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

Обратите внимание, что ради этой статьи я также сделал несколько коротких путей. Это включает в себя генерирование универсального исключения IllegalArgumentException при сбое проверки. Вы, вероятно, захотите, чтобы более конкретное / настраиваемое исключение в приложении промышленного уровня идентифицировало различные сценарии. Десятичный синтаксический анализ также выполняется наивно, возможно, вы захотите исправить его в определенном формате, а затем использовать DecimalFormat для его анализа.

Полный код загружен на Github .

Ссылка: Чистый код из траншей от нашего партнера JCG Саима Ахмеда в блоге Codesod .