Добрый день, уважаемый читатель Хабра! Меня зовут Вартанян Артур и я работаю в компании 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
Комментарии (8)
gleb_l
11.07.2022 15:07+1Дропдаун тип документа {паспорт РФ, загранпаспорт, права, свидетельство о рождении}
Следующее поле - номер документа - text с вводом и валидацией по маске в зависимости от значения первого.
rogue06 Автор
11.07.2022 15:33-2Прошу прощения, но я не особо понял пример.
То есть я ввел серию, после чего номер, и номер валидируется исходя из валидации серии?
gleb_l
11.07.2022 15:36Нет. Сначала выбираете тип документа, и в зависимости от этого выбора поле номера документа валидируется по-разному.
Sad_Bro
11.07.2022 18:22+1Подобную проблему можно решить следующим образом,- используя стандартный валидатор.
У любой аннотации javax.validation есть параметр message, туда можно внести готовое сообщение, но это нам не подходит, так как, как правило, требуется локализация и поэтому в message кидаем ключ сообщения из message bundle. Туда же, в параметр message можно положить код ошибки, например
@RegExp(value = "^[а-яА-ЯёЁ .'-]+$", message = "wrong.validation.email.{777}")
Теперь в ControllerAdvice из стандартного исключения достаем шаблон что мы указали в dto, используя этот шаблон достаем локализованное сообщение из бандла, так как шаблон содержит код, то путем не хитрого парсинга достаем его (777) и засовываем в ответ.Dmitry2019
12.07.2022 09:28+1Кроме кода ошибки нужно ещё передать параметры ошибки, чтобы вставить в локалзованную строку.
gleb_l
Что будет, если критерии валидации одного поля зависят от другого?
rogue06 Автор
Можете привести пример?
upagge
В спринговом валидаторе можно проверять переменные метода, при этом учитывая зависимость между аргументами. Например, когда вам передают 2 даты (от и до), и надо проверить, что дата "от" меньше, чем дата "до". Думаю речь об этом