Пишем свой Validation API для Spring Boot приложения

от автора

Добрый день, уважаемый читатель Хабра! Меня зовут Вартанян Артур и я работаю в компании 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”?

Варианты решения

Вот какие варианты решения задачи мы рассматривали:

  1. Можно было бы в параметры аннотаций, которые отвечают за валидацию, передать вместе с простыми сообщением соответствующий код, а затем его распарсить при формирования JSon для клиента(пример см. ниже). До # мы бы все распарсили в message, а все что после отнесли бы к code. Но такой вариант с излишними парсерами и кодами ошибок в параметрах нам показался неуместным, возможно даже костыльным в дальнейшем поддержании, потому что парсинг в данном случае был бы устроен с привязкой на всякие символы и логику считки данных до и после этих же символов.  

  2. Так как первый вариант мы решили не использовать, то на ум кроме того, как написать свою небольшую реализацию валидации с использованием всех плюшек Spring’а, больше ничего не пришло. К тому же решение, которое было бы написано нами в дальнейшем стало бы пригодным к адаптации на остальных микросервисах проекта (выдача ошибок везде аналогична) и АОП-реализация смотрится намного интереснее, чем hardcode сообщений, кодов ошибок и парсинг текста, да и времени на выполнение задачи было достаточно, поэтому мы и выбрали данный вариант.

Пример из варианта №1:

@NotBlank(message = "Name may not be null#01") private String name;

Реализация

Последовательность действий по пунктам:

  1. Создадим аннотации для валидации полей (проверка на пустоту строки и на регулярное выражение), а также аннотацию-триггер, которая будет запускать валидацию на уровне параметров метода.

  2. Распишем классы ошибок.

  3. Спроектируем интерфейсы и реализуем классы с логикой для валидации объектов, используя Java Reflection API.

  4. Приведем в действие написанный функционал с помощью Spring AOP(Aspect).

  5. Обработаем ошибки валидации и отдадим клиенту соответствующее 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


ссылка на оригинал статьи https://habr.com/ru/company/reksoft/blog/675902/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *