Пользователи часто передают в приложение некорректные данные. Такое происходит либо из злого умысла, либо по ошибке. Сто́ит проверять данные на соответствие бизнес-требованиям.
Эти бизнес-правила влияют на каждый уровень приложения. Веб-интерфейс сообщает пользователю подробные и локализованные сообщения об ошибках. Уровни бизнес-логики и хранения должны проверять приходящие от клиентов значения, перед отправкой в хранилище. База данных 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>Спонсор поста
Используемые версии
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");
}
}
Вызываем наш 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или пустым. Поддерживаемые типы:CharSequenceCollection. Оценивается размер коллекции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.
Время на прочтение
16 мин
Количество просмотров 40K
Этот текст посвящен различным подходам к валидации данных: на какие подводные камни может наткнуться проект и какими методами и технологиями стоит руководствоваться при валидации данных в Java-приложениях.
Я часто видел проекты, создатели которых вообще не утруждались выбором подхода к валидации данных. Команды работали над проектом под невероятным давлением в виде сроков и размытых требований, и в итоге у них просто не оставалось времени на точную, последовательную валидацию. Поэтому, код валидации у них разбросан повсюду: в сниппетах Javascript, контроллерах экранов, в бинах бизнес-логики, сущностях предметной области, триггерах и database constraints. В этом коде было полно операторов if-else, он выбрасывал кучу исключений, и попробуй разберись, где там у них валидируется этот конкретный кусок данных… Как результат, по мере развития проекта становится тяжело и дорого соблюдать и требования (зачастую довольно путаные), и единообразие подходов к валидации данных.
Так есть ли какой-то простой и изящный способ валидации данных? Способ, который защитит нас от греха нечитаемости, способ, который соберет всю логику валидации воедино, и который уже создан за нас разработчиками популярных Java-фреймворков?
Да, такой способ существует.
Для нас, разработчиков платформы CUBA, очень важно, чтобы вы могли пользоваться передовыми практиками. Мы считаем, что код валидации должен:
- Быть переиспользуемым и следовать принципу DRY;
- Быть естественным и понятным;
- Помещаться там, где его ожидает увидеть разработчик;
- Уметь проверять данные из разных источников: пользовательского интерфейса, вызовов SOAP, REST и т.д.
- Без проблем работать в многопоточной среде;
- Вызываться внутри приложения автоматически, без необходимости запускать проверки вручную;
- Выдавать пользователю понятные, локализованные сообщения в лаконичных диалоговых окнах;
- Следовать стандартам.
Давайте посмотрим, как это можно реализовать на примере приложения, написанного с использованием фреймворка CUBA Platform. Однако, так как CUBA построена на основе Spring и EclipseLink, большинство используемых здесь приемов будет работать и на любой другой Java платформе, поддерживающей спецификации JPA и Bean Validation.
Валидация с использованием database constraints
Пожалуй, самый распространенный и очевидный способ валидации данных — это использование ограничений на уровне БД, например, флаг required (для полей, значение которых не может быть пустым), длина строки, уникальные индексы и т.д. Такой способ больше всего подходит для корпоративных приложений, так как этот тип ПО обычно строго ориентирован на обработку данных. Тем не менее, даже здесь разработчики часто совершают ошибки, задавая ограничения отдельно для каждого уровня приложения. Чаще всего причина кроется в распределении обязанностей между разработчиками.
Рассмотрим пример, который большинству из нас знаком, некоторым даже по собственному опыту… Если спецификация гласит, что в поле номера паспорта должно быть 10 знаков, весьма вероятно, что проверяться это будет всеми: архитектором БД в DDL, backend-разработчиком в соответствующих Entity и REST сервисах, и наконец, разработчиком UI непосредственно на стороне клиента. Затем это требование меняется, и поле возрастает до 15 знаков. Девопсы меняют значения constraints в БД, но для пользователя ничего не меняется, ведь на стороне клиента ограничение все то же…
Любой разработчик знает, как избежать этой проблемы, — валидация должна быть централизована! В CUBA такая валидация находится в JPA-аннотациях к сущностям. Основываясь на этой метаинформации, CUBA Studio сгенерирует верный DDL-скрипт и применит соответствующие валидаторы на стороне клиента.
Если аннотации изменятся, CUBA обновит DDL-скрипты и сгенерирует миграционные скрипты, поэтому в следующий раз при развертывании проекта новые ограничения на основе JPA вступят в силу и в интерфейсе и в базе данных приложения.
Несмотря на простоту и реализацию на уровне БД, дающую абсолютную надежность этому методу, область применения JPA аннотаций ограничена самыми простыми случаями, которые могут быть выражены в стандарте DDL, и не включают триггеры БД или хранимые процедуры. Так что, ограничения, основанные на JPA могут делать поле сущности уникальным или обязательным или задавать максимальную длину столбца. Можно даже задать уникальное ограничение для комбинации колонок с помощью аннотации @UniqueConstraint. Но на этом, пожалуй, все.
Как бы то ни было, в случаях, требующих более сложной логики валидации, вроде проверки поля на минимальное/максимальное значение, валидации при с помощи регулярного выражения, или выполнения кастомной проверки, свойственной только вашему приложению, применяется подход, известный как «Bean Validation».
Bean validation
Всем известно, что хорошей практикой является следование стандартам, имеющим длинный жизненный цикл, чья эффективность доказана на тысячах проектов. Java Bean Validation — это подход, зафиксированный в JSR 380, 349 и 303 и их применениях: Hibernate Validator и Apache BVal.
Хотя этот подход знаком многим разработчикам, его часто недооценивают. Это простой способ встраивать валидацию данных даже в legacy-проекты, который позволяет выстраивать проверки понятно, просто, надежно и максимально близко к бизнес-логике.
Использование Bean Validation дает проекту немало преимуществ:
- Логика валидации расположена рядом с предметной областью: определение ограничений для полей и методов бина происходит естественным и по-настоящему объектно-ориентированным образом.
- Стандарт Bean Validation дает нам десятки валидационных аннотаций прямо из коробки, например:
@NotNull,@Size,@Min,@Max,@Pattern,@Email,@Past, не совсем стандартные@URL,@Length, мощнейший@ScriptAssertи многие другие. - Стандарт не ограничивает нас готовыми аннотациями и позволяет создавать свои собственные. Мы можем также создать новую аннотацию, объединив несколько других, или определить ее, используя отдельный Java-класс как валидатор.
Например, в приведенном выше примере мы можем задать аннотацию уровня класса@ValidPassportNumber, чтобы проверить, что номер паспорта соответствует формату, зависящему от значения поляcountry. - Ограничения можно ставить не только на поля или классы, но и на методы и их параметры. Этот подход называется “validation by contract” и будет рассмотрен чуть позже.
Когда пользователь отправляет введенную информацию, CUBA Platform (как и некоторые другие фреймворки) запускает Bean Validation автоматически, поэтому он мгновенно выдает сообщение об ошибке, если валидация не прошла, и нам не нужно запускать валидаторы бинов вручную.
Вернемся к примеру с номером паспорта, но на этот раз дополним его несколькими ограничениями сущности Person:
- Поле
nameдолжно состоять из 2 или более символов и быть корректным. (Как видно, regexp не простой, зато «Charles Ogier de Batz de Castelmore Comte d’Artagnan» пройдет проверку, а «R2D2» — нет); height(рост) должен быть в следующем интервале:0 < height <= 300см;- Поле
emailдолжно содержать строку, соответствующую формату корректного email.
Со всеми этими проверками класс Person будет выглядеть так:
@Listeners("passportnumber_PersonEntityListener")
@NamePattern("%s|name")
@Table(name = "PASSPORTNUMBER_PERSON")
@Entity(name = "passportnumber$Person")
@ValidPassportNumber(groups = {Default.class, UiCrossFieldChecks.class})
@FraudDetectionFlag
public class Person extends StandardEntity {
private static final long serialVersionUID = -9150857881422152651L;
@Pattern(message = "Bad formed person name: ${validatedValue}",
regexp = "^[A-Z][a-z]*(\\s(([a-z]{1,3})|(([a-z]+\\')?[A-Z][a-z]*)))*$")
@Length(min = 2)
@NotNull
@Column(name = "NAME", nullable = false)
protected String name;
@Email(message = "Email address has invalid format: ${validatedValue}",
regexp = "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$")
@Column(name = "EMAIL", length = 120)
protected String email;
@DecimalMax(message = "Person height can not exceed 300 centimeters",
value = "300")
@DecimalMin(message = "Person height should be positive",
value = "0", inclusive = false)
@Column(name = "HEIGHT")
protected BigDecimal height;
@NotNull
@Column(name = "COUNTRY", nullable = false)
protected Integer country;
@NotNull
@Column(name = "PASSPORT_NUMBER", nullable = false, length = 15)
protected String passportNumber;
...
}
Person.java
Полагаю, использование таких аннотаций, как @NotNull, @DecimalMin,@Length, @Pattern и им подобных вполне очевидно и не требует комментариев. Давайте пристальнее посмотрим на реализацию аннотации @ValidPassportNumber.
Наш новенький @ValidPassportNumber проверяет, что Person#passportNumber соответствует шаблону regexp для каждой страны, задаваемой полем Person#country.
Для начала заглянем в документацию (мануалы по CUBA или Hibernate — вполне подойдут), согласно ней, нам необходимо пометить наш класс этой новой аннотацией и передать в нее параметр groups, где UiCrossFieldChecks.class означает, что данная валидация должна быть запущена на этапе кросс-валидации — после проверки всех индивидуальных полей, а Default.class сохраняет ограничение в группе валидации по умолчанию.
Описание аннотации выглядит так:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidPassportNumberValidator.class)
public @interface ValidPassportNumber {
String message() default "Passport number is not valid";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
ValidPassportNumber.java
Здесь @Target(ElementType.TYPE) говорит, что целью этой runtime-аннотации является класс, а @Constraint(validatedBy = … ) определяет что проверку выполняет класс ValidPassportNumberValidator, реализующий интерфейс ConstraintValidator<...>. Сам код валидации находится в методе isValid(...), который и выполняет реальную проверку довольно прямолинейным образом:
public class ValidPassportNumberValidator
implements ConstraintValidator<ValidPassportNumber, Person> {
public void initialize(ValidPassportNumber constraint) {
}
public boolean isValid(Person person, ConstraintValidatorContext context) {
if (person == null)
return false;
if (person.country == null || person.passportNumber == null)
return false;
return doPassportNumberFormatCheck(person.getCountry(),
person.getPassportNumber());
}
private boolean doPassportNumberFormatCheck(CountryCode country,
String passportNumber) {
...
}
}
ValidPassportNumberValidator.java
Вот и все. С CUBA Platform нам не нужно писать ничего, кроме строки кода, которая заставит нашу кастомную валидацию работать и выдавать пользователю сообщения об ошибке.
Ничего сложного, правда?
Теперь проверим, как это все работает. Здесь у CUBA есть и другие ништяки: она не только показывает пользователю сообщение об ошибке, но и подсвечивает красным поля, не прошедшие bean validation:
Не правда ли, изящное решение? Вы получаете адекватное отображение ошибок валидации в UI, добавив лишь пару Java-аннотаций к сущностям предметной области.
Подводя итог раздела, давайте еще раз кратко перечислим плюсы Bean Validation для сущностей:
- Она понятна и читаема;
- Позволяет определять ограничения значений прямо в классах сущностей;
- Ее можно настраивать и дополнять;
- Интегрирована в популярные ORM, а проверки запускаются автоматически до того, как изменения сохранятся в БД;
- Некоторые фреймворки также запускают валидацию бинов автоматически, когда пользователь отправляет данные в UI (а если нет, нетрудно вызвать интерфейс
Validatorвручную); - Bean Validation — это признанный стандарт, и в Интернете полно документации по нему.
Но что делать, если нужно установить ограничение на метод, конструктор или REST-адрес для валидации данных, исходящих из внешней системы? Или если нужно декларативно проверить значения параметров метода, не прописывая нудный код с множеством if-else условий в каждом проверяемом методе?
Ответ прост: Bean Validation применим и к методам!
Validation by contract
Иногда бывает нужно пойти дальше валидации состояния модели данных. Многим методам может пойти на пользу автоматическая валидация параметров и возвращаемого значения. Это может быть необходимо не только для проверки данных, идущих в адреса REST или SOAP, но и в тех случаях, когда мы хотим прописать предусловия и постусловия вызовов метода, чтобы убедиться, что введенные данные были проверены до выполнения тела метода, или что возвращаемое значение находится в ожидаемом диапазоне, или нам, например, нужно просто декларативно описать диапазоны значений входных параметров для улучшения читаемости кода.
С помощью bean validation ограничения могут применяться к входным параметрам и возвращаемым значениям методов и конструкторов для проверки предусловий и постусловий их вызовов у любого Java класса. У этого пути есть несколько преимуществ перед традиционными способами проверки правильности параметров и возвращаемых значений:
- Не нужно проводить проверки вручную в императивном стиле (например, путем выкидывая
IllegalArgumentExceptionи ему подобных). Можно определить ограничения декларативно, и сделать код был более понятным и выразительным; - Ограничения можно настраивать, использовать повторно и конфигурировать: не нужно писать логику валидации для каждой проверки. Меньше кода — меньше багов.
- Если класс, возвращаемое значение метода или его параметр отмечены аннотацией
@Validated, то проверки будут автоматически выполняться платформой при каждом вызове метода. - Если выполняемый модуль отмечен аннотацией
@Documented, его пред- и постусловия будут включены в генерируемый JavaDoc.
Используя ‘валидацию по контракту’ мы получаем понятный, компактный и легко поддерживаемый код.
Для примера давайте глянем на интерфейс REST-контроллера CUBA-приложения. Интерфейс PersonApiService позволяет получить список людей из БД с помощью метода getPersons() и добавить нового человека, используя вызов addNewPerson(...).
И не забываем, что bean validation наследуется! Другими словами, если мы аннотируем некий класс, или поле, или метод, то на все классы, наследующие данный класс или реализующие данный интерфейс, будет распространяться та же самая валидационная аннотация.
@Validated
public interface PersonApiService {
String NAME = "passportnumber_PersonApiService";
@NotNull
@Valid
@RequiredView("_local")
List<Person> getPersons();
void addNewPerson(
@NotNull
@Length(min = 2, max = 255)
@Pattern(message = "Bad formed person name: ${validatedValue}",
regexp = "^[A-Z][a-z]*(\\s(([a-z]{1,3})|(([a-z]+\\')?[A-Z][a-z]*)))*$")
String name,
@DecimalMax(message = "Person height can not exceed 300 cm",
value = "300")
@DecimalMin(message = "Person height should be positive",
value = "0", inclusive = false)
BigDecimal height,
@NotNull
CountryCode country,
@NotNull
String passportNumber
);
}
PersonApiService.java
Достаточно ли понятен этот фрагмент кода?
_(За исключением аннотации @RequiredView(“_local”), специфичной для CUBA Platform и проверяющей, что возвращаемый объект Person содержит все поля из таблицы PASSPORTNUMBER_PERSON)._
Аннотация @Valid определяет, что каждый объект коллекции, возвращаемой методом getPersons(), должен валидироваться также на соответствие ограничениям класса Person.
В CUBA приложении эти методы доступны по следующим адресам:
- /app/rest/v2/services/passportnumber_PersonApiService/getPersons
- /app/rest/v2/services/passportnumber_PersonApiService/addNewPerson
Откроем приложение Postman и убедимся, что валидация работает как надо:
Как вы, возможно, заметили, в примере выше не валидируется номер паспорта. Все потому, что это поле требует перекрестной проверки параметров метода addNewPerson, так как выбор шаблона регулярного выражения для валидации passportNumber зависит от значения поля country. Такая перекрестная проверка — полный аналог ограничениям сущностей на уровне класса!
Перекрестная валидация параметров поддерживается JSR 349 и 380. Можете ознакомиться с документацией hibernate, чтобы узнать, как реализовать собственную перекрестную валидацию методов класса/интерфейса.
За пределами bean validation
Нет в мире совершенства, вот и у bean validation есть свои недостатки и ограничения:
- Иногда нам нужно просто проверить состояние сложного графа объектов перед сохранением изменений в БД. Например, нужно убедиться, что все элементы заказа покупателя помещаются в одну посылку. Это достаточно тяжёлая операция, и осуществлять ее каждый раз, когда пользователь добавляет новые предметы в заказ, не лучшая идея. Поэтому такая проверка может понадобиться только один раз: перед сохранением объекта
Orderи его подобъектовOrderItemв БД. - Некоторые проверки необходимо проводить внутри транзакции. Например, система электронного магазина должна проверять, достаточно ли экземпляров товара в наличии для исполнения заказа до его коммита в БД. Такая проверка может быть произведена только внутри транзакции, т.к. система является многопоточной и количество товара на складе может измениться в любое время.
CUBA Platform предлагает два механизма валидации данных до коммита, которые называются entity listeners и transaction listeners. Рассмотрим их подробнее.
Entity listemers
Entity listeners в CUBA очень похожи на PreInsertEvent, PreUpdateEvent и PredDeleteEvent listeners, которые JPA предлагает разработчику. Оба механизма позволяют проверять объекты сущностей до и после того, как они будут сохранены в БД.
В CUBA легко создать и подключить entity listener, для этого нужно две вещи:
- Создать управляемый bean, реализующий один из интерфейсов entity listener. Для валидации важны 3 интерфейса:
BeforeDeleteEntityListener<T>,
BeforeInsertEntityListener<T>,
BeforeUpdateEntityListener<T> - Добавить аннотацию
@Listenersк объекту сущности, который планируется отслеживать.
И все.
По сравнению со стандартом JPA (JSR 338, раздел 3.5), listener-интерфейсы CUBA Platform типизированы, поэтому вам не нужно приводить аргумент с типом Object к типу сущности, чтобы начать с ней работать. Платформа CUBA добавляет связанным сущностям или вызывающим EntityManager возможность загружать и изменять другие сущности. Все эти изменения также будут вызывать соответствующий entity listener.
Также платформа CUBA поддерживает «мягкое удаление», подход, когда вместо реального удаления записей из базы данных они только помечаются как удаленные и становятся недоступными для обычного использования. Так, для мягкого удаления платформа вызывает слушателей BeforeDeleteEntityListener / AfterDeleteEntityListener в то время, как стандартные реализации вызывали бы слушателей PreUpdate / PostUpdate.
Давайте посмотрим на пример. Здесь Event listener bean подключается к классу сущности всего одной строкой кода: аннотацией @Listeners, которая принимает имя класса слушателя:
@Listeners("passportnumber_PersonEntityListener")
@NamePattern("%s|name")
@Table(name = "PASSPORTNUMBER_PERSON")
@Entity(name = "passportnumber$Person")
@ValidPassportNumber(groups = {Default.class, UiCrossFieldChecks.class})
@FraudDetectionFlag
public class Person extends StandardEntity {
...
}
Person.java
Сама реализация слушателя выглядит так:
/**
* Checks that there are no other persons with the same
* passport number and country code
* Ignores spaces in the passport number for the check.
* So numbers "12 45 768007" and "1245 768007" and "1245768007"
* are the same for the validation purposes.
*/
@Component("passportnumber_PersonEntityListener")
public class PersonEntityListener implements
BeforeDeleteEntityListener<Person>,
BeforeInsertEntityListener<Person>,
BeforeUpdateEntityListener<Person> {
@Override
public void onBeforeDelete(Person person, EntityManager entityManager) {
if (!checkPassportIsUnique(person.getPassportNumber(),
person.getCountry(), entityManager)) {
throw new ValidationException(
"Passport and country code combination isn't unique");
}
}
@Override
public void onBeforeInsert(Person person, EntityManager entityManager) {
// use entity argument to validate the Person object
// entityManager could be used to access database
// if you need to check the data
// throw ValidationException object if validation check failed
if (!checkPassportIsUnique(person.getPassportNumber(),
person.getCountry(), entityManager))
throw new ValidationException(
"Passport and country code combination isn't unique");
}
@Override
public void onBeforeUpdate(Person person, EntityManager entityManager) {
if (!checkPassportIsUnique(person.getPassportNumber(),
person.getCountry(), entityManager))
throw new ValidationException(
"Passport and country code combination isn't unique");
}
...
}
PersonEntityListener.java
Entity listeners — отличный выбор, если:
- Необходимо выполнить проверку данных внутри транзакции до того, как объект сущности будет сохранен в БД;
- Необходимо проверить данные в БД в процессе валидации, например, проверить, что в наличии достаточно товара для принятия заказа;
- Нужно просмотреть не только объект сущности, вроде
Order, но и объекты в связанные с сущностью, например, объектыOrderItemsдля сущностиOrder; - Мы хотим отследить операции вставки, обновления или удаления только для некоторых классов сущностей, например, только для сущностей
OrderиOrderItem, и нам не нужно проверять изменения в других классах сущности во время транзакции.
Transaction listeners
CUBA transaction listeners также действуют в контексте транзакций, но, по сравнению с entity listeners, они вызываются для каждой транзакции базы данных.
Эти дает им супер-силу:
- ничто не может ускользнуть от их внимания.
Но это же определяют их недостатки:
- их сложнее писать;
- они могут существенно снизить производительность;
- Их стоит писать очень внимательно: баг в transaction listener может помешать даже первичной загрузке приложения.
Итак, transaction listeners — хорошее решение, когда нужно проинспектировать разные типы сущностей по одному алгоритму, например, проверка всех данных на предмет кибер-мошенничества единым сервисом, который обслуживает все ваши бизнес-объекты.
Взгляните на образец, который проверяет, есть ли у сущности аннотация @FraudDetectionFlag, и, если есть, запускает детектор фрода. Повторюсь: имейте в виду, что этот метод вызывается в системе до коммита каждой транзакции БД, поэтому код должен стараться проверить как можно меньше объектов как можно быстрее.
@Component("passportnumber_ApplicationTransactionListener")
public class ApplicationTransactionListener
implements BeforeCommitTransactionListener {
private Logger log = LoggerFactory.getLogger(ApplicationTransactionListener.class);
@Override
public void beforeCommit(EntityManager entityManager,
Collection<Entity> managedEntities) {
for (Entity entity : managedEntities) {
if (entity instanceof StandardEntity
&& !((StandardEntity) entity).isDeleted()
&& entity.getClass().isAnnotationPresent(FraudDetectionFlag.class)
&& !fraudDetectorFeedAndFastCheck(entity))
{
logFraudDetectionFailure(log, entity);
String msg = String.format(
"Fraud detection failure in '%s' with id = '%s'",
entity.getClass().getSimpleName(), entity.getId());
throw new ValidationException(msg);
}
}
}
...
}
ApplicationTransactionListener.java
Чтобы превратиться в transaction listener, управляемый bean должен реализовывать интерфейс BeforeCommitTransactionListener и метод beforeCommit. Transaction listener’ы связываются автоматически при запуске приложения. CUBA регистрирует все классы, реализующие BeforeCommitTransactionListener или AfterCompleteTransactionListener в качестве transaction listener’ов.
Заключение
Bean validation (JPA 303, 349 и 980) — это подход, который может служить надежной основой для 95% случаев валидации данных, встречающихся в корпоративном проекте. Главное преимущество такого подхода состоит в том, что большая часть логики валидации сконцентрирована прямо в классах доменной модели. Поэтому ее легко найти, легко читать и легко поддерживать. Spring, CUBA и многие другие библиотеки поддерживают эти стандарты и автоматически выполняют проверки в рамках валидации во время получения данных на UI слое, вызова validated-методов или процесса сохранения данных через ORM, поэтому Bean validation с точки зрения разработчика часто выглядит как магия.
Некоторые разработчики ПО рассматривают валидацию на уровне классов предметной модели как неестественную и слишком сложную, говорят, что проверки данных на уровне UI — достаточно эффективная стратегия. Однако, я считаю, что многочисленные точки валидации в компонентах и контроллерах UI — не самый рациональный подход. К тому же, методы валидации, перечисленные здесь, не выглядят такими неестественными, когда они интегрированы в платформу, в которой есть валидаторы бинов и listener’ы и которая автоматически интегрирует их с клиентским слоем.
В заключение, сформулируем правила, помогающие выбрать лучший метод валидации:
- JPA валидация обладает ограниченной функциональностью, но является хорошим выбором для простейших ограничений в классах сущности, если такие ограничения могут быть отображены на DDL.
- Bean Validation — гибкий, лаконичный, декларативный, многоразовый и удобный для чтения способ настроить большинство проверок в классах предметной области. В большинстве случаев это лучший выбор, если не нужно запускать валидации внутри транзакций.
- Валидация по контракту это bean validation, но для вызовов методов. Используйте ее для входных и выходных параметров метода, например, в контроллерах REST.
- Entity listeners: хотя они и не так декларативны, как аннотации Bean Validation, они отлично подходят для проверки больших графов объектов или проверок внутри транзакции БД. Например, когда нужно считать данные из БД для принятия решения. У Hibernate есть аналог таких слушателей.
- Transaction listeners — опасное, но мощное оружие, работающее внутри контекста транзакции. Используйте его, когда в процессе исполнения нужно решить, какие объекты должны быть проверены или когда нужно проверить много разных типов сущностей по одному и тому же алгоритму валидации.
PS: Надеюсь, эта статья освежила ваши знания о различных методах валидации в корпоративных приложениях на Java, и подкинула несколько идей о том, как оптимизировать архитектуру проектов, над которыми вы работаете.
Полезные ссылки
It is usually best practice not to catch or throw unchecked expressions (IllegalArgumentException is a RuntimeException which counts as «unchecked»). See the Java Tutorials — Exceptions for more details. If you can avoid it, try rewriting your code such that a runtime exception is not needed to be caught. This is a controversial issue, but runtime exceptions exist for a reason: they help the programmer identify bugs. If you catch them, then the bug is not being fixed, it is just being avoided. Try using an if-else statement?
According to the API, «the name must match exactly an identifier used to declare an enum constant.» I believe this means the parameter is case-sensitive. In addition, the return type of the valueOf method is some type, not void, so you can’t have that statement in the try block. try blocks should contain commands or void methods, such as int x = 3; or System.out.println(3); or something.
———EDIT——-
OP, in response to your comment:
Like others here have said, it depends on what you’re trying to accomplish. I assume that since you have the line Media_Delivery.valueOf("streaming"); in the try block, that you’re attempting to see whether "streaming" is equal to one of the enum constants? In that case, you wouldn’t need an if-else statement, you could simply write
boolean result = medi_delivery.equals(Media_Delivery.Streaming.name()) ||
medi_delivery.equals(Media_Delivery.Progressive.name());
System.out.println(result);
Or even better, if you don’t want to have multiple || conditions, try a switch statement that cycles through each enum constant, testing the equality of the given string.
-Chris
PS: on naming convention, since enum constants are implicitly static final, it is common practice to declare them in all caps, such as STREAMING and PROGRESSIVE (the Java Tutorials — Enums).
Я часто видел проекты, создатели которых вообще не утруждались выбором подхода к валидации данных. Команды работали над проектом под невероятным давлением в виде сроков и размытых требований, и в итоге у них просто не оставалось времени на точную, последовательную валидацию. Поэтому, код валидации у них разбросан повсюду: в сниппетах Javascript, контроллерах экранов, в бинах бизнес-логики, сущностях предметной области, триггерах и database constraints. В этом коде было полно операторов if-else, он выбрасывал кучу исключений, и попробуй разберись, где там у них валидируется этот конкретный кусок данных… Как результат, по мере развития проекта становится тяжело и дорого соблюдать и требования (зачастую довольно путаные), и единообразие подходов к валидации данных.
Так есть ли какой-то простой и изящный способ валидации данных? Способ, который защитит нас от греха нечитаемости, способ, который соберет всю логику валидации воедино, и который уже создан за нас разработчиками популярных Java-фреймворков?
Да, такой способ существует.
Для нас, разработчиков платформы CUBA, очень важно, чтобы вы могли пользоваться передовыми практиками. Мы считаем, что код валидации должен:
- Быть переиспользуемым и следовать принципу DRY;
- Быть естественным и понятным;
- Помещаться там, где его ожидает увидеть разработчик;
- Уметь проверять данные из разных источников: пользовательского интерфейса, вызовов SOAP, REST и т.д.
- Без проблем работать в многопоточной среде;
- Вызываться внутри приложения автоматически, без необходимости запускать проверки вручную;
- Выдавать пользователю понятные, локализованные сообщения в лаконичных диалоговых окнах;
- Следовать стандартам.
Давайте посмотрим, как это можно реализовать на примере приложения, написанного с использованием фреймворка CUBA Platform. Однако, так как CUBA построена на основе Spring и EclipseLink, большинство используемых здесь приемов будет работать и на любой другой Java платформе, поддерживающей спецификации JPA и Bean Validation.
Валидация с использованием database constraints
Пожалуй, самый распространенный и очевидный способ валидации данных — это использование ограничений на уровне БД, например, флаг required (для полей, значение которых не может быть пустым), длина строки, уникальные индексы и т.д. Такой способ больше всего подходит для корпоративных приложений, так как этот тип ПО обычно строго ориентирован на обработку данных. Тем не менее, даже здесь разработчики часто совершают ошибки, задавая ограничения отдельно для каждого уровня приложения. Чаще всего причина кроется в распределении обязанностей между разработчиками.
Рассмотрим пример, который большинству из нас знаком, некоторым даже по собственному опыту… Если спецификация гласит, что в поле номера паспорта должно быть 10 знаков, весьма вероятно, что проверяться это будет всеми: архитектором БД в DDL, backend-разработчиком в соответствующих Entity и REST сервисах, и наконец, разработчиком UI непосредственно на стороне клиента. Затем это требование меняется, и поле возрастает до 15 знаков. Девопсы меняют значения constraints в БД, но для пользователя ничего не меняется, ведь на стороне клиента ограничение все то же…
Любой разработчик знает, как избежать этой проблемы, — валидация должна быть централизована! В CUBA такая валидация находится в JPA-аннотациях к сущностям. Основываясь на этой метаинформации, CUBA Studio сгенерирует верный DDL-скрипт и применит соответствующие валидаторы на стороне клиента.
Если аннотации изменятся, CUBA обновит DDL-скрипты и сгенерирует миграционные скрипты, поэтому в следующий раз при развертывании проекта новые ограничения на основе JPA вступят в силу и в интерфейсе и в базе данных приложения.
Несмотря на простоту и реализацию на уровне БД, дающую абсолютную надежность этому методу, область применения JPA аннотаций ограничена самыми простыми случаями, которые могут быть выражены в стандарте DDL, и не включают триггеры БД или хранимые процедуры. Так что, ограничения, основанные на JPA могут делать поле сущности уникальным или обязательным или задавать максимальную длину столбца. Можно даже задать уникальное ограничение для комбинации колонок с помощью аннотации @UniqueConstraint. Но на этом, пожалуй, все.
Как бы то ни было, в случаях, требующих более сложной логики валидации, вроде проверки поля на минимальное/максимальное значение, валидации при с помощи регулярного выражения, или выполнения кастомной проверки, свойственной только вашему приложению, применяется подход, известный как «Bean Validation».
Bean validation
Всем известно, что хорошей практикой является следование стандартам, имеющим длинный жизненный цикл, чья эффективность доказана на тысячах проектов. Java Bean Validation — это подход, зафиксированный в JSR 380, 349 и 303 и их применениях: Hibernate Validator и Apache BVal.
Хотя этот подход знаком многим разработчикам, его часто недооценивают. Это простой способ встраивать валидацию данных даже в legacy-проекты, который позволяет выстраивать проверки понятно, просто, надежно и максимально близко к бизнес-логике.
Использование Bean Validation дает проекту немало преимуществ:
- Логика валидации расположена рядом с предметной областью: определение ограничений для полей и методов бина происходит естественным и по-настоящему объектно-ориентированным образом.
- Стандарт Bean Validation дает нам десятки валидационных аннотаций прямо из коробки, например: @NotNull, @Size, @Min, @Max, @Pattern, @Email, @Past, не совсем стандартные @URL, @Length, мощнейший @ScriptAssert и многие другие.
- Стандарт не ограничивает нас готовыми аннотациями и позволяет создавать свои собственные. Мы можем также создать новую аннотацию, объединив несколько других, или определить ее, используя отдельный Java-класс как валидатор.
- Например, в приведенном выше примере мы можем задать аннотацию уровня класса @ValidPassportNumber, чтобы проверить, что номер паспорта соответствует формату, зависящему от значения поля country.
- Ограничения можно ставить не только на поля или классы, но и на методы и их параметры. Этот подход называется “validation by contract” и будет рассмотрен чуть позже.
Когда пользователь отправляет введенную информацию, CUBA Platform (как и некоторые другие фреймворки) запускает Bean Validation автоматически, поэтому он мгновенно выдает сообщение об ошибке, если валидация не прошла, и нам не нужно запускать валидаторы бинов вручную.
Вернемся к примеру с номером паспорта, но на этот раз дополним его несколькими ограничениями сущности Person:
- Поле name должно состоять из 2 или более символов и быть корректным. (Как видно, regexp не простой, зато «Charles Ogier de Batz de Castelmore Comte d’Artagnan» пройдет проверку, а «R2D2» — нет);
- height (рост) должен быть в следующем интервале: 0 < height <= 300 см;
- Поле email должно содержать строку, соответствующую формату корректного email.
Со всеми этими проверками класс Person будет выглядеть так:
Полагаю, использование таких аннотаций, как @NotNull, @DecimalMin,@Length, @Pattern и им подобных вполне очевидно и не требует комментариев. Давайте пристальнее посмотрим на реализацию аннотации @ValidPassportNumber.
Наш новенький @ValidPassportNumber проверяет, что Person#passportNumber соответствует шаблону regexp для каждой страны, задаваемой полем Person#country.
Для начала заглянем в документацию (мануалы по CUBA или Hibernate — вполне подойдут), согласно ней, нам необходимо пометить наш класс этой новой аннотацией и передать в нее параметр groups, где UiCrossFieldChecks.class означает, что данная валидация должна быть запущена на этапе кросс-валидации — после проверки всех индивидуальных полей, а Default.class сохраняет ограничение в группе валидации по умолчанию.
Описание аннотации выглядит так:
Здесь @Target(ElementType.TYPE) говорит, что целью этой runtime-аннотации является класс, а @Constraint(validatedBy = … ) определяет что проверку выполняет класс ValidPassportNumberValidator, реализующий интерфейс ConstraintValidator<…>. Сам код валидации находится в методе isValid(…), который и выполняет реальную проверку довольно прямолинейным образом:
Вот и все. С CUBA Platform нам не нужно писать ничего, кроме строки кода, которая заставит нашу кастомную валидацию работать и выдавать пользователю сообщения об ошибке. Ничего сложного, правда?
Теперь проверим, как это все работает. Здесь у CUBA есть и другие ништяки: она не только показывает пользователю сообщение об ошибке, но и подсвечивает красным поля, не прошедшие bean validation:
Не правда ли, изящное решение? Вы получаете адекватное отображение ошибок валидации в UI, добавив лишь пару Java-аннотаций к сущностям предметной области.
Подводя итог раздела, давайте еще раз кратко перечислим плюсы Bean Validation для сущностей:
- Она понятна и читаема;
- Позволяет определять ограничения значений прямо в классах сущностей;
- Ее можно настраивать и дополнять;
- Интегрирована в популярные ORM, а проверки запускаются автоматически до того, как изменения сохранятся в БД;
- Некоторые фреймворки также запускают валидацию бинов автоматически, когда пользователь отправляет данные в UI (а если нет, нетрудно вызвать интерфейс Validator вручную);
- Bean Validation — это признанный стандарт, и в Интернете полно документации по нему.
Но что делать, если нужно установить ограничение на метод, конструктор или REST-адрес для валидации данных, исходящих из внешней системы? Или если нужно декларативно проверить значения параметров метода, не прописывая нудный код с множеством if-else условий в каждом проверяемом методе?
Ответ прост: Bean Validation применим и к методам!
Validation by contract
Иногда бывает нужно пойти дальше валидации состояния модели данных. Многим методам может пойти на пользу автоматическая валидация параметров и возвращаемого значения. Это может быть необходимо не только для проверки данных, идущих в адреса REST или SOAP, но и в тех случаях, когда мы хотим прописать предусловия и постусловия вызовов метода, чтобы убедиться, что введенные данные были проверены до выполнения тела метода, или что возвращаемое значение находится в ожидаемом диапазоне, или нам, например, нужно просто декларативно описать диапазоны значений входных параметров для улучшения читаемости кода.
С помощью bean validation ограничения могут применяться к входным параметрам и возвращаемым значениям методов и конструкторов для проверки предусловий и постусловий их вызовов у любого Java класса. У этого пути есть несколько преимуществ перед традиционными способами проверки правильности параметров и возвращаемых значений:
- Не нужно проводить проверки вручную в императивном стиле (например, путем выкидывая IllegalArgumentException и ему подобных). Можно определить ограничения декларативно, и сделать код был более понятным и выразительным;
- Ограничения можно настраивать, использовать повторно и конфигурировать: не нужно писать логику валидации для каждой проверки. Меньше кода — меньше багов.
- Если класс, возвращаемое значение метода или его параметр отмечены аннотацией @Validated, то проверки будут автоматически выполняться платформой при каждом вызове метода.
- Если выполняемый модуль отмечен аннотацией @Documented, его пред- и постусловия будут включены в генерируемый JavaDoc.
Используя ‘валидацию по контракту’ мы получаем понятный, компактный и легко поддерживаемый код.
Для примера давайте глянем на интерфейс REST-контроллера CUBA-приложения. Интерфейс PersonApiService позволяет получить список людей из БД с помощью метода getPersons() и добавить нового человека, используя вызов addNewPerson(…).
И не забываем, что bean validation наследуется! Другими словами, если мы аннотируем некий класс, или поле, или метод, то на все классы, наследующие данный класс или реализующие данный интерфейс, будет распространяться та же самая валидационная аннотация.
Достаточно ли понятен этот фрагмент кода? (За исключением аннотации @RequiredView(“_local”), специфичной для CUBA Platform и проверяющей, что возвращаемый объект Person содержит все поля из таблицы PASSPORTNUMBER_PERSON).
Аннотация @Valid определяет, что каждый объект коллекции, возвращаемой методом getPersons(), должен валидироваться также на соответствие ограничениям класса Person.
В CUBA приложении эти методы доступны по следующим адресам:
- /app/rest/v2/services/passportnumber_PersonApiService/getPersons
- /app/rest/v2/services/passportnumber_PersonApiService/addNewPerson
Откроем приложение Postman и убедимся, что валидация работает как надо:
Как вы, возможно, заметили, в примере выше не валидируется номер паспорта. Все потому, что это поле требует перекрестной проверки параметров метода addNewPerson, так как выбор шаблона регулярного выражения для валидации passportNumber зависит от значения поля country. Такая перекрестная проверка — полный аналог ограничениям сущностей на уровне класса!
Перекрестная валидация параметров поддерживается JSR 349 и 380. Можете ознакомиться с документацией hibernate, чтобы узнать, как реализовать собственную перекрестную валидацию методов класса/интерфейса.
За пределами bean validation
Нет в мире совершенства, вот и у bean validation есть свои недостатки и ограничения:
- Иногда нам нужно просто проверить состояние сложного графа объектов перед сохранением изменений в БД. Например, нужно убедиться, что все элементы заказа покупателя помещаются в одну посылку. Это достаточно тяжёлая операция, и осуществлять ее каждый раз, когда пользователь добавляет новые предметы в заказ, не лучшая идея. Поэтому такая проверка может понадобиться только один раз: перед сохранением объекта Order и его подобъектов OrderItem в БД.
- Некоторые проверки необходимо проводить внутри транзакции. Например, система электронного магазина должна проверять, достаточно ли экземпляров товара в наличии для исполнения заказа до его коммита в БД. Такая проверка может быть произведена только внутри транзакции, т.к. система является многопоточной и количество товара на складе может измениться в любое время.
CUBA Platform предлагает два механизма валидации данных до коммита, которые называются entity listeners и transaction listeners. Рассмотрим их подробнее.
Entity listemers
Entity listeners в CUBA очень похожи на PreInsertEvent, PreUpdateEvent и PredDeleteEvent listeners, которые JPA предлагает разработчику. Оба механизма позволяют проверять объекты сущностей до и после того, как они будут сохранены в БД.
В CUBA легко создать и подключить entity listener, для этого нужно две вещи:
- Создать управляемый bean, реализующий один из интерфейсов entity listener. Для валидации важны 3 интерфейса:
BeforeDeleteEntityListener,
BeforeInsertEntityListener,
BeforeUpdateEntityListener - Добавить аннотацию @Listeners к объекту сущности, который планируется отслеживать.
И все.
По сравнению со стандартом JPA (JSR 338, раздел 3.5), listener-интерфейсы CUBA Platform типизированы, поэтому вам не нужно приводить аргумент с типом Object к типу сущности, чтобы начать с ней работать. Платформа CUBA добавляет связанным сущностям или вызывающим EntityManager возможность загружать и изменять другие сущности. Все эти изменения также будут вызывать соответствующий entity listener.
Также платформа CUBA поддерживает «мягкое удаление», подход, когда вместо реального удаления записей из базы данных они только помечаются как удаленные и становятся недоступными для обычного использования. Так, для мягкого удаления платформа вызывает слушателей BeforeDeleteEntityListener / AfterDeleteEntityListener в то время, как стандартные реализации вызывали бы слушателей PreUpdate / PostUpdate.
Давайте посмотрим на пример. Здесь Event listener bean подключается к классу сущности всего одной строкой кода: аннотацией @Listeners, которая принимает имя класса слушателя:
Сама реализация слушателя выглядит так:
Entity listeners — отличный выбор, если:
- Необходимо выполнить проверку данных внутри транзакции до того, как объект сущности будет сохранен в БД;
- Необходимо проверить данные в БД в процессе валидации, например, проверить, что в наличии достаточно товара для принятия заказа;
- Нужно просмотреть не только объект сущности, вроде Order, но и объекты в связанные с сущностью, например, объекты OrderItems для сущности Order;
- Мы хотим отследить операции вставки, обновления или удаления только для некоторых классов сущностей, например, только для сущностей Order и OrderItem, и нам не нужно проверять изменения в других классах сущности во время транзакции.
Transaction listeners
CUBA transaction listeners также действуют в контексте транзакций, но, по сравнению с entity listeners, они вызываются для каждой транзакции базы данных.
Эти дает им супер-силу:
- ничто не может ускользнуть от их внимания.
Но это же определяют их недостатки:
- их сложнее писать;
- они могут существенно снизить производительность;
- их стоит писать очень внимательно: баг в transaction listener может помешать даже первичной загрузке приложения.
Итак, transaction listeners — хорошее решение, когда нужно проинспектировать разные типы сущностей по одному алгоритму, например, проверка всех данных на предмет кибер-мошенничества единым сервисом, который обслуживает все ваши бизнес-объекты.
Взгляните на образец, который проверяет, есть ли у сущности аннотация @FraudDetectionFlag, и, если есть, запускает детектор фрода. Повторюсь: имейте в виду, что этот метод вызывается в системе до коммита каждой транзакции БД, поэтому код должен стараться проверить как можно меньше объектов как можно быстрее.
Чтобы превратиться в transaction listener, управляемый bean должен реализовывать интерфейс BeforeCommitTransactionListener и метод beforeCommit. Transaction listener’ы связываются автоматически при запуске приложения. CUBA регистрирует все классы, реализующие BeforeCommitTransactionListener или AfterCompleteTransactionListener в качестве transaction listener’ов.
Заключение
Bean validation (JPA 303, 349 и 980) — это подход, который может служить надежной основой для 95% случаев валидации данных, встречающихся в корпоративном проекте. Главное преимущество такого подхода состоит в том, что большая часть логики валидации сконцентрирована прямо в классах доменной модели. Поэтому ее легко найти, легко читать и легко поддерживать. Spring, CUBA и многие другие библиотеки поддерживают эти стандарты и автоматически выполняют проверки в рамках валидации во время получения данных на UI слое, вызова validated-методов или процесса сохранения данных через ORM, поэтому Bean validation с точки зрения разработчика часто выглядит как магия.
Некоторые разработчики ПО рассматривают валидацию на уровне классов предметной модели как неестественную и слишком сложную, говорят, что проверки данных на уровне UI — достаточно эффективная стратегия. Однако, я считаю, что многочисленные точки валидации в компонентах и контроллерах UI — не самый рациональный подход. К тому же, методы валидации, перечисленные здесь, не выглядят такими неестественными, когда они интегрированы в платформу, в которой есть валидаторы бинов и listener’ы и которая автоматически интегрирует их с клиентским слоем.
В заключение, сформулируем правила, помогающие выбрать лучший метод валидации:
- JPA валидация обладает ограниченной функциональностью, но является хорошим выбором для простейших ограничений в классах сущности, если такие ограничения могут быть отображены на DDL.
- Bean Validation — гибкий, лаконичный, декларативный, многоразовый и удобный для чтения способ настроить большинство проверок в классах предметной области. В большинстве случаев это лучший выбор, если не нужно запускать валидации внутри транзакций.
- Валидация по контракту — это bean validation, но для вызовов методов. Используйте ее для входных и выходных параметров метода, например, в контроллерах REST.
- Entity listeners: хотя они и не так декларативны, как аннотации Bean Validation, они отлично подходят для проверки больших графов объектов или проверок внутри транзакции БД. Например, когда нужно считать данные из БД для принятия решения. У Hibernate есть аналог таких слушателей.
- Transaction listeners — опасное, но мощное оружие, работающее внутри контекста транзакции. Используйте его, когда в процессе исполнения нужно решить, какие объекты должны быть проверены или когда нужно проверить много разных типов сущностей по одному и тому же алгоритму валидации.
PS: Надеюсь, эта статья освежила ваши знания о различных методах валидации в корпоративных приложениях на Java, и подкинула несколько идей о том, как оптимизировать архитектуру проектов, над которыми вы работаете.
Зарегистрируйтесь для доступа к 15+ бесплатным курсам по программированию с тренажером
Валидация
—
Java: Веб-технологии
В коде ниже данные от пользователей принимаются и сохраняются как есть. То есть мы полагаем, что пользователи вводят данные корректно:
app.post("/users", ctx -> {
var name = ctx.formParam("name");
var email = ctx.formParam("email");
var password = ctx.formParam("password");
var passwordConfirmation = ctx.formParam("passwordConfirmation");
var course = new User(name, email, password);
UserRepository.save(course);
ctx.redirect("/users");
});
На практике такого не бывает. Данные могут быть неполными, ошибочными или неподходящими для нашей системы. Поэтому получив данные от пользователя, мы должны их проверить. Такая проверка называется валидацией.
Валидация включает два основных элемента:
- Проверка входных данных на корректность — например, заполнены ли обязательные поля или совпадает ли пароль и его подтверждения
- Проверка возможности выполнить операцию — например, мы не сможем зарегистрировать нового пользователя, если он пытается ввести почту, связанную с уже существующим аккаунтом
Проверка корректности данных
Проверить корректность введённого пароля можно простым сравнением:
var password = ctx.formParam("password");
var passwordConfirmation = ctx.formParam("passwordConfirmation");
if (password != passwordConfirmation) {
// Что-то делаем, если пароли не совпали
}
Что делать дальше? Будет логично, если мы отобразим ту же форму с двумя важными дополнениями:
- Нужно сохранить введенные данные, чтобы пользователю не пришлось заполнять все поля заново
- Нужно вывести сообщения об ошибках — например, списком над формой
Посмотрим сразу на готовый код для решения этой задачи, а потом разберем его.
Шаг 1. Создаем дата-класс, который передает в шаблон данные формы и ошибки. Ошибки передаются в виде объекта, который формирует Javalin в случае ошибки валидации:
package org.example.hexlet.dto.users;
import java.util.List;
import java.util.Map;
import io.javalin.validation.ValidationError;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class BuildUserPage {
private String name;
private String email;
private Map<String, List<ValidationError<Object>>> errors;
}
Шаг 2. Расширяем обработчик. Добавляем встроенный механизм валидации Javalin и обработку его исключений:
app.post("/users", ctx -> {
var name = ctx.formParam("name");
var email = ctx.formParam("email");
try {
var passwordConfirmation = ctx.formParam("passwordConfirmation");
var password = ctx.formParamAsClass("password", String.class)
.check(value -> value == passwordConfirmation, "Пароли не совпадают")
.get();
var user = new User(name, email, password);
UserRepository.save(user);
ctx.redirect("/users");
} catch (ValidationException e) {
var page = new BuildUserPage(name, email, e.getErrors());
ctx.render("users/new.jte", Collections.singletonMap("page", page));
}
});
Валидация в Javalin работает через методы *AsClass. Они возвращают валидатор, который содержит набор методов для дополнительных проверок. Здесь мы использовали метод check(), который принимает на вход два параметра:
- Функцию-предикат, в которой выполняется проверка
- Строку, которую мы выведем при проваленной проверке
Если валидация провалена, Javalin выкидывает исключение ValidationException. Далее мы перехватываем его, чтобы отрисовать форму. Затем мы идем в блок catch, берем данные формы и это исключение, после чего передаем все в шаблон users/new.jte на отрисовку.
Шаг 3. Заполняем шаблон данными и выводим ошибки:
@import org.example.hexlet.dto.BuildUserPage
@param BuildUserPage page
@if(page.getErrors() != null)
<ul>
@for(var validator : page.getErrors().values())
@for(var error : validator)
<li>${error.getMessage()}</li>
@endfor
@endfor
</ul>
@endif
<form action="/users" method="post">
<div>
<label>
Имя
<input type="text" name="name" value="${page.getName()}" />
</label>
</div>
<div>
<label>
Email
<input type="email" required name="email" value="${page.getEmail()}" />
</label>
</div>
<div>
<label>
Пароль
<input type="password" required name="password" />
</label>
</div>
<div>
<label>
Подтверждение пароля
<input type="password" required name="passwordConfirmation" />
</label>
</div>
<input type="submit" value="Зарегистрировать" />
</form>
Чтобы заполнить данные, достаточно применить интерполяцию в нужных элементах формы. А вот вывод ошибок выглядит чуть сложнее. Все дело в структуре объектов для представления ошибок.
Каждая ошибка представляет собой Map, потому что один валидатор может генерировать несколько ошибок. Например, мы можем добавить проверку на длину пароля:
var password = ctx.formParamAsClass("password", String.class)
.check(value -> value == passwordConfirmation, "Пароли не совпадают")
.check(value -> value.length() > 6, "У пароля недостаточная длина")
.get();
Шаг 4. Дополняем обработчик формы создания пользователя, потому что теперь его шаблон работает с BuildUserPage:
app.get("/users/build", ctx -> {
var page = new BuildUserPage();
Открыть доступ
Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно
-
130 курсов, 2000+ часов теории -
1000 практических заданий в браузере -
360 000 студентов
Наши выпускники работают в компаниях:


















