Всем привет, мои дорогие друзья и читатели!
Перед тем, как будем писать статью, немного предыстории… Недавно столкнулся с одной проблемой в работе с библиотекой Mapstruct, которую бегло описал в своем телеграм-канале здесь. В комментариях проблему к записи решили, в этом помог мой коллега по прошлому проекту.
После этого я решил написать на эту тему статью, но мы конечно же не будем узко смотреть и постараемся вначале войти в курс дела, понять, что такое Mapstruct и зачем он нужен, и уже на реальном примере разберем возникшую ранее ситуацию и как ее решить. Поэтому настоятельно рекомендую проделать все выкладки параллельно чтению статьи, дабы ощутить все на практике.
Перед началом — подпишись на мой телеграм-канал, я там собираю свою деятельность, пишу мысли о разработке на Java и IT в целом.
Подписался? Отлично! Ну что ж, теперь поехали!
Mapstruct, чаво?
A code generator for fast type-safe bean mappings.
Первая наша задача — разобраться, что такое Mapstruct и зачем он нам. В общем и целом можно почитать о нем на официальном сайте. На главной странице сайта написаны три ответа на вопросы: что это такое? зачем? как?
Постараемся и мы так сделать:
Что это такое?
Mapstruct — это библиотека, которая помогает сопоставлять (маппить, в целом, так всегда и говорят: маппить, замапить и т.д.) объекты одних сущностей в объекты других сущностей при помощи сгенерированного кода на основе конфигураций, которые описываются через интерфейсы.
Зачем?
В большинстве своем мы разрабатываем многослойные приложения (слой работы с базой, слой бизнес логики, слой взаимодействия приложения с внешним миром) и каждый слой имеет свои объекты для хранения и обработки данных.
И эти данные нужно передавать из слоя в слой путем перевода из одной сущности в другую.
Для тех, кто не работал с таким подходом, это может показаться несколько сложным. Например, у нас есть сущность к базе данных Student. Когда данные этой сущности переходят в слой бизнес-логики (сервисов), нам нужно перевести данные из класса Student в класс StudentModel. Далее, после всех манипуляций с бизнес логикой, данные нужно выдать наружу. И для этого у нас есть класс StudentDto. Разумеется, нам нужно передать данные из класса StudentModel в StudentDto.
Писать руками каждый раз методы, которые будут переносить, трудоемко. Плюс это лишний код в кодовой базе, который нужно поддерживать. Можно допустить ошибку.
А Mapstruct такие методы генерирует на этапе компиляции и хранит в generated-sources.
Как?
При помощи аннотаций. Нам необходимо просто создать аннотацию, в которой будет главная аннотация Mapper, которая скажет библиотеке, что методы в этом интерфейсе можно использовать для перевода из одних объектов в другие.
Как я сказал раньше про студентов, в нашем случае это будет интерфейс StudentMapper, в котором будут несколько методов по перегонке данных из одного слоя в другой:
public class Student {
private Long id;
private String firstName;
private String lastName;
private Integer age;
}
public class StudentDTO {
private Long id;
private String firstName;
private String lastName;
private Integer age;
}
public class StudentModel {
private Long id;
private String firstName;
private String lastName;
private Integer age;
}
Для этих классов создаем маппер (здесь и далее так мы будем называть интерфейс, который описывает что мы хотим перевести и куда):
Красота этого подхода в том, что если в разных классах имена и тип полей совпадают (как в нашем случае), то настроек для Mapstruct хватит, чтобы на основании интерфейса StudentMapper на этапе компиляции сгенерировать нужную реализацию, которая будет переводить.
Так уже стало понятнее, да? Пойдем дальше, и на реальном примере разберем работу в Spring Boot приложении.
Пример работы Spring Boot и Mapstruct
Первое, что нам нужно — это создать Spring Boot проект и добавить в него Mapstruct.
Для этого дела у меня есть организация в GitHub с шаблонами для репозиториев и старт для Spring Boot один из них.
На его основе создаем новый проект:
Далее получим проект.
Да, друзья, ставьте звезду проекту, если нашли его полезным, так я буду знать, что делаю это не зря.
В этом проекте раскроем ситуацию, которую я получил на работе и описал в посте у себя в телеграм-канале.
Вкратце обрисую ситуацию для тех, кто не в теме: когда пишем тесты на мапперы (то есть на те реализации интерфейсов, о которых мы говорили ранее) хочется, чтобы тесты проходили как можно быстрее.
Самый простой вариант с мапперами — это во время запуска теста использовать аннотацию SpringBootTest, которая поднимет весь ApplicationContext Spring Boot приложения и инъектирует нужный для теста маппер внутрь теста.
Но этот вариант ресурсоемкий и занимает значительно больше времени, поэтому для нас он не подходит. Нам нужно писать модульный (unit) тест, который бы просто создал нужный маппер и проверил, что его методы работают именно так, как мы ожидаем.
Для чего нужно, чтобы быстрее шли тесты? Если тесты проходят долго, то это замедляет весь процесс разработки. Пока тесты не пройдут на новом коде, этот код нельзя считать верным и его не возьмут в тестирование, а значит его не возьмут в продакшн и значит, что работу разработчик не выполнил.
Казалось бы зачем писать тест на библиотеку, работа которой не подлежит сомнению? И все же писать тест нужно, потому что мы тестируем то, насколько правильно описали маппер и делает ли он то, что мы ожидаем.
Первым делом, чтобы облегчить нам работу, добавим Lombok в наш проект, путем добавления еще одной зависимости в pom.xml:
В нашем проекте, нам нужно будет перевести из model классов (которые используются для работы с бизнес-логикой) в классы DTO, которые используем для коммуникации с внешним миром.
В нашем упрощенном варианте, мы будем предполагать, что поля не изменяются и наши мапперы будут простыми. Но, если будет желание, можно будет написать более развернутую статью о том, как работать с Mapstruct, как его настраивать, как пользоваться его преимуществами. Но потом, так как эта статья выйдет немаленькой.
Допустим у нас есть студент со списком лекций и лекторов, которые он посещает. Создадим пакет model. На основе этого создадим простенькую модель:
package com.github.romankh3.templaterepository.springboot.dto;
import lombok.Data;
import java.util.List;
@Data
public class StudentDTO {
private Long id;
private String name;
private List<LectureDTO> lectures;
private List<LecturerDTO> lecturers;
}
его лекции
package com.github.romankh3.templaterepository.springboot.dto;
import lombok.Data;
@Data
public class LectureDTO {
private Long id;
private String name;
}
и лекторы
package com.github.romankh3.templaterepository.springboot.dto;
import lombok.Data;
@Data
public class LecturerDTO {
private Long id;
private String name;
}
И создадим пакет dto рядом с пакетом model:
package com.github.romankh3.templaterepository.springboot.dto;
import lombok.Data;
import java.util.List;
@Data
public class StudentDTO {
private Long id;
private String name;
private List<LectureDTO> lectures;
private List<LecturerDTO> lecturers;
}
лекции
package com.github.romankh3.templaterepository.springboot.dto;
import lombok.Data;
@Data
public class LectureDTO {
private Long id;
private String name;
}
и лекторы
package com.github.romankh3.templaterepository.springboot.dto;
import lombok.Data;
@Data
public class LecturerDTO {
private Long id;
private String name;
}
Теперь создадим маппер, который будет переводить коллекцию моделей лекций в коллекцию DTO лекций.
Первое, что нужно сделать — это добавить Mapstruct в проект. Для этого воспользуемся их официальным сайтом, там все описано.
То есть нам нужно добавить одну зависимость и плагин в наш помник (если есть вопросы о том, что такое помник, — вот, пожалуйста, Статья1 и Статья2):
Нужно отдельно отметить, что в мапперах мы ссылаемся на другие мапперы. Делается это посредством поля uses в аннотации Mapper, как это сделано в StudentMapper:
Здесь мы используем два маппера, чтобы правильно замаппить список лекций и список лекторов.
Теперь нужно скомпилировать наш код и посмотреть, что там и как.
Сделать это можно при помощи команды mvn clean compile. Но, как оказалось, при создании реализаций для Mapstruct наших мапперов реализации мапперов не перезаписывали поля. Почему?
Оказалось, что не получилось подхватить аннотацию Data от Lombok. И что-то нужно было делать…
Поэтому у нас в статье появился новый раздел.
Связываем Lombok и Mapstruct
После нескольких минут поиска, выяснилось, что нужно определенным образом соединить Lombok и Mapstruct. В документации Mapstruct есть информация об этом.
После исследования примера, который предложили разработчики из Mapstruct, обновим наш pom.xml:
Добавим отдельно версии:
После этого должно все получиться.
Еще раз скомпилируем наш проект.
Но где же искать классы, которые сгенерировал Mapstruct? Они лежат в generated-sources:
${projectDir}/target/generated-sources/annotations/
Теперь, когда мы подготовились к тому, чтобы осознать мое разочарование из поста про Mapstruct, попробуем создать тесты на мапперы.
Пишем тесты на наши мапперы
Я создам быстрый и простой тест, который бы протестировал один из мапперов в случае, когда мы создаем интеграционный тест и не заморачиваемся о времени его прохождения:
Здесь аннотацией SpringBootTest мы запускаем весь applicationContext и уже из него при помощи аннотации Autowired извлекаем необходимый нам класс для тестирования. С точки зрения скорости и легкости написания теста — это очень хорошо.
Тест успешно проходит, все хорошо.
Но мы пойдем другой дорогой и напишем модульный тест на маппер, например, на LectureListMapper…
Так как реализации, которые генерирует Mapstruct лежат в одном класспасе, что и наш проект, то мы спокойно можем использовать их в наших тестах. Выглядит все прекрасно — никаких аннотаций, создаем самым простым способом класс, что нам нужен и все.
Но когда мы запустим тест, то поймем, что он упадет и в консоли будет NullPointerException…
Все потому, что реализация маппера LectureListMapper имеет вид:
package com.github.romankh3.templaterepository.springboot.mapper;
import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.Generated;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2021-12-09T21:46:12+0300",
comments = "version: 1.4.2.Final, compiler: javac, environment: Java 15.0.2 (N/A)"
)
@Component
public class LectureListMapperImpl implements LectureListMapper {
@Autowired
private LectureMapper lectureMapper;
@Override
public List<LectureModel> toModelList(List<LectureDTO> dtos) {
if ( dtos == null ) {
return null;
}
List<LectureModel> list = new ArrayList<LectureModel>( dtos.size() );
for ( LectureDTO lectureDTO : dtos ) {
list.add( lectureMapper.toModel( lectureDTO ) );
}
return list;
}
@Override
public List<LectureDTO> toDTOList(List<LectureModel> models) {
if ( models == null ) {
return null;
}
List<LectureDTO> list = new ArrayList<LectureDTO>( models.size() );
for ( LectureModel lectureModel : models ) {
list.add( lectureMapper.toDTO( lectureModel ) );
}
return list;
}
}
Если мы посмотрим, то NPE (сокращение от NullPointerException), мы получаем как раз от переменной lectureMapper, которая оказывается не инициализирована.
Но у нас в реализации нет и конструктора, при помощи которого мы бы могли инициализировать переменную. Здесь как раз закопана причина, почему Mapstruct реализовал маппер именно так!
В Spring можно несколькими способами добавлять бины в классы, можно инъектировать их через поле вместе с аннотацией Autowired, как сделано выше, а можно инъектировать через конструктор. В такой проблемной ситуации я оказался на работе, когда нужно было оптимизировать время выполнения тестов.
Я думал, что сделать с этим ничего нельзя и излил свою боль на своем телеграм-канале. И тут мне в комментариях помогли, сказали, что есть возможность настроить стратегию инъектирования.
В интерфейсе Mapper есть поле injectionStrategy, которое как раз и принимает енам InjectionStrategy у которого два значения: FIELD и CONSTRUCTOR.
Теперь, зная об этом, добавим в наши мапперы эту настройку, покажу на примере LectureListMapper:
Выделил жирным часть, что добавил. Добавим эту опцию для всех остальных и перекомпилируем проект, чтобы сгенерировались мапперы уже с новой строкой.
Когда это выполним, посмотрим как изменилась реализация маппера для LectureListMapper (выделил жирным ту часть, что нам нужна):
package com.github.romankh3.templaterepository.springboot.mapper;
import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.Generated;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2021-12-09T22:25:37+0300",
comments = "version: 1.4.2.Final, compiler: javac, environment: Java 15.0.2 (N/A)"
)
@Component
public class LectureListMapperImpl implements LectureListMapper {
private final LectureMapper lectureMapper;
@Autowired
public LectureListMapperImpl(LectureMapper lectureMapper) {
this.lectureMapper = lectureMapper;
}
@Override
public List<LectureModel> toModelList(List<LectureDTO> dtos) {
if ( dtos == null ) {
return null;
}
List<LectureModel> list = new ArrayList<LectureModel>( dtos.size() );
for ( LectureDTO lectureDTO : dtos ) {
list.add( lectureMapper.toModel( lectureDTO ) );
}
return list;
}
@Override
public List<LectureDTO> toDTOList(List<LectureModel> models) {
if ( models == null ) {
return null;
}
List<LectureDTO> list = new ArrayList<LectureDTO>( models.size() );
for ( LectureModel lectureModel : models ) {
list.add( lectureMapper.toDTO( lectureModel ) );
}
return list;
}
}
И вот теперь Mapstruct реализовал инъектирование маппера через конструктор. Собственно чего мы и добивались.
Теперь наш тест перестанет компилироваться, обновим его и получим:
Теперь, если мы запустим тест, то все сработает так как положено, так как в LectureListMapperImpl мы передаем необходимый ему LectureMapper… Победа!
Вам не сложно, а мне приятно:
Друзья, все как обычно, подписывайтесь на мой гитхаб-аккаунт, на телеграм-аккаунт. Там я выкладываю результат своей деятельности, есть реально полезные вещи)
Особенно приглашаю вступить в группу обсуждений телеграм-канала. Так сложилось, что если у кого-то есть технический вопрос, там можно получить ответ. Такой формат интересен для всех, можно почитать, кто что умеет и набраться опыта.
Вывод
В рамках этой статьи мы познакомились с таким нужным и часто используемым продуктом как Mapstruct. Разобрали что это, зачем и как. На реальном примере пощупали, что можно делать и как можно менять.
Также, разобрали как настроить инъектирование бинов через конструктор, чтобы была возможность нормально проводить тестирование мапперов.
Коллеги из Mapstruct предоставили пользователям их продукта выбирать каким именно способом инъектировать мапперы, за что им несомненно спасибо. НО, несмотря на то, что Spring рекомендует инъектировать бины через конструктор, ребята из Mapstruct поставили по умолчанию инъектирование через поле. Почему так? Нет ответа.
Я подозреваю, что могут быть причины о которых мы просто не знаем, и поэтому они так сделали. И чтобы узнать у них, я создал GitHub issue в их официальном репозитории продукта.
Спасибо за статью. Жаль примеры примитивные. Хотелось бы увидеть пример где не все поля Entity есть в DTO. У меня есть задача где в DTO нужно передать некоторые поля с двух связаных Entity. Предвосхищаю часы которые я потрачу над решением данной задачи =)
Добрый день, Роман! Читаю статью и, похоже, обнаружил ошибку: когда ты описываешь модели - в тексте указываешь DTO (при описании DTO - то же самое дублируешь).
За статью спасибо! Вопрос к автору: Как сопоставить объекты в случае, если модель и dto имеют разные поля или вовсе некоторые поля отстутствуют. к примеру: струдент в базе имеет 2 поля Имя и Фамилия, а dto, в свою очередь, имеет поле "Полное имя" или просто имя без фамилии. И второй вопрос почему бы не использовать стандартный интерфейс Converter? почему предпочтение отдаём mapstruct?
Mapstruct очень гибкий инструмент, перевести в полное имя можно написав default метод в этом же интерфейсе и над полем указать аннотацию @Mapping, где указать target поле, то есть то, из которого будет делаться маппинг и имя дефолтного метода.
А что касается второго вопроса, так Mapstruct сам генерирует классы, этим нам не нужно заниматься. Если я правильно понял о чем ты, то там два подхода: либо мы навешиваем на наши модели доп логику(что очень плохо) либо мы сами создаем эти реализации, что за нас сделает Mapstruct(что тоже плохо).
Может в примере нейминги немного поменять?
С первого взгляда разница между lectures и lecturers не совсем очевидна)
Лекторов может заменить на преподавателей?
Тоже интересно почему такой вариант по умолчанию. Ведь когда делаешь @autowired через поле idea напоминает, что так не рекомендуется. Я для себя запомнил эту рекомендацию и всегда делал autowired через конструктор.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