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

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

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

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

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

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

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

Hibernate Validator также поддерживает использование аннотаций для задания правил валидации полей класса. Такой декларативный подход позволяет избежать «загрязнения» кода. При передаче объекта с аннотациями в валидатор происходит проверка соответствия значениям заданным ограничениям.

Для подключения валидации к проекту добавьте следующий стартер в зависимости Maven:

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

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

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

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

Java 21
Spring Boot 3.3.5

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

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

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

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

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

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

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

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

Для валидации данных, переданных в теле запроса, добавим аннотации к полям сущности PersonDto:

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-адреса.

Чтобы передать объект в валидатор, достаточно добавить аннотацию @Valid к параметру personDto. Выполнение метода контроллера начнется только после успешного прохождения всех проверок:

@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.

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

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

Переменные пути и параметры запроса обычно имеют примитивные типы данных, такие как int, либо их аналоги (Integer, String), поэтому сложные Java-объекты здесь не проверяются.

Для валидации таких параметров можно добавить аннотацию ограничения (например, @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 на уровне класса. Эта аннотация должна присутствовать на классе, даже если ограничения указаны в методах.

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

Разница между @Validated и @Valid

Аннотации @Validated и @Valid часто используются для валидации в Spring, но они предназначены для разных случаев.

Аннотация @Valid из пакета javax.validation активирует стандартную валидацию Java Bean Validation, обеспечивая проверку полей объектов и их вложенных структур. Однако @Valid не может запускать валидацию параметров на уровне методов контроллеров и сервисов. Это ограничение связано с тем, что спецификация Bean Validation была разработана прежде всего для проверки состояния самих объектов, а не входных параметров методов.

Аннотация @Validated, в свою очередь, является расширением Spring Framework и активирует механизм валидации Spring на уровне методов. В отличие от @Valid, она поддерживает валидацию параметров метода и позволяет задавать группы валидации. Именно @Validated позволяет использовать такие аннотации, как @Min, @Max, @NotNull и другие, для параметров методов в контроллерах и сервисах, гарантируя, что ограничения будут проверены перед выполнением метода.

Таким образом, @Valid применяется, когда нужно проверить внутреннее состояние объектов, а @Validated необходима для активации проверок на уровне параметров методов и поддержки более сложных сценариев валидации.

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

Валидацию можно выполнять и в других компонентах 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, и ответ клиенту вернется с HTTP-статусом 500.

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

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

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

Слой хранения данных (Persistence Layer) — это последняя линия защиты данных в приложении. В Spring Data валидация данных в этом слое осуществляется с использованием Hibernate, который поддерживает Bean Validation "из коробки".

Например, если требуется сохранить объекты класса PersonDto в базе данных, Hibernate автоматически проверит соблюдение аннотаций ограничений, указанных в классе, таких как @NotBlank или @Min. Если объект PersonDto нарушает одно из этих ограничений, то при попытке сохранения будет выброшено исключение ConstraintViolationException. Валидация срабатывает на уровне Hibernate только при вызове flush() у EntityManager, когда объект непосредственно записывается в базу данных.

Валидация на уровне Hibernate полезна, но в некоторых случаях её может потребоваться отключить — например, если валидация уже полностью осуществляется на предыдущих уровнях приложения. Для этого в Spring Boot можно установить параметр конфигурации:

spring.jpa.properties.javax.persistence.validation.mode=none

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

Основное место для валидации данных — это сервисный слой. Такой подход имеет ряд преимуществ:

  • Единая точка валидации для всех взаимодействий. Различные контроллеры (REST, Kafka, gRPC) могут обращаться к одному и тому же бизнес-слою. Централизация валидации в сервисном слое позволяет избежать её дублирования в каждом контроллере и гарантирует, что проверки будут применяться единообразно, вне зависимости от типа входящего запроса.
  • Избежание работы с невалидными объектами в бизнес-логике. Если валидация выполняется на уровне репозитория, то это означает, что бизнес-логика работала с потенциально некорректными объектами. Это может привести к непредвиденным ошибкам.
Спонсор поста 3

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

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

Я подробно описывал обработку исключений в 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, который будет обрабатывать исключения ConstraintViolationException и MethodArgumentNotValidException. Это позволит конвертировать ошибки валидации в единый формат ValidationErrorResponse.

@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. При возникновении ConstraintViolationException или MethodArgumentNotValidException, контроллер возвращает сообщение с HTTP-статусом 400 (Bad Request), и клиент получает детализированную информацию о каждом нарушении

Для кастомизации сообщений об ошибках можно использовать параметр message в аннотациях валидации. Например, укажем кастомное сообщение для IP-адреса:

@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
}

В этом примере:

  • Поле name не должно быть пустым.
  • Поле reportIntervalInDays должно находиться в диапазоне от 7 до 30 включительно.
  • Поле reportEmailAddress должно содержать корректный адрес электронной почты.

Если приложение запускается с некорректным значением конфигурации, например, если reportEmailAddress не соответствует формату email, Spring Boot остановит запуск и выведет ошибку:

***
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 — размер аннотированного элемента должен находиться в пределах заданных границ, включая их. Поддерживаемые типы:
    • CharSequence, Collection, Map, Array — проверяются длина или размер.
  • @AssertTrue — значение должно быть true.
  • @Email — проверяет, что значение является корректным email.
  • @Positive / @PositiveOrZero — число должно быть строго положительным или положительным (включая 0).
  • @Negative / @NegativeOrZero — число должно быть строго отрицательным или отрицательным (включая 0).
  • @Past / @PastOrPresent — дата должна быть в прошлом (включая настоящее).
  • @Future / @FutureOrPresent — дата должна быть в будущем (включая настоящее).

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

@NotNull — проверяет, что объект не равен null, но допускает пустые значения (например, пустые строки, коллекции или массивы). Применяется к CharSequence, Collection, Map, Array.

@NotEmpty — проверяет, что объект не равен null и имеет ненулевой размер. Подходит для CharSequence, Collection, Map, Array.

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

Для примера, аннотация @Size(min=6) пропустит строку из шести пробелов, тогда как @NotBlank потребует, чтобы строка содержала хотя бы один непробельный символ.

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

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

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

Для таких ситуаций Bean Validation поддерживает группы валидации. Это позволяет задавать правила, которые будут выполняться только для определённых операций. Все аннотации ограничений имеют поле 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;

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

}
  • Аннотация @Null на поле id требует, чтобы id был пустым при создании (OnCreate).
  • Аннотация @NotNull требует, чтобы id был заполнен при обновлении (OnUpdate).

Использование групп в контроллерах

Для работы групп валидации в 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 { };

}

Реализуем интерфейс ConstraintValidator, который определяет логику валидации. Метод isValid проверяет, начинается ли строка с заглавной буквы.

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.

Для ручного запуска валидации можно создать экземпляр валидатора, используя ValidatorFactory и затем проверить объект:

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);
    }
  }

}

Здесь метод validateInput создает ValidatorFactory, затем получает Validator и запускает проверку объекта personDto. Если при валидации обнаружены нарушения, выбрасывается исключение ConstraintViolationException, содержащее список ошибок.

В Spring Boot уже предусмотрен заранее сконфигурированный Validator, который можно внедрить через Dependency Injection. Это избавляет от необходимости создавать валидатор вручную и упрощает код.

@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);
    }
  }

}

В данном примере Validator внедряется в сервис через конструктор, что позволяет использовать его для проверки personDto без необходимости создавать экземпляры валидатора вручную.

Заключение

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

Мы рассмотрели, как:

  1. Проверять данные на уровне контроллеров, обеспечивая первичную защиту на входе и информативные сообщения об ошибках для пользователей.
  2. Централизовать валидацию в сервисном слое, чтобы гарантировать единые правила проверки для всех возможных точек доступа к бизнес-логике, минимизируя дублирование кода.
  3. Подключать валидацию в репозиториях JPA для финального контроля целостности данных, исключая риск некорректных записей в базу данных.
  4. Создавать кастомные аннотации, позволяющие адаптировать проверки под уникальные требования, такие как сложные зависимости между полями.
  5. Использовать группы валидации для применения разных правил валидации в зависимости от контекста (например, создание или обновление).
  6. Выполнять валидацию принудительно в любом компоненте приложения, например, в сервисах, используя встроенный валидатор Spring Boot для удобства и согласованности.

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

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

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