В этой статье мы рассмотрим, как преобразовывать сущности в 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.
Спасибо, как раз искал библиотеку для маппинга. Возникла проблема с рекурсией при сериализации объекта с Bi-directional связью.
Кстати, а почему именно пост хранит юзера, а не наоборот, юзер лист постов? Мне казалось, что так логичнее. Я просто только учусь писать на спринге, возможно чего-то не так понимаю, буду благодарен за ответ)
Надо это читать как «пост ссылается на юзера», тогда логично. В базе это будет внешний ключ user_id в таблице post, ссылающийся на user.
Из many-to-one и one-to-many отношений надо предпочитать первое — many-to-one. Так Hibernate работает эффективнее — генерирует меньше sql-запросов.
А, тут возможно сами данные в dto выглядят нелогично, потому что все посты принадлежат одному юзеру. Но это совпадение просто, надо было мне к разным юзерам посты добавить. Контроллер то все посты возвращает, независимо от чьи они.
А если бы, чисто гипотетически, я бы хотел получить все посты конкретного юзера, то как правильнее было бы сделать: создать OneToMany связь от юзера к постам, либо в PostRepository создать метод findAllByUser/findAllByUserId?
метод
вложенные поля можно конвертировать через createTypeMap
Внедрил его на проект. Но в ходе тестов выявили такую беду: все работает, потом в какой то момент ошибка:
Error mapping xxx.Dto to xxx.Dto2 . Причем ошибка на всех дто где используется ModelMapper . Перезагружаю tomcat и ошибки какое то время нет, потом бац опять.