Как стать автором
Поиск
Написать публикацию
Обновить
73.6

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

Время на прочтение10 мин
Количество просмотров15K

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

Теги:
Хабы:
Всего голосов 8: ↑5 и ↓3+2
Комментарии9

Публикации

Информация

Сайт
www.reksoft.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия