Добрый день, уважаемый читатель Хабра! Меня зовут Вартанян Артур и я работаю в компании Reksoft Java-разработчиком. В данной статье мы напишем свой собственный вариант реализации валидации для объектов и его полей, используя Java Reflection Api и Spring AOP.

Недавно, на одном из наших проектов возникла необходимость отдачи сообщения клиенту при плохой валидации объекта и его полей в формате JSon по типу: текст ошибки и код ошибки, где у каждого свойства будет свое поле.
Пример:
{ "code": "04", "message": "Parsing request error region" }
Обратите внимание, что code - это не HTTP-код, а отдельная переменная, значение которой определяется в части бизнес-анализа.
Описание проблемы
Проект у нас был полностью написан на стандартном Spring Boot стеке. В том числе в приложение уже было интегрировано решение Spring Boot Validation. Конечно же, этот функционал фреймворка позволял нам быстро и без особого труда реализовать систему валидации объектов и отдавать клиенту нужный HTTP-код ошибки вместе с сообщением, но как динамично сформировать JSon-объект с заполненным полем “code”?
Варианты решения
Вот какие варианты решения задачи мы рассматривали:
Можно было бы в параметры аннотаций, которые отвечают за валидацию, передать вместе с простыми сообщением соответствующий код, а затем его распарсить при формирования JSon для клиента(пример см. ниже). До # мы бы все распарсили в message, а все что после отнесли бы к code. Но такой вариант с излишними парсерами и кодами ошибок в параметрах нам показался неуместным, возможно даже костыльным в дальнейшем поддержании, потому что парсинг в данном случае был бы устроен с привязкой на всякие символы и логику считки данных до и после этих же символов.
Так как первый вариант мы решили не использовать, то на ум кроме того, как написать свою небольшую реализацию валидации с использованием всех плюшек Spring’а, больше ничего не пришло. К тому же решение, которое было бы написано нами в дальнейшем стало бы пригодным к адаптации на остальных микросервисах проекта (выдача ошибок везде аналогична) и АОП-реализация смотрится намного интереснее, чем hardcode сообщений, кодов ошибок и парсинг текста, да и времени на выполнение задачи было достаточно, поэтому мы и выбрали данный вариант.
Пример из варианта №1:
@NotBlank(message = "Name may not be null#01") private String name;
Реализация
Последовательность действий по пунктам:
Создадим аннотации для валидации полей (проверка на пустоту строки и на регулярное выражение), а также аннотацию-триггер, кото��ая будет запускать валидацию на уровне параметров метода.
Распишем классы ошибок.
Спроектируем интерфейсы и реализуем классы с логикой для валидации объектов, используя Java Reflection API.
Приведем в действие написанный функционал с помощью Spring AOP(Aspect).
Обработаем ошибки валидации и отдадим клиенту соответствующее DTO.
Шаг 1. Аннотации
Аннотации в Java создаются таким же образом как и интерфейс, с единственной разницей в наличии символа @ перед объявленным типом.
@Target указывает, какой элемент программы будет использоваться аннотацией. В нашем случае мы указываем, что аннотация будет применима к полям (FIELD).
@Retention позволяет указать жизненный цикл аннотации. В нашем случае это время выполнения программы (RUNTIME).
Единственное, что стоит отметить в коде ниже - это абстрактный метод value(), который присутствует в RegExp. Туда будет передано регулярное выражение как параметр аннотации, исходя из которой и будет происходить валидация.
Аннотация для проверки на пустоту строки(@NotEmpty):
import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface NotEmpty { }
Аннотация для проверки на регулярное выражение(@RegExp):
import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface RegExp { String value(); }
Шаг 2. Классы ошибок
Имея в своем арсенале готовые аннотации, можно переходить к написанию классов ошибок.
Абстрактный класс RequestException, который наследуется от RuntimeException и имеет всего два метода - getCode() и getMessage():
public abstract class RequestException extends RuntimeException { public abstract String getCode(); @Override public abstract String getMessage(); }
Класс ошибки для пустой строки(@NotEmpty):
public class RequiredParameterDidNotSetException extends RequestException { private final String parameterName; public RequiredParameterDidNotSetException(String parameterName) { this.parameterName = parameterName; } @Override public String getCode() { return "01"; } @Override public String getMessage() { return "Required parameter " + this.parameterName + " was not passed"; } }
Класс ошибки для регулярного выражения(@RegExp):
public class ParameterParsingException extends RequestException { private final String parameterName; public ParameterParsingException(String parameterName) { this.parameterName = parameterName; } @Override public String getCode() { return "04"; } @Override public String getMessage() { return "Parsing request error " + this.parameterName; } }
Из интересного тут стоит отметить методы для получения code и message. При валидации у каждой аннотации будет свой класс ошибки, а сам класс будет содержать определенное сообщение и код ошибки для информирования о результате валидации.
Шаг 3. Реализация основной функциональности с использованием Reflection API
С ходу хочу отметить, что изначально новичку иерархия может показаться достаточно сложной и запутанной. Для того, чтобы было понятнее, я решил нарисовать небольшую схему иерархии классов и интерфейсов:
Интерфейс ParamValidator:
public interface ParamValidator { void validate(Object bean); }
Создание интерфейса и класса для валидации объекта.
Тут должно быть все предельно понятно. Создается интерфейс ParamValidator, который содержит в себе единственный метод с входным параметром объекта для валидации. Класс AnnotationBasedParamValidatorImpl является классом реализацией интерфейса выше. Суть его работы заключается в следующем: метод получает на вход объект и далее через рефлексию проверяет каждое поле объекта на наличие аннотаций. Если нужная аннотация присутствует, то поле отправляется на валидацию.
Может возникнуть вопрос: “а как метод понимает, в какой именно валидатор он должен отправить поле, ведь у нас их как минимум два?”
Ответ: в классе присутствует Map(validationFunctions) и Set(supportedFieldAnnotations). Во множестве у нас присутствуют все доступные аннотации, а из справочника, исходя из аннотации, подбирается нужный валидатор.
Класс AnnotationBasedParamValidatorImpl, реализующий ParamValidator:
import java.lang.annotation.Annotation; import java.lang.reflect.Field; import java.util.Map; import java.util.Set; public class AnnotationBasedParamValidatorImpl implements ParamValidator { private final Map<Class<? extends Annotation>, FieldValidator> validationFunctions; private final Set<Class<? extends Annotation>> supportedFieldAnnotations; public AnnotationBasedParamValidatorImpl(Map<Class<? extends Annotation>, FieldValidator> validationFunctions) { this.validationFunctions = validationFunctions; supportedFieldAnnotations = this.validationFunctions.keySet(); } @Override public void validate(Object param) { if (param == null) { throw new ValidationException("Passed param is null"); } Class<?> clazz = param.getClass(); Field[] declaredFields = clazz.getDeclaredFields(); for (Field field : declaredFields) { field.setAccessible(true); supportedFieldAnnotations.stream() .filter(field::isAnnotationPresent) .map(validationFunctions::get) .forEach(fieldValidator -> fieldValidator.validate(param, field)); } } }
Для объекта validationFunctions нужно сконфигурировать bean, иначе инжектить в него будет нечего.
Класс ValidationConfiguration:
import java.lang.annotation.Annotation; import java.util.HashMap; import java.util.Map; @Configuration public class ValidationConfiguration { @Bean public ParamValidator getParamValidator() { Map<Class<? extends Annotation>, FieldValidator> validatorMap = new HashMap<>(); validatorMap.put(RegExp.class, new RegularExpressionValidatorImpl()); validatorMap.put(NotEmpty.class, new NotEmptyValidatorImpl()); return new AnnotationBasedParamValidatorImpl(validatorMap); } }
Создание интерфейсов и классов для валидации строк.
Как мы уже отметили выше, наш объект отправляется к своему валидатору. Реализовано это следующим образом: имеется интерфейс FiledValidator, который имеет единственный метод с двумя входными параметрами - объект и его поле. Его реализуют классы NotEmptyValidatorImpl и RegularExpresiionValidatorImp, которые и являются непосредственно нашими валидаторами для проверки поля на пустоту и на регулярное выражение, соответственно.
Интерфейс FieldValidator:
public interface FieldValidator { void validate(Object entity, Field field); }
Класс NotEmptyValidatorImpl, реализующий FieldValidator:
import java.lang.reflect.Field; import java.util.Collection; public class NotEmptyValidatorImpl implements FieldValidator { @Override public void validate(Object entity, Field field) { try { if (Collection.class.isAssignableFrom(field.getType())) { Collection<?> fieldValue = (Collection<?>) field.get(entity); if (fieldValue == null || fieldValue.isEmpty()) { throw new RequiredParameterDidNotSetException(field.getName()); } } else if (String.class.isAssignableFrom(field.getType())) { String fieldValue = (String) field.get(entity); if (fieldValue == null || fieldValue.isEmpty()) { throw new RequiredParameterDidNotSetException(field.getName()); } } else { if (field.get(entity) == null) { throw new RequiredParameterDidNotSetException(field.getName()); } } } catch (IllegalAccessException e) { throw new ValidationException(e); } } }
Класс RegularExpressionValidatorImpl, реализующий FieldValidator:
import java.lang.reflect.Field; public class RegularExpressionValidatorImpl implements FieldValidator { @Override public void validate(Object entity, Field field) { if (String.class.isAssignableFrom(field.getType())) { RegExp annotation = field.getAnnotation(RegExp.class); String regex = annotation.value(); try { String fieldValue = (String) field.get(entity); if (fieldValue != null && !fieldValue.matches(regex)) { throw new ParameterParsingException(field.getName()); } } catch (IllegalAccessException e) { throw new ValidationException(e); } } } }
Шаг 4. Добавление Spring Aspect
Нам осталось реализовать “триггер”, который будет запускать валидацию параметров каждый раз, как будет этот метод вызываться. Это можно сделать явным образом, вызывая в коде наши валидаторы для каждого отдельного параметра, а можно применить немного АОП и сквозным функционалом выполнять промежуточные действия. Так мы получим более гибкое решение, упрощенное чтение и обслуживание кода. Для тех, кто еще не сталкивался с аспектами, вот описание из википедии:
Аспект(англ. aspect) — модуль или класс, реализующий сквозную функциональность. Аспект изменяет поведение остального кода, применяя совет в точках соединения, определенных некоторым срезом.
1) Создадим новую аннотацию, которая будет размещаться над методом, входные параметры которого нужно будет валидировать:
import java.lang.annotation.*; @Documented @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface ValidParams { }
2) Добавим класс MethodParamValidationAspect, сделаем Inject нашего ParamValidator. Используя JoinPoint, переберем параметры метода и отправим на валидацию:
import java.util.stream.Stream; @Aspect @Component public class MethodParamValidationAspect { private final ParamValidator validator; public MethodParamValidationAspect(ParamValidator validator) { this.validator = validator; } @Before(value = "@annotation(ru.validation.validation.annotation.ValidParams)") public void validateParameters(JoinPoint joinPoint) { Stream.of(joinPoint.getArgs()).forEach(validator::validate); } }
Из интересного тут стоить отметить аннотацию @Before. В нее мы должны передать путь до нашей собственной аннотации, к которой и нужно привязать действия данного метода.
Шаг 6. Обработка ошибок валидации и формирование DTO
На самом последнем этапе необходимо заполнить DTO, которое будет возвращаться клиенту при ошибке валидации. Я решил реализовать это путем добавления ошибки в обработчик ExceptionAdvice, но вы можете забрать code и message из классов ошибок по своему.
Класс ErrorDTO:
public class ErrorDto { private String code; private String message; public String getCode() { return code; } public void setCode(String code) { this.code = code; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } }
Класс ExceptionAdvice для перехвата ошибок:
import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import ru.validation.dto.ErrorDto; @ControllerAdvice public class ExceptionAdvice extends ResponseEntityExceptionHandler { @ResponseBody @ExceptionHandler({ParameterParsingException.class, RequiredParameterDidNotSetException.class}) @ResponseStatus(HttpStatus.BAD_REQUEST) protected ErrorDto requestParameterExceptionHandler(RequestException ex) { return ErrorDto.builder() .code(ex.getCode()) .message(ex.getMessage()) .build(); } }
Проверка работоспособности:
Проверим, как весь наш функционал будет работать на практике.
Создадим класс DeliveryRequestDto и навесим над полями наши аннотации:
public class DeliveryRequestDto { @NotEmpty @RegExp(value = "^[а-яА-ЯёЁ .'-]+$") private String region; @NotEmpty @RegExp(value = "^[а-яА-ЯёЁ .'-]+$") private String city; public String getRegion() { return region; } public void setRegion(String region) { this.region = region; } public String getCity() { return city; } public void setCity(String city) { this.city = city; } }
А теперь распишем метод в классе контроллера:
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import ru.validation.dto.DeliveryRequestDto; import ru.validation.validation.annotation.ValidParams; @RestController public class DeliveryController { @ValidParams @GetMapping("/check") public void checkDeliveryAvailability(@RequestBody DeliveryRequestDto requestDto) { System.out.println("Validation!"); } }
Единственное, что нас тут интересует - этот наличие входного параметра у метода checkDeliveryAvailability(DeliveryRequestDto requestDto) и аннотация @ValidParams, которая висит над методом и запускает процесс валидации.
Откроем PostMan и попробуем вызвать метод:
Дополнение:
Для еще большей динамичности и легкости в дальнейшей поддержке можно вынести текст ошибки и код(message, code) в отдельный файл, передавая в java-код через параметр для переменной. Таким образом, можно будет менять текст без проникновения в исходный код, и легко добавить поддержку локализации в проект.
Пример класса ошибки RequiredParameterDidNotSetException:
public class RequiredParameterDidNotSetException extends RequestException { private final String parameterName; @Value(${client.code}) private String code; @Value(${client.message}) private String message; public RequiredParameterDidNotSetException(String parameterName) { this.parameterName = parameterName; } @Override public String getCode() { return this.code; } @Override public String getMessage() { return this.message + parametrName; } }
Резюме:
Мы реализовали свой вариант валидации, которая ничем не хуже Spring Validation. Плюс ко всему, у нас добавилась возможность динамично отдавать сообщение с кодом, а также писать свои классы ошибок и выдавать клиенту при плохой валидации.
Надеюсь статья была интересной!
Исходный код можно посмотреть тут: https://github.com/University-and-Education/Validation
