Всем привет! Сегодня мы коснёмся валидации данных, входящих через Data Transfer Object (DTO), настроим аннотации и видимости — так, чтобы получать и отдавать только то, что нам нужно.

Итак, у нас есть DTO-класс UserDto, с соответствующими полями:

public class UserDto {

    private Long id;
    private String name;
    private String login;
    private String password;
    private String email;
}

Я опускаю конструкторы и геттеры-сеттеры — уверен, вы умеете их создавать, а увеличивать в 3-4 раза код смысла не вижу — представим, что они уже есть.

Мы будем принимать DTO через контроллер с CRUD-методами. Опять же, я не буду писать все методы CRUD — для чистоты эксперимента нам хватит пары. Пусть это будут create и updateName.

    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = 
    MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<UserDto> create(@RequestBody UserDto dto) {
       return new ResponseEntity<>(service.save(dto), HttpStatus.OK);
    }

    @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = 
    MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<UserDto> updateName(@RequestBody UserDto dto) {
       return new ResponseEntity<>(service.update(dto), HttpStatus.OK);
    }

Для наглядности их тоже пришлось упростить. Таким образом, мы получаем какой-то JSON, который преобразуется в UserDto, и возвращаем UserDto, который преобразуется в JSON и отправляется на клиент.

Теперь предлагаю ознакомиться с теми несколькими аннотациями валидации, с которыми мы будем работать.

    @Null //значение должно быть null
    @NotNull //значение должно быть не null
    @Email //это должен быть e-mail

Со всеми аннотациями можно ознакомиться в библиотеке javax.validation.constraints. Итак, настроим наше DTO таким образом, чтобы сразу получать валидированый объект для дальнейшего перевода в сущность и сохранения в БД. Те поля, которые должны быть заполнены, мы пометим NotNull, также пометим e-mail:

public class UserDto {

    @Null //автогенерация в БД
    private Long id;

    @NotNull
    private String name;

    @NotNull
    private String login;

    @NotNull
    private String password;

    @NotNull
    @Email
    private String email;
}

Мы задали настройки валидации для DTO — должны быть заполнены все поля, кроме id — он генерируется в БД. Добавим валидацию в контроллер:

    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = 
    MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<UserDto> create(@Validated @RequestBody UserDto dto) {
       return new ResponseEntity<>(service.save(dto), HttpStatus.OK);
    }

    @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = 
    MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<UserDto> updateName(@Validated @RequestBody UserDto dto) {
       return new ResponseEntity<>(service.update(dto), HttpStatus.OK);
    }

Настроенная таким образом валидация подойдёт к созданию нового пользователя, но не подойдёт для обновления существующих — ведь для этого нам нужно будет получить id (который задан как null), а также, пропустить поля login, password и email, поскольку в updateName мы изменяем только имя. То есть, нам нужно получить id и name, и ничего больше. И здесь нам потребуются интерфейсы видимости.

Создадим прямо в классе DTO интерфейс (для наглядности, я рекомендую выносить такие вещи в отдельный класс, а лучше, в отдельный пакет, например, transfer). Интерфейс будет называться New, второй будет называться Exist, от которого мы унаследуем UpdateName (в дальнейшем мы сможем наследовать от Exist другие интерфейсы видимости, мы же не одно имя будем менять):

public class User {

    interface New {
    }

    interface Exist {
    }
    
    interface UpdateName extends Exist {
    }

    @Null //автогенерация в БД
    private Long id;

    @NotNull
    private String name;

    @NotNull
    private String login;

    @NotNull
    private String password;

    @NotNull
    @Email
    private String email;
}

Теперь мы пометим наши аннотации интерфейсом New.

    @Null(groups = {New.class})
    private Long id;

    @NotNull(groups = {New.class})
    private String name;

    @NotNull(groups = {New.class})
    private String login;

    @NotNull(groups = {New.class})
    private String password;

    @NotNull(groups = {New.class})
    @Email(groups = {New.class})
    private String email;

