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


  1. gleb_l
    11.07.2022 14:10
    +1

    Что будет, если критерии валидации одного поля зависят от другого?


    1. rogue06 Автор
      11.07.2022 14:23
      -2

      Можете привести пример?


      1. upagge
        11.07.2022 15:57

        В спринговом валидаторе можно проверять переменные метода, при этом учитывая зависимость между аргументами. Например, когда вам передают 2 даты (от и до), и надо проверить, что дата "от" меньше, чем дата "до". Думаю речь об этом


  1. gleb_l
    11.07.2022 15:07
    +1

    Дропдаун тип документа {паспорт РФ, загранпаспорт, права, свидетельство о рождении}

    Следующее поле - номер документа - text с вводом и валидацией по маске в зависимости от значения первого.


    1. rogue06 Автор
      11.07.2022 15:33
      -2

      Прошу прощения, но я не особо понял пример.

      То есть я ввел серию, после чего номер, и номер валидируется исходя из валидации серии?


      1. gleb_l
        11.07.2022 15:36

        Нет. Сначала выбираете тип документа, и в зависимости от этого выбора поле номера документа валидируется по-разному.


  1. 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) и засовываем в ответ.


    1. Dmitry2019
      12.07.2022 09:28
      +1

      Кроме кода ошибки нужно ещё передать параметры ошибки, чтобы вставить в локалзованную строку.