Валидация данных в Spring Boot

Нередко пользователи пытаются передать в приложение некорректные данные. Это происходит либо из злого умысла, либо по ошибке. Поэтому стоит проверять данные на соответствие бизнес-требованиям.

· 9 минуты на чтение

Пользователи часто передают в приложение некорректные данные. Такое происходит либо из злого умысла, либо по ошибке. Сто́ит проверять данные на соответствие бизнес-требованиям.

Эти бизнес-правила влияют на каждый уровень приложения. Веб-интерфейс сообщает пользователю подробные и локализованные сообщения об ошибках. Уровни бизнес-логики и хранения должны проверять приходящие от клиентов значения, перед отправкой в хранилище. База данных SQL делает окончательную проверку, чтобы гарантировать целостность хранимой информации.

Эти задачи поможет решить Bean Validation. Он интегрирован со Spring и Spring Boot. Hibernate Validator считается эталонной реализацией Bean Validation.

Идея Bean Validation в том, чтобы определять такие правила, как «Это поле не может быть null» или «Это число должно находиться в заданном диапазоне» с помощью аннотаций. Это гораздо проще, чем постоянно писать условные операторы проверок.

Hibernate Validator также задаёт правила валидации с помощью аннотаций над полями класса. Этот декларативный подход не загрязняет код. При передаче размеченного таким образом объекта класса в валидатор, происходит проверка на ограничения.

Добавьте стартер в проект, чтобы включить валидацию:

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

Актуальная версия Spring Boot в Maven

Спонсор поста
😺
Репозиторий: example/spring_validation

Используемые версии

Java 17
Spring Boot 2.7.1

История изменений статьи

26.02.2022: Обновил версию Java с 11 до 17. Также обновил версию Spring Boot до 2.6.3
29.06.2022: Spring Boot – 2.7.1. Добавил коллекцию Postman с тестами.

Валидация в контроллерах

Обычно данные сначала попадают в контроллер. У входящего HTTP запроса возможно проверить следующие параметры:

  • тело запроса.
  • переменные пути (например, id в /foos/{id}).
  • параметры запроса.

Рассмотрим каждый из них подробнее.

Валидация тела запроса

Тело запроса POST и PUT обычно содержит данные в формате JSON. Spring автоматически сопоставляет входящий JSON с объектом Java.

Разметим сущность с помощью аннотаций валидации.

public class PersonDto {

    private Long id;

    @NotBlank
    private String name;

    @Min(1)
    @Max(10)
    private int numberBetweenOneAndTen;

    @Pattern(regexp = "^((25[0-5]|(2[0-4]|1[0-9]|[1-9]|)[0-9])(\\.(?!$)|$)){4}$")
    private String ipAddress;

    // getters and setters

}

Все основные аннотации мы рассмотрим позднее, но по названиям довольно легко понять, какое условие они проверяют:

  • Поле name не должно быть пустым или null.
  • Поле numberBetweenOneAndTen должно́ находиться в диапазоне от 1 до 10, включительно.
  • Поле ipAddress должно содержать строку в формате IP-адреса.

Достаточно добавить для входящего параметра personDto аннотацию @Valid, чтобы передать объект в валидатор. Выполнение метода контролера начнётся только, если объект пройдёт все проверки.

@RestController
@RequestMapping("/api/person")
public class PersonController {

    @PostMapping
    public ResponseEntity<String> valid(@Valid @RequestBody PersonDto personDto) {
        return ResponseEntity.ok("valid");
    }

}

В демонстрационном проекте для удобства вы можете использовать Swagger, о нём я писал в статье: Документирование API с помощью OpenAPI 3 и Swagger. Я буду использовать Postman.

Вызываем наш POST метод и передаём в него не валидные данные.

Postman возвращает нам ошибку, а в консоли видим исключение. Оно сообщает нам о двух ошибках валидации.

Исключение MethodArgumentNotValidException выбрасывается, когда объект не проходит проверку. По умолчанию Spring преобразует это исключение в HTTP статус 400.

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

Проверка переменных пути и параметров запроса

При проверке переменных пути и параметров запроса не проверяются сложные Java-объекты, так как path-переменные и параметры запроса являются примитивными типами, такими как int, или их аналогами: Integer или String.

Вместо аннотации поля класса, как описано выше, добавляют аннотацию ограничения (в данном случае @Min) непосредственно к параметру метода в контроллере:

@Validated
@RestController
@RequestMapping("/api/person")
public class PersonController {

    @GetMapping("{id}")
    public ResponseEntity<String> getById(
            @PathVariable("id") @Min(0) int personId
    ) {
        return ResponseEntity.ok("valid");
    }

    @GetMapping
    public ResponseEntity<String> getByName(
            @RequestParam("name") @NotBlank String name
    ) {
        return ResponseEntity.ok("valid");
    }

}

Обратите внимание, что необходимо добавить @Validated в контроллер на уровне класса, чтобы проверять параметры метода. В этом случае аннотация @Validated устанавливается на уровне класса, даже если она присутствует на методах.

В отличии валидации тела запроса, при неудачной проверки параметра вместо метода MethodArgumentNotValidException будет выброшен ConstraintViolationException. По умолчанию последует ответ со статусом HTTP 500 (Internal Server Error), так как Spring не регистрирует обработчик для этого исключения по умолчанию.

Валидация в сервисном слое

Можно проверять данные на любых других компонентах Spring. Для этого используется комбинация аннотаций @Validated и @Valid.

@Service
@Validated
public class PersonService {

    public void save(@Valid PersonDto personDto) {
        // do something
    }

}

Напомню, как выглядит наша сущность:

public class PersonDto {

    private Long id;

    @NotBlank
    private String name;

    @Min(1)
    @Max(10)
    private int numberBetweenOneAndTen;

    @Pattern(regexp = "^((25[0-5]|(2[0-4]|1[0-9]|[1-9]|)[0-9])(\\.(?!$)|$)){4}$")
    private String ipAddress;

    // getters and setters

}

Казалось бы, пример такой же как и в контроллере и логично ожидать MethodArgumentNotValidException, но будет выброшен ConstraintViolationException и 500 ошибка.

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

Помимо объкетов можно проверять примитивы и их обертки, выступающие в виде аргументов метода.

Валидация сущностей JPA

Persistence Layer – это последняя линия проверки данных. По умолчанию Spring Data использует Hibernate, который поддерживает Bean Validation из коробки.

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

Bean Validation запускается Hibernate только после того как EntityManager вызовет flush().

Для отключения валидации в репозиториях установите свойство Spring Boot spring.jpa.properties.javax.persistence.validation.mode равным null.

Где проводить валидацию?

На мой взгляд, лучшее место для основной валидации это сервисный слой. У этого есть несколько причин:

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

Конкретизация ошибок

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

Я подробно описывал обработку исключений в REST API в отдельной статье. Здесь мы разберем только обработку исключений валидации.

Сначала определим структуру сообщения с ошибкой. Назовем ее ValidationErrorResponse. И этот класс содержит список объектов Violation:

@Getter
@RequiredArgsConstructor
public class ValidationErrorResponse {

    private final List<Violation> violations;

}

@Getter
@RequiredArgsConstructor
public class Violation {

    private final String fieldName;
    private final String message;

}

Затем создаем ControllerAdvice, который обрабатывает все ConstraintViolationExventions, которые пробрасываются до уровня контроллера. Чтобы отлавливать ошибки валидации и для тел запросов, мы также будем перехватывать и MethodArgumentNotValidExceptions:

@ControllerAdvice
public class ErrorHandlingControllerAdvice {

    @ResponseBody
    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ValidationErrorResponse onConstraintValidationException(
            ConstraintViolationException e
    ) {
        final List<Violation> violations = e.getConstraintViolations().stream()
                .map(
                        violation -> new Violation(
                                violation.getPropertyPath().toString(),
                                violation.getMessage()
                        )
                )
                .collect(Collectors.toList());
        return new ValidationErrorResponse(violations);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ResponseBody
    public ValidationErrorResponse onMethodArgumentNotValidException(
            MethodArgumentNotValidException e
    ) {
        final List<Violation> violations = e.getBindingResult().getFieldErrors().stream()
                .map(error -> new Violation(error.getField(), error.getDefaultMessage()))
                .collect(Collectors.toList());
        return new ValidationErrorResponse(violations);
    }

}

Здесь информацию о нарушениях из исключений переводится в нашу структуру данных ValidationErrorResponse.

Можно изменить сообщение об ошибке с помощью параметра message у любой аннотации валидации.

@Pattern(
        regexp = "^((25[0-5]|(2[0-4]|1[0-9]|[1-9]|)[0-9])(\\.(?!$)|$)){4}$",
        message = "Не соответствует формату IP адреса"
)
private String ipAddress;

Валидация конфигурации приложения

Spring Boot аннотация @ConfigurationProperties используется для связывания свойств из application.properties с Java объектом.

Bean Validation поможет обнаружить ошибку в этих данных при старте приложения. Допустим имеется следующий конфигурационный класс:

@Validated
@ConfigurationProperties(prefix="app.properties")
class AppProperties {

  @NotEmpty
  private String name;

  @Min(value = 7)
  @Max(value = 30)
  private Integer reportIntervalInDays;

  @Email
  private String reportEmailAddress;

  // getters and setters
}

При попытке запуска с недействительным адресом электронной почты получаем ошибку:

***
APPLICATION FAILED TO START
***

Description:

Binding to target org.springframework.boot.context.properties.bind.BindException:
  Failed to bind properties under 'app.properties' to
  io.reflectoring.validation.AppProperties failed:

    Property: app.properties.reportEmailAddress
    Value: manager.analysisapp.com
    Reason: must be a well-formed email address

Action:

Update your application's configuration

Стандартные ограничения

Библиотека javax.validation имеет множество аннотаций для валидации.

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

  • message – указывает на ключ свойства в ValidationMessages.properties, который используется для отправки сообщения в случае нарушения ограничения.
  • groups – позволяет определить, при каких обстоятельствах будет срабатывать эта проверка. О группах проверки поговорим позже.
  • payload – позволяет определить полезную нагрузку, которая будет передаваться сс проверкой.
  • @Constraint – указывает на реализацию интерфейса ConstraintValidator.

Рассмотрим самые популярные ограничения:

  • @NotNull - аннотированный элемент не должен быть null. Принимает любой тип.
  • @Null - аннотированный элемент должен быть null. Принимает любой тип.
  • @NotBlank - аннотированный элемент не должен быть null и должен содержать хотя бы один непробельный символ. Принимает CharSequence.
  • @NotEmpty - аннотированный элемент не должен быть null или пустым. Поддерживаемые типы:
    • CharSequence
    • Collection. Оценивается размер коллекции
    • Map. Оценивается размер мапы
    • Array. Оценивается длина массива
  • @Size - размер аннотированного элемента должен быть между указанными границами, включая сами границы. null элементы считаются валидными. Поддерживаемые типы:
    • CharSequence. Оценивается длина последовательности символов
    • Collection. Оценивается размер коллекции
    • Map. Оценивается размер мапы
    • Array. Оценивается длина массива
  • @AssertTrue проверяет, что аннотированное значение свойства истинно.
  • @Email подтверждает, что аннотированное свойство является действительным адресом электронной почты.
  • @Positive и @PositiveOrZero применяются к числовым значениям и подтверждают, что они строго положительные или положительные, включая 0.
  • @Negative и @NegativeOrZero применяются к числовым значениям и подтверждают, что они строго отрицательные или отрицательные, включая 0.
  • @Past и @PastOrPresent проверяют, что значение даты находится в прошлом или прошлом, включая настоящее.
  • @Future и @FutureOrPresent подтверждают, что значение даты находится в будущем или в будущем, включая настоящее.

Различия межу @NotNull, @NotEmpty и @NotBlank

@NotBlank применяется только к строкам и проверяет, что строка не пуста и не состоит только из пробелов.

@NotNull применяется к CharSequence, Collection, Map или Array и проверяет, что объект не равен null. Но при этом он может быть пуст.

@NotEmpty применяется к CharSequence, Collection, Map или Array и проверяет, что он не null имеет размер больше 0.

Аннотация @Size(min=6) пропустит строку состоящую из 6 пробелов и/или символов переноса строки, а @NotBlank не пропустит.

Группы валидаций

Некоторые объекты участвуют в разных вариантах использования. Возьмем типичные операции CRUD: при обновлении и создании, скорее всего, будет использоваться один и тот же класс. Тем не менее, некоторые валидации должны срабатывать при различных обстоятельствах:

  • только перед созданием
  • только перед обновлением
  • или в обоих случаях

Функция Bean Validation, которая позволяет нам внедрять такие правила проверки, называется “Validation Groups”. Все аннотации ограничений имеют поле groups. Это поле используется для передачи любых классов, каждый из которых определяет группу проверки.

Для нашего примера CRUD определим два маркерных интерфейса OnCreate и OnUpdate:

public interface Marker {

    interface OnCreate {}

    interface OnUpdate {}

}

Затем используем эти интерфейсы с любой аннотацией ограничения:

public class PersonDto {

  @Null(groups = Marker.OnCreate.class)
  @NotNull(groups = Marker.OnUpdate.class)
  private Long id;

  // ... ... ... ... ...

}

Это позволит убедиться, что id пуст при создании и заполнен при обновлении. Spring поддерживает группы проверки только с аннотацией @Validated

@Validated
@RestController
@RequestMapping("/api/group-valid/person")
public class PersonControllerGroupValid {

    @PostMapping
    @Validated({Marker.OnCreate.class})
    public ResponseEntity<String> create(@RequestBody @Valid PersonDto personDto) {
        return ResponseEntity.ok("valid");
    }

    @PutMapping
    @Validated(Marker.OnUpdate.class)
    public ResponseEntity<String> update(@RequestBody @Valid PersonDto personDto) {
        return ResponseEntity.ok("valid");
    }

}

Обратите внимание, что аннотация @Validated применяется ко всему классу. Чтобы определить, какая группа проверки активна, она также применяется на уровне метода.

Использование групп проверки может легко стать анти-паттерном.

При использовании групп валидации сущность должна знать правила валидации для всех случаев использования (групп), в которых она используется.

Создание своего ограничения

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

Напишем свою аннотацию, которая будет проверять, что строка начинается с большой буквы. Сначала создаем аннотацию @CapitalLetter:

@Target({ FIELD })
@Retention(RUNTIME)
@Constraint(validatedBy = CapitalLetterValidator.class)
@Documented
public @interface CapitalLetter {

  String message() default "{CapitalLetter.invalid}";

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

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

}

Реализация валидатора выглядит следующим образом:

public class CapitalLetterValidator implements ConstraintValidator<CapitalLetter, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value != null && !value.isEmpty()) {
            return Character.isUpperCase(value.charAt(0));
        }
        return true;
    }

}

Теперь можно использовать аннотацию @CapitalLetter, как и любую другую аннотацию ограничения.

public class PersonDto {

  // ... ... ... ... ...

  @NotBlank
  @CapitalLetter
  private String name;

  // ... ... ... ... ...

}

Принудительный вызов валидации

Для принудительного вызова проверки, без использования Spring Boot, создайте валидатор вручную.

public class ProgrammaticallyValidatingService {

  public void validateInput(PersonDto personDto) {
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    Validator validator = factory.getValidator();
    Set<ConstraintViolation<personDto>> violations = validator.validate(personDto);
    if (!violations.isEmpty()) {
      throw new ConstraintViolationException(violations);
    }
  }

}

Тем не менее, Spring Boot предоставляет предварительно сконфигурированный экземпляр валидатора. Внедрив этот экземпляр в сервис не придется создавать его вручную.

@Service
public class ProgrammaticallyValidatingService {

  private Validator validator;

  public ProgrammaticallyValidatingService(Validator validator) {
    this.validator = validator;
  }

  public void validateInputWithInjectedValidator(PersonDto personDto) {
    Set<ConstraintViolation<PersonDto>> violations = validator.validate(personDto);
    if (!violations.isEmpty()) {
      throw new ConstraintViolationException(violations);
    }
  }

}

Резюмирую

Валидация это неотъемлимая часть бизнес логики. Используя зависимость spring-boot-starter-validation, мы можем облегчить себе работу.

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

Стоит возвращать клиенту понятное описание ошибки валидации, используя @ControllerAdvice.

Struchkov Mark
Struchkov Mark
Задавайте вопросы, если что-то осталось не понятным👇