Теперь эти аннотации работают только при указании интерфейса New. Нам остаётся только задать аннотации для того случая, когда нам потребуется апдейтить поле name (напомню, нам нужно указать не-нулловвыми id и name, остальные нулловыми). Вот как это выглядит:

    @Null(groups = {New.class})
    @NotNull(groups = {UpdateName.class})
    private Long id;

    @NotNull(groups = {New.class, UpdateName.class})
    private String name;

    @NotNull(groups = {New.class})
    @Null(groups = {UpdateName.class})
    private String login;

    @NotNull(groups = {New.class})
    @Null(groups = {UpdateName.class})
    private String password;

    @NotNull(groups = {New.class})
    @Null(groups = {UpdateName.class})
    @Email(groups = {New.class})
    private String email;

Теперь нам осталось задать необходимые настройки в контроллерах, прописать интерфейс, чтобы задать валидацию:

    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = 
    MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<UserDto> create(@Validated(UserDto.New.class) @RequestBody UserDto dto) {
       return new ResponseEntity<>(service.save(dto), HttpStatus.OK);
    }

    @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = 
    MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<UserDto> updateName(@Validated(UserDto.UpdateName.class) @RequestBody 
UserDto dto) {
       return new ResponseEntity<>(service.update(dto), HttpStatus.OK);
    }

Теперь для каждого метода будет вызываться свой набор настроек.

Итак, мы разобрались, как валидировать входные данные, теперь осталось валидировать выходные. Это делается при помощи аннотации @JsonView.

Сейчас в выходном DTO, который мы отдаём обратно, содержатся все поля. Но, предположим, нам не нужно никогда отдавать пароль (кроме исключительных случаев).

Для валидации выходного DTO добавим ещё два интерфейса, которые будут отвечать за видимость выходных данных — Details (для отображения пользователям) и AdminDetails (для отображения только админам). Интерфейсы могут наследоваться друг от друга, но для простоты восприятия сейчас мы делать этого не будем — достаточно примера со входными данными на этот счёт.

    interface New {
    }

    interface Exist {
    }

    interface UpdateName extends Exist {
    }

    interface Details {
    }

    interface AdminDetails {
    }

Теперь мы можем аннотировать поля так, как нам нужно (видны все, кроме пароля):

    @Null(groups = {New.class})
    @NotNull(groups = {UpdateName.class})
    @JsonView({Details.class})
    private Long id;

    @NotNull(groups = {New.class, UpdateName.class})
    @JsonView({Details.class})
    private String name;

    @NotNull(groups = {New.class})
    @Null(groups = {UpdateName.class})
    @JsonView({Details.class})
    private String login;

    @NotNull(groups = {New.class})
    @Null(groups = {UpdateName.class})
    @JsonView({AdminDetails.class})
    private String password;

    @NotNull(groups = {New.class})
    @Null(groups = {UpdateName.class})
    @Email(groups = {New.class})
    @JsonView({Details.class})
    private String email;

Осталось пометить нужные методы контроллера:

    @JsonView(Details.class)
    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = 
    MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<UserDto> create(@Validated(UserDto.New.class) @RequestBody UserDto dto) {
       return new ResponseEntity<>(service.save(dto), HttpStatus.OK);
    }

    @JsonView(Details.class)
    @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = 
    MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<UserDto> updateName(@Validated(UserDto.UpdateName.class) @RequestBody UserDto dto) {
       return new ResponseEntity<>(service.update(dto), HttpStatus.OK);
    }

А когда-нибудь в другой раз мы пометим аннотацией @JsonView(AdminDetails.class) метод, который будет дёргать только пароль. Если же мы хотим, чтобы админ получал всю информацию, а не только пароль, аннотируем соответствующим образом все нужные поля:

    @Null(groups = {New.class})
    @NotNull(groups = {UpdateName.class})
    @JsonView({Details.class, AdminDetails.class})
    private Long id;

    @NotNull(groups = {New.class, UpdateName.class})
    @JsonView({Details.class, AdminDetails.class})
    private String name;

    @NotNull(groups = {New.class})
    @Null(groups = {UpdateName.class})
    @JsonView({Details.class, AdminDetails.class})
    private String login;

    @NotNull(groups = {New.class})
    @Null(groups = {UpdateName.class})
    @JsonView({AdminDetails.class})
    private String password;

    @NotNull(groups = {New.class})
    @Null(groups = {UpdateName.class})
    @Email(groups = {New.class})
    @JsonView({Details.class, AdminDetails.class})
    private String email;

Надеюсь, эта статья помогла разобраться с валидацией входных DTO и видимостью данных выходных.

Комментарии (22)


  1. symbix
    05.12.2017 17:52

    опускаю [...] геттеры-сеттеры — уверен, вы умеете их создавать

    Я не джавист, так что вопрос может показаться глупым, извините.


    А почему геттеры-сеттеры, а не public final? DTO ведь по своей сути просто иммутабельная структура.


    1. DSolodukhin
      05.12.2017 18:44

      Так сложилось исторически ru.wikipedia.org/wiki/JavaBeans


    1. xpendence Автор
      05.12.2017 18:56

      В принципе, Вы правы, суть DTO заключается в простой передаче объекта, но в Java всё-таки лучше придерживаться спецификации Java Beans. Для DTO стандартным будет набор "конструктор со всеми полями + геттеры".


    1. xpendence Автор
      05.12.2017 20:06

      Также, допустим, Model Mapper именно сетит поля, поэтому, при его использовании нужны ещё и сеттеры.


    1. mikaakim
      05.12.2017 23:10

      Хотя бы для того, чтобы можно было поменять значение поле и сохранить тот же объект в базу, а не создавать новый этой операции.


      1. symbix
        05.12.2017 23:17

        Сохранить DTO в базу? Зачем?


  1. michael_vostrikov
    06.12.2017 08:01

    Жуть какая. Можно было просто сделать 2 разных DTO. Без всяких магических аннотаций.


    1. xpendence Автор
      06.12.2017 09:21

      Правильно, зачем вообще лезть в этот жуткий Spring?


  1. kBaHt
    06.12.2017 09:10

    А в каком пакете лежит аннотация Validation?


    1. xpendence Автор
      06.12.2017 09:11

      Для пользования нужно подключить Spring Context.


      1. kBaHt
        06.12.2017 10:33

        Вы не путаете с @Validated?


        1. xpendence Автор
          06.12.2017 19:28

          Да, опечатка. Исправлю. Писал не в IDE.


  1. AstarothAst
    06.12.2017 10:23

    Я опускаю конструкторы и геттеры-сеттеры — уверен, вы умеете их создавать, а увеличивать в 3-4 раза код смысла не вижу — представим, что они уже есть.

    Для этого придумали lombok и аннотацию Data


  1. krupt
    06.12.2017 12:54

    А какие сообщения об ошибках будут уходить в ответе? Хотелось бы взглянуть


    1. xpendence Автор
      06.12.2017 19:29

      Выбросит HttpStatus 409 и не пропустит дальше аннотации.


    1. Leffchik
      06.12.2017 22:09

      Плюсую. Также интересуют более сложные варианты валидации. К примеру, если ли пользователь с таким именем уже в БД, валидация завязанная на несколько полей сразу и проч. Будет ли продолжение?
      P.S. Также не нашёл аннотацию @Validation. Есть @Validated. Возможно перепутали?


      1. xpendence Автор
        06.12.2017 22:09

        Да, опечатался. Исправил.


      1. AstarothAst
        07.12.2017 11:16

        Не претендую на правоту, но «более сложные варианты валидации», наверное, все же лучше реализовывать махровой императивщиной, а не магическими аннотациями — чем сложнее будет маршрут валидации, тем сложнее будет понять, что, как и в каком порядке проверяется. Плюс это будет дебажится, что позволит найти ответ на рано или поздно возникнувший вопрос «а почему конкретно в этом случае не работает?»


        1. Leffchik
          07.12.2017 11:21

          Согласен. Тем не менее, интересуют варианты реализации, общий каркас, скажем так.


        1. rraderio
          07.12.2017 13:31

          Можно как-то сделать чтобы часть валидировались аннотациями а часть махровой императивщиной?


  1. pmcode
    08.12.2017 12:28

    JSR-303 неплох, но в сложных случаях декларативная логика так или иначе пасует, и то что по идее должно было упрощать код, вырождается в помойку из аннотаций. Неоднократно такое видел. Вот есть стандартный бин, и над каждым полем пачка аннотаций из 10+ строк JPA, JSR-303, Jackson, JAXB, Lombok… Хочется их уже просто в отдельный файл убирать или типа того.