Преобразование Entity в DTO с помощью ModelMapper

В этой статье мы рассмотрим, как преобразовывать сущности в DTO-объекты с помощью библиотеки ModelMapper.

Пример представляет собой Spring Boot REST API, выдающее список постов и список пользователей. Поскольку пользователь содержит закрытую информацию (пароль, email, роль), то списки выдаются не в том виде, в каком они хранятся в базе, а в виде специальных объектов DTO (Data Transfer Object).  Они содержат только те поля, которые нужны на фронтенде.

Преобразование сущностей (Entity) в объекты DTO будем делать с помощью библиотеки ModelMapper , так как она удобна.

Maven-зависимость ModelMapper

Чтобы добавить библиотеку в приложение Spring Boot, в POM-файле пропишем:

<dependency>
    <groupId>org.modelmapper</groupId>
    <artifactId>modelmapper</artifactId>
    <version>2.3.9</version>
</dependency>

Бин ModelMapper в конфигурации

Сразу же создадим в конфигурации бин, который будем впоследствии внедрять в контроллер:

@Bean
public ModelMapper getMapper() {
    return new ModelMapper();
}

Сущности (Entity)

Сущности Post и User находятся в отношении ManyToOne, то есть несколько постов может быть у одного User.

Класс Post:

@Entity
@Data
@NoArgsConstructor
public class Post {
    @Id
    @GeneratedValue(generator = "sequence")
    private Long id;

    private String title;

    private String text;
    
    @CreationTimestamp    
    private LocalDateTime createdDateTime;
    @ManyToOne(fetch = FetchType.LAZY)
    private User user;
}

Класс User:

@Data
@Entity
@NoArgsConstructor
public class User {
    @Id
    @GeneratedValue(generator = "sequence")
    private Long id;

    private String email;

    private String nickname;

    @JsonInclude(JsonInclude.Include.NON_NULL)
    private String password;

    private String role="ROLE_USER";

    private boolean locked=false;

}

Поля email, password, role и locked передавать на фронтенд нежелательно.

Когда мы передаем список постов, то из всех полей пользователя достаточно оставить только nickname и id:

[
    {
        "id": 3,
        "title": "Super Post3",
        "text": "Super Text3",
        "createdDateTime": "2021-03-17T23:05:33.365513",
        "user": {
            "id": 1,
            "nickname": "admin"
        }
    },
    {
        "id": 4,
        "title": "Super Post4",
        "text": "Super Text4",
        "createdDateTime": "2021-03-17T23:05:33.371479",
        "user": {
            "id": 1,
            "nickname": "admin"
        }
    },
    {
        "id": 5,
        "title": "Super Post5",
        "text": "Super Text5",
        "createdDateTime": "2021-03-17T23:05:33.371479",
        "user": {
            "id": 1,
            "nickname": "admin"
        }
    }
]

Также есть список пользователей для администратора, и в нем нужны почти поля User, кроме пароля:

[
    {
        "id": 1,
        "email": "admin@example.com",
        "nickname": "admin",
        "role": "ROLE_ADMIN",
        "locked": false
    },
    {
        "id": 2,
        "email": "user@example.com",
        "nickname": "bob",
        "role": "ROLE_USER",
        "locked": false
    }
]

Контроллеры

Перейдем к контроллерам.

PostController — список постов

Как показано в образце JSON выше, PostController выдает список постов с очень сокращенным User — только поля id и nickname.

Просто поставить @JsonIgnore на лишних полях пользователя нельзя, т.к. в другом списке некоторые из этих полей используются.

Так что создадим для передачи поста по сети отдельный DTO-объект PostDto. Он отличается от Post тем, что вместо User содержит UserDto —  всего с двумя полями пользователя — id и nickname:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class PostDto {
    Long id;

    String title;

    String text;

    LocalDateTime createdDateTime;

    @JsonProperty("user")
    UserDto userDto;

}

Чтобы при сериализации название поля пользователя в JSON оставалось user, (а не userDto), мы аннотировали его с помощью @JsonProperty(«user»).

UserDto:

@Data
@NoArgsConstructor
public class UserDto {
    private Long id;
    private String nickname;
}

Теперь осталось преобразовать Post в PostDto. Сделаем это на уровне контроллера. Для этого внедрим в контроллер ModelMapper:

Контроллер:

@RestController
public class PostController {
    @Autowired
    private PostRepository postRepository;
    @Autowired
    private ModelMapper modelMapper;

    @GetMapping("/post")
    public List<PostDto> findAll() {
        List<Post> posts = postRepository.findAll();
        return MapperUtil.convertList(posts, this::convertToPostDto);
    }


    private PostDto convertToPostDto(Post post) {
        PostDto postDto = modelMapper.map(post, PostDto.class);
        postDto.setUserDto(convertToUserDto(post.getUser()));
        return postDto;
    }

    private UserDto convertToUserDto(User user) {
        return modelMapper.map(user, UserDto.class);
    }
}

Преобразовать Post в PostDto можно было бы и самостоятельно, создав в Post метод toPostDto(). Но с библиотекой ModelMapper это сделать проще (особенно, когда проект не такой маленький).

Преобразование Post в PostDto с помощью ModelMapper

Собственно преобразование тут в двух строках.

Преобразование User в UserDto:

modelMapper.map(user, UserDto.class);

И Post в PostDto:

PostDto postDto = modelMapper.map(post, PostDto.class);

ModelMapper сопоставляет те поля User и Post, которые есть в  UserDto и PostDto, остальные — игнорирует. Вложенный объект автоматически не сопоставляется.

Вообще ModelMapper — довольно умная библиотека, в ней можно настроить несколько режимов сопоставления, в том числе она распознает нестрогие соответствия названий полей.

MapperUtil.convertList  — это утилита для преобразования списка, она просто делает конвертацию для всех элементов списка, принимая в качестве аргумента функцию-конвертер для одного элемента:

@Configuration
public class MapperUtil {
    @Bean
    public ModelMapper getMapper() {
        return new ModelMapper();
    }

    public static <R, E> List<R> convertList(List<E> list, Function<E, R> converter) {
        return list.stream().map(e -> converter.apply(e)).collect(Collectors.toList());
    }
}

UserController — список пользователей

Аналогично внедряем ModelMapper в UserController:

@RestController
public class UserController {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private ModelMapper modelMapper;

    @GetMapping("/user")
    public List<User> getAll() {
        List<User> users = userRepository.findAll();
        return MapperUtil.convertList(users,this::convertToNoPassUser);
    }


    private User convertToNoPassUser(User user) {
        modelMapper.typeMap(User.class, User.class).addMappings(mapper -> mapper.skip(User::setPassword));
        return modelMapper.map(user, User.class);
    }

}

Но здесь уже не будем создавать отдельный UserDto для передачи пользователя без пароля. Вместо этого просто обнулим поле пароля.

Преобразование User в User без пароля

Можно было бы просто присвоить user.password=null, но для демонстрации возможностей ModelMapper сделаем это с помощью него. Чтобы ModelMapper пропустил поле password, настроим маппинг перед конвертацией:

private User convertToNoPassUser(User user) {
    modelMapper.typeMap(User.class, User.class).addMappings(mapper -> mapper.skip(User::setPassword));
    return modelMapper.map(user, User.class);
}

Аналогично преобразовываем все элементы списка с помощью утилиты MapperUtil.convertList.

Чтобы нулевое поле пароля не шло в JSON, аннотируем его:

@JsonInclude(JsonInclude.Include.NON_NULL)
private String password;

Итоги

Мы рассмотрели шаблон Data Transfer Object в REST API, реализовав его с помощью ModelMapper. Показана лишь малая часть возможностей ModelMapper, на самом деле он гораздо мощнее.

Код примера есть на GitHub.

Преобразование Entity в DTO с помощью ModelMapper: 8 комментариев

  1. Спасибо, как раз искал библиотеку для маппинга. Возникла проблема с рекурсией при сериализации объекта с Bi-directional связью.
    Кстати, а почему именно пост хранит юзера, а не наоборот, юзер лист постов? Мне казалось, что так логичнее. Я просто только учусь писать на спринге, возможно чего-то не так понимаю, буду благодарен за ответ)

    1. Надо это читать как «пост ссылается на юзера», тогда логично. В базе это будет внешний ключ user_id в таблице post, ссылающийся на user.
      Из many-to-one и one-to-many отношений надо предпочитать первое — many-to-one. Так Hibernate работает эффективнее — генерирует меньше sql-запросов.

    2. А, тут возможно сами данные в dto выглядят нелогично, потому что все посты принадлежат одному юзеру. Но это совпадение просто, надо было мне к разным юзерам посты добавить. Контроллер то все посты возвращает, независимо от чьи они.

      1. А если бы, чисто гипотетически, я бы хотел получить все посты конкретного юзера, то как правильнее было бы сделать: создать OneToMany связь от юзера к постам, либо в PostRepository создать метод findAllByUser/findAllByUserId?

  2. Внедрил его на проект. Но в ходе тестов выявили такую беду: все работает, потом в какой то момент ошибка:
    Error mapping xxx.Dto to xxx.Dto2 . Причем ошибка на всех дто где используется ModelMapper . Перезагружаю tomcat и ошибки какое то время нет, потом бац опять.

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

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