Pull to refresh
1021.68
OTUS
Цифровые навыки от ведущих экспертов

Всё ещё используете If/else валидацию в Spring 6.0+ / SpringBoot 3.0+?

Reading time12 min
Views13K
Original author: NGU

Если да, то вам следовало бы обновить свой код, используя приведенные ниже рекомендации.

Чтобы избежать влияния несанкционированных параметров на ваш бизнес, в ваших веб-сервисах должна быть реализована проверка параметров на уровне контроллера! В большинстве случаев параметры запроса можно разделить на два следующих вида:

  • POST и PUT-запросы, использующие requestBody для передачи параметров.

  • GET-запросы, использующие requestParam/PathVariable для передачи параметров.

Минимальные требования:

  • Spring 6.0+

  • SpringBoot 3.0+

Спецификация Java API (JSR303) определяет стандарт Validation Api для Bean, но не предоставляет его реализацию. Hibernate Validation является реализацией этого стандарта, добавляя ряд аннотаций валидации, таких как @Email, @Length и т.д. Spring Validation — это вторичная инкапсуляция Hibernate Validation, используемая для поддержки автоматической валидации параметров Spring MVC.

Без лишних отлагательств давайте на примере Spring Boot проекта познакомимся с использованием Spring Validation.

В SpringBoot 3.0+

библиотека валидации была перенесена в jakarta.validation

<dependency>
            <groupId>jakarta.validation</groupId>
            <artifactId>jakarta.validation-api</artifactId>
</dependency>

который может импортироваться по

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

RequestBody

В POST и PUT запросах для передачи параметров обычно используется requestBody. В этом случае бэкенд использует для их приема DTO-объект. Если DTO-объект помечен аннотацией @Validated, то может быть реализована автоматическая валидация параметров.

Например, есть интерфейс для хранения данных пользователя, который требует, чтобы длина userName была 2-10, длина полей account и password — 6-20. Если параметры не пройдут проверку, будет выброшена ошибка MethodArgumentNotValidException, и Spring по умолчанию отправит запрос 400 (Bad Request).

@Data
public class UserDTO {

    private Long userId;

    @NotNull
    @Length(min = 2, max = 10)
    private String userName;

    @NotNull
    @Length(min = 6, max = 20)
    private String account;

    @NotNull
    @Length(min = 6, max = 20)
    private String password;
}

Объявление верификационных аннотаций для параметров методов:

@PostMapping("/save")
public Result saveUser(@RequestBody @Validated UserDTO userDTO) {
    // ...
    return Result.ok();
}


// А НЕ(!!!)

@PostMapping("/save")
public Result saveUser(UserDTO userDTO) {
    
  if(userDTO.getUserName().getLength >=2 && userDTO.getUserName().getLength <=10)
  {
    ...
  }
  
  if(...)
  {
    ...
  }
  else{
    ...
  }
  ...
    
  return Result.ok();
}

RequestParam/PathVariable

GET-запросы обычно используют для передачи параметров requestParam/PathVariable. Если параметров много (например, более 6), для их приема также следует использовать DTO-объекты. В противном случае рекомендуется поместить один параметр во входящие параметры метода. В этом случае классе Controller должен быть отмечен аннотацией @Validated, а входные параметры объявлены с аннотацией ограничения (например, @Min и т.д.). Если проверка не будет пройдена, будет выброшена ошибка ConstraintViolationException. Пример кода выглядит следующим образом:

@RequestMapping("/api/user")
@RestController
@Validated
public class UserController {

    @GetMapping("{userId}")
    public Result detail(@PathVariable("userId") @Min(10000000000000000L) Long userId) {

        UserDTO userDTO = new UserDTO();
        userDTO.set...
        return Result.ok(userDTO);
    }


    @GetMapping("getByAccount")
    public Result getByAccount(@Length(min = 6, max = 20) @NotNull String  account) {

        UserDTO userDTO = new UserDTO();
        userDTO.set...
        return Result.ok(userDTO);
    }
}

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

Прежде чем углубляться, мы должны понять, в чем состоит взаимосвязь и различие

@Valid и @Validated

И @Valid, и @Validated используются для запуска процесса валидации при обработке запроса в Spring. Однако между ними есть несколько ключевых различий:

  • Происхождение: @Valid — это стандартная аннотация из спецификации Java Bean Validation, также известной как JSR-303. Она не является специфичной для Spring и может использоваться в любом Java-приложении. С другой стороны, @Validated — это специфическая для Spring аннотация, предоставляемая самим Spring.

  • Функция: @Valid используется для проверки объекта метода или параметра в методе. Зачастую она используется, когда объект получен в HTTP-запросе, и вы хотите проверить поля этого объекта. @Validated используется для проверки параметров метода на Spring-бине. Зачастую она используется, когда метод компонента Spring имеет параметры, которые должны проходить валидацию.

  • Группировка: Только @Validated поддерживает группировку ограничений. Это полезно, когда для одного и того же объекта при разных обстоятельствах требуются разные группы проверок.

Подводя итог, можно сказать, что при работе в рамках фреймворка Spring лучшем решением будет использование @Validated из-за ее дополнительных функций. Используйте @Valid вне Spring или когда дополнительные возможности @Validated не требуются.

Отлично, теперь мы можем перейти к интересным частям.

Групповая валидация

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

В приведенном ниже примере, например, при сохранении User, UserId может быть null, а при обновлении User, значение UserId должно быть >=10000000000000000L; правила валидации для других полей одинаковы в обоих случаях. Пример кода с использованием групповой валидации на данный момент выглядит следующим образом:

@Data
public class UserDTO {

    @Min(value = 10000000000000000L, groups = Update.class)
    private Long userId;

    @NotNull(groups = {Save.class, Update.class})
    @Length(min = 2, max = 10, groups = {Save.class, Update.class})
    private String userName;

    @NotNull(groups = {Save.class, Update.class})
    @Length(min = 6, max = 20, groups = {Save.class, Update.class})
    private String account;

    @NotNull(groups = {Save.class, Update.class})
    @Length(min = 6, max = 20, groups = {Save.class, Update.class})
    private String password;

    /**
     * группа проверок для сохранения
     */
    public interface Save {
    }

    /**
     * группа проверок для обновления 
     */
    public interface Update {
    }
}

@PostMapping("/save")
public Result saveUser(@RequestBody @Validated(UserDTO.Save.class) UserDTO userDTO) {
    // ...
    return Result.ok();
}

@PostMapping("/update")
public Result updateUser(@RequestBody @Validated(UserDTO.Update.class) UserDTO userDTO) {
    // ...
    return Result.ok();
}

Как видите, группировка происходит в аннотациях ограничений.

Вложенная валидация

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

Например, при сохранении информации о пользователе, приведенной, мы также получаем информацию о его работе. Следует отметить, что в этот раз соответствующее поле класса DTO должно быть помечено аннотацией @Valid.

@Data
public class UserDTO {

    @Min(value = 10000000000000000L, groups = Update.class)
    private Long userId;

    @NotNull(groups = {Save.class, Update.class})
    @Length(min = 2, max = 10, groups = {Save.class, Update.class})
    private String userName;

    @NotNull(groups = {Save.class, Update.class})
    @Length(min = 6, max = 20, groups = {Save.class, Update.class})
    private String account;

    @NotNull(groups = {Save.class, Update.class})
    @Length(min = 6, max = 20, groups = {Save.class, Update.class})
    private String password;

    @NotNull(groups = {Save.class, Update.class})
    @Valid
    private Job job;

    @Data
    public static class Job {

        @Min(value = 1, groups = Update.class)
        private Long jobId;

        @NotNull(groups = {Save.class, Update.class})
        @Length(min = 2, max = 10, groups = {Save.class, Update.class})
        private String jobName;

        @NotNull(groups = {Save.class, Update.class})
        @Length(min = 2, max = 10, groups = {Save.class, Update.class})
        private String position;
    }


    public interface Save {
    }


    public interface Update {
    }
}

Вложенная валидация может использоваться в сочетании с групповой валидацией. Кроме того, вложенная валидация коллекции будет проверять каждый элемент в коллекции, например, поле List будет проверять каждый объект Job в списке

Валидация коллекции

Например, тело запроса напрямую отправляет JSON-массив на бэкенд и рассчитывает на валидацию каждого элемента в массиве. В этом случае, если мы напрямую используем список или набор из java.util.Collection для получения данных, проверка параметров не будет произведена! Мы можем использовать пользовательскую коллекцию-список для приема параметров:

Оберните тип List и объявите аннотацию @Valid.

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

Например, если нам нужно сохранить сразу несколько объектов User, метод в слое Controller может быть написан следующим образом:

public class ValidationList<E> implements List<E> {

    @Delegate
    @Valid // обязательно
    public List<E> list = new ArrayList<>();

    @Override
    public String toString() {
        return list.toString();
    }
}

@PostMapping("/saveList")
public Result saveList(@RequestBody @Validated(UserDTO.Save.class) ValidationList<UserDTO> userList) {
    // ...
    return Result.ok();
}

Пользовательская валидация

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

Кастомизировать Spring Validation очень просто. Предположим, что мы настраиваем проверку зашифрованного id (состоящего из цифр или букв от a-f и длиной 32-256). Есть два основных шага:

Определите пользовательскую аннотацию ограничения и реализуйте интерфейс ConstraintValidator, в котором будет прописан валидатор ограничения:

@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {EncryptIdValidator.class})
public @interface EncryptId {

    String message() default "id format error";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}


public class EncryptIdValidator implements ConstraintValidator<EncryptId, String> {

    private static final Pattern PATTERN = Pattern.compile("^[a-f\\d]{32,256}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {

        if (value != null) {
            Matcher matcher = PATTERN.matcher(value);
            return matcher.find();
        }
        return true;
    }
}


@Data
public class XXXDTO {

    @EncryptId
    private Long id;

    ...
}

Таким образом, мы можем использовать @EncryptId для проверки параметров!

Программная валидация

Приведенные выше примеры основаны на аннотациях для реализации автоматической валидации, но в некоторых случаях нам может потребоваться вызвать проверку программно. В этом случае мы можем внедрить объект javax.validation.Validator и затем вызвать его API.

@Autowired
private javax.validation.Validator globalValidator;

@PostMapping("/saveWithCodingValidate")
public Result saveWithCodingValidate(@RequestBody UserDTO userDTO) {
    Set<ConstraintViolation<UserDTO>> validate = globalValidator.validate(userDTO, UserDTO.Save.class);

    if (validate.isEmpty()) {
        // ...

    } else {
        for (ConstraintViolation<UserDTO> userDTOConstraintViolation : validate) {
            // ...
            System.out.println(userDTOConstraintViolation);
        }
    }
    return Result.ok();
}

Fail Fast

По умолчанию Spring Validation проверит все поля, прежде чем выбросить исключение. Добавив несколько простых настроек, вы можете включить режим Fail Fast, который немедленно возвращает исключение при неудачной проверке.

@Bean
public Validator validator() {
    ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
            .configure()
            .failFast(true)
            .buildValidatorFactory();
    return validatorFactory.getValidator();
}

И последнее, но не менее важное

Принцип реализации 

@RequestBody

В Spring MVC RequestResponseBodyMethodProcessor используется для разбора параметров, аннотированных @RequestBody, и для обработки возвращаемых значений методов, аннотированных @ResponseBody. Очевидно, что логика для выполнения проверки параметров должна находиться в методе разрешения параметров resolveArgument().

 public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {

 ...

 /**
  * При неудачной проверке выбрасывает MethodArgumentNotValidException.
  * @throws HttpMessageNotReadableException если {@link RequestBody#required()}
  * является {@code true} и там нет содержимого, или если нет подходящего
  * конвертера для чтения содержимого.
  */
 @Override
 public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
   NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {

  parameter = parameter.nestedIfOptional();
  
  //Инкапсулируйте данные запроса в DTO-объект 
  Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());

  if (binderFactory != null) {
   String name = Conventions.getVariableNameForParameter(parameter);
   WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
   if (arg != null) {
    validateIfApplicable(binder, parameter);
    if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
     throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
    }
   }
   if (mavContainer != null) {
    mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
   }
  }

  return adaptArgumentIfNecessary(arg, parameter);
 }

 ...

}

Как вы можете видеть, resolveArgument() вызывает validateIfApplicable() для валидации параметров.

protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
  Annotation[] annotations = parameter.getParameterAnnotations();
  for (Annotation ann : annotations) {
   //определяем подсказки валидации
   Object[] validationHints = ValidationAnnotationUtils.determineValidationHints(ann);
   if (validationHints != null) {
    binder.validate(validationHints);
    break;
   }
  }
 }

Отсюда вы должны понять, почему аннотации @Validated и @Valid можно смешивать в этом сценарии. Давайте перейдем к реализации WebDataBinder.validate():

/**
  * Вызваем указанные валидаторы, если таковые имеются, с заданными подсказками валидации.
  * <p>Примечание: подсказки валидации могут быть проигнорированы реальным целевым валидатором.
  * @param validationHints один или несколько объектов подсказок для передачи в {@link SmartValidator}
  * @since 3.1
  * @see #setValidator(Validator)
  * @see SmartValidator#validate(Object, Errors, Object...)
  */
 public void validate(Object... validationHints) {
  Object target = getTarget();
  Assert.state(target != null, "No target to validate");
  BindingResult bindingResult = getBindingResult();
  // Вызываем каждый валидатор с одним и тем же результатом привязки
  for (Validator validator : getValidators()) {
   if (!ObjectUtils.isEmpty(validationHints) && validator instanceof SmartValidator smartValidator) {
    smartValidator.validate(target, bindingResult, validationHints);
   }
   else if (validator != null) {
    validator.validate(target, bindingResult);
   }
  }
 }

Оказывается, что базовый слой в конечном итоге вызывает Hibernate Validator, чтобы выполнить реальную обработку валидации.

Проверка параметров на уровне метода

Этот метод валидации предполагает поочередно распределять параметры по параметрам метода и объявлять аннотации ограничений перед каждым параметром, что представляет собой валидацию параметров на уровне метода. Фактически, этот метод может быть использован для любых методов Spring Bean, таких как Controller/Service и т.д. В основе реализации лежит принцип AOP (аспектно-ориентированное программирование). В частности, здесь задействован MethodValidationPostProcessor, динамически регистрирующий AOP-аспект, а затем использующий MethodValidationInterceptor для вплетения расширений в pointcut.

public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor
  implements InitializingBean {

  ...


 @Override
 public void afterPropertiesSet() {
  // Создаем аспект для всех бинов аннотированных '@Validated'. 
  Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
  // Создаем советника для расширений
  this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
 }

 /**
  * Создание рекомендаций AOP для целей валидации метода, которые будут применяться
  * с указателем для указанной аннотации "validated".
  * @param validator поставщик для используемого валидатора
  * @return перехватчик (обычно, но не обязательно,
  * {@link MethodValidationInterceptor} или его подкласс)
  * @since 6.0
  */
 protected Advice createMethodValidationAdvice(Supplier<Validator> validator) {
  return new MethodValidationInterceptor(validator);
 }

}

Давайте взглянем на MethodValidationInterceptor:

public class MethodValidationInterceptor implements MethodInterceptor {

 private final Supplier<Validator> validator;


 ...


 @Override
 @Nullable
 public Object invoke(MethodInvocation invocation) throws Throwable {
  // Избегайте вызова валидатора на FactoryBean.getObjectType/isSingleton
  if (isFactoryBeanMetadataMethod(invocation.getMethod())) {
   return invocation.proceed();
  }

  Class<?>[] groups = determineValidationGroups(invocation);

  // Стандартный API Bean Validation 1.1
  ExecutableValidator execVal = this.validator.get().forExecutables();
  Method methodToValidate = invocation.getMethod();
  Set<ConstraintViolation<Object>> result;

  Object target = invocation.getThis();
  if (target == null && invocation instanceof ProxyMethodInvocation methodInvocation) {
   // Разрешаем проверку для AOP-прокси без таргета
   target = methodInvocation.getProxy();
  }
  Assert.state(target != null, "Target must not be null");

  try {
   result = execVal.validateParameters(target, methodToValidate, invocation.getArguments(), groups);
  }
  catch (IllegalArgumentException ex) {
   // Вероятно, имеет место несовпадение типов между интерфейсом и реализацией, как сообщалось в SPR-12237 / HV-1011
   // Давайте попробуем найти связанный метод в классе реализации...
   methodToValidate = BridgeMethodResolver.findBridgedMethod(
     ClassUtils.getMostSpecificMethod(invocation.getMethod(), target.getClass()));
   result = execVal.validateParameters(target, methodToValidate, invocation.getArguments(), groups);
  }
  if (!result.isEmpty()) {
   throw new ConstraintViolationException(result);
  }

  Object returnValue = invocation.proceed();

  result = execVal.validateReturnValue(target, methodToValidate, returnValue, groups);
  if (!result.isEmpty()) {
   throw new ConstraintViolationException(result);
  }

  return returnValue;
 }

 ...

}

Вот и получается, что будь то валидация параметров requestBody или валидация на уровне метода, в конце-концов работу выполнит Hibernate Validator.

Спасибо за внимание! Приходите на бесплатные открытые уроки, которые пройдут в преддверии старта курса «Разработчик на Spring Framework»:

  • 13 марта: поговорим о JHipster, затронем Rapid Application Development и рассмотрим некоторые примеры использования. Записаться

  • 20 марта: попробуем разобраться с паттернами Controller, Service, Repository — какую пользу они могут нам принести? А также обсудим особенности их использования в Spring. Записаться

Tags:
Hubs:
Total votes 19: ↑14 and ↓5+9
Comments11

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS