Исходный код проекта: github
В этой статье мы разберемся с такими понятиями как DTO, Mapping, а также примерами их использования (в самом конце вы увидите полезные ссылки на доп источники по теме).
Я потратил на изучение данной библиотеки немало нервных клеток, и уверен, что узнал далеко не все способы и лайфхаки, но постарался донести информацию с практической стороны, чтобы вы с самого старта не испытывали "нежданчиков" и сэкономили свое время в попытках найти работающий способ.
Hidden text
СПОЙЛЕР: в данном проекте я не использую слой service, а обращаюсь к repository напрямую, и также максимально упростил реализацию, чтобы сфокусироваться на рассматриваемой теме.
Начальные данные
Смоделируем ситуацию: у нас есть некий сервис, который работает с пользователями. Сохраняет, обновляет и возвращает данные о пользователях. Стандартный набор полей сущности User выглядит так: id, username, email, password.
Когда пользователь регистрируется в нашей системе, мы сохраняем переданные данные в БД (базу данных), и затем возвращаем сохраненные данные, а также статус, в зависимости от результата операции (успех или ошибку).
Вопрос: как вы считаете, стоит ли включать в ответ клиенту поле password при успешном сохранении пользователя?
Hidden text
Спойлер: нет!
Конечно, можно обнулить поле password, и передать его клиенту - но мы можем забыть выполнить обнуление пароля, и передать его в ответе - а там на стороне клиента данные могут спокойно перехватить и использовать в своих целях (помните, что клиент это не только браузер - могут быть другие приложения, которые используют наш API).
Именно поэтому мы создадим дополнительный объект UserResponse, который и является DTO, и будет иметь только те поля, которые должен получить клиент, исключая поле password, как показано на рисунке ниже:
Мы, как разработчики, должны заботиться о безопасности на своей стороне. Если есть возможность и необходимость скрыть некоторые поля, которые как пример не нужны, либо представляют особую важность (в данном случае поле password - пароль пользователя), то лучше не включать их в конечный ответ клиенту (клиентом может быть наш микросервис, либо браузер, либо любой другой сервис, который обращается к нашему серверу)
Для понимания: клиент-серверная архитектура предполагает, что любой, кто обращается к серверу, является клиентом. А тот, кто принимает те самые запросы и обрабатывает их, является сервером. То есть клиентом и сервером могут быть сервисы, которые взаимодействуют между собой, даже в рамках одной структуры.
Таким образом мы видим, что DTO (Data transfer object) - объект передачи данных, смысл которого заключается в хранении промежуточных данных. Когда мы возвращаем данные клиенту, обычно передаем DTO обьект (если того требует логика), это как раз все данные (в нашем случае id, username, email).
По сути, DTO - это обычный Java объект, который не является частью бизнес модели (тогда как наш User - представление реального пользователя с набором данных), но служит неким хранилищем для данных, которые надо передать (отсюда и название - объект передачи данных).
Mapping: что ты, черт возьми, такое?
Теперь мы двигаемся к следующему вопросу: как нам собственно привести объект User к объекту UserResponse?
На этом этапе в игру вступает понятие маппинг (eng: mapping), которое подразумевает процесс преобразования объекта X в объект Y, в нашем случае это преобразование User ---> UserResponse.
Простыми словами: маппинг нужен чтобы преобразовывать одни объекты и поля к другим объектам.
MapStruct: смаппь меня, если сможешь
MapStruct - это библиотека, которая по сути и является маппером. Мапперов бывает много, но самый распространенный и удобный - MapStruct.
С помощью маппера нам не придется делать преобразование объектов руками (создавать новый объект, класть туда нужные поля, и возвращать наш DTO), все делается автоматически, ну или почти..
Зачем нам вообще нужен маппер, к чему лишние технологии?
Hidden text
Хороший вопрос. В процессе работы над реальными задачами, вам понадобится добавлять дополнительную логику (устанавливать значения по умолчанию, как пример текущее время, или другие константы / enum-значения, преобразовывать данные (шифровать пароль, генерировать никнейм), и т.п).
Проще использовать уже готовую библиотеку, которая сэкономит ваше время, нервы, и позволит реализовывать более гибкое поведение для конкретных ситуаций.
Теория - хорошо, а практика - еще лучше!
Для начала нам надо выбрать систему сборки для проекта (в данном примере я использую Gradle, но пример подключения библиотеки для Maven будет также приведен ниже).
dependencies {
//...
// MapStruct
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
}
<properties>
<org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
</properties>
...
<dependencies>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
</dependencies>
...
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
Создадим сущность User:
@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
private String password;
}
А также UserResponse (dto) для ответа клиенту:
public record UserResponse(
Long id,
String username,
String email) {
}
Не пугайтесь, если увидели record впервые: это встроенное ключевое слово в Java, которое позволяет создать неизменяемый класс с final полями (мы не можем изменить поля после создания объекта), а также автоматически сгенерированными hashcode()&equals() + toString() + get() методами.
UserMapper - наш первый маппер
Теперь создадим interface UserMapper, который как раз таки и будет содержать основную логику конвертации (маппинга) сущности в DTO:
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
public interface UserMapper {
UserResponse toUserResponse(User user); //map User to UserResponse
List<UserResponse> toUserResponseList(List<User> users); //map list of User to list of UserResponse
}
Как видно, ничего сложного:
Объявляем интерфейс (или абстрактный класс) с именем 'Mapper' в конце (это скорее правило хорошего тона, но не является обязательным условием), а также помещаем в папку mapper (чтобы отделить классы проекта по зонам ответственности)
Добавляем аннотацию @Mapper (org.mapstruct.Mapper), по которой MapStruct будет генерировать реализацию (под капотом MapStruct будет искать все классы, которые помечены данной аннотацией, и генерировать реализацию по заданным внутри этого интерфейса/абстрактного класса правилам), и указываем componentModel (он нужен в том случае, если мы используем Spring-framework в нашем проекте)
Добавляем абстрактные методы в наш интерфейс, реализацию которых возьмет на себя MapStruct
Без Spring:
Если мы не используем Spring, нужно объявить переменную INSTANCE внутри UserMapper interface, которая позволит получить нам реализацию из интерфейса:
@Mapper
public interface UserMapper {
UserMapper INSTANCE = Mappers.getMapper(UserMapper.class); //add instance to call mapper
UserResponse toUserResponse(User user); //map User to UserResponse
List<UserResponse> toUserResponseList(List<User> users); //map list of User to list of UserResponse
}
Контроллеры
Теперь добавим класс UserController, который будет получать запросы и выполнять логику в ответ на запрос:
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserRepository userRepository;
private final UserMapper userMapper;
@GetMapping
public ResponseEntity<List<UserResponse>> findAll() {
List<User> users = userRepository.findAll();
List<UserResponse> userResponseList = userMapper.toUserResponseList(users);
return ResponseEntity.ok(userResponseList);
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> findById(@PathVariable Long id) {
Optional<User> user = userRepository.findById(id);
return user.isPresent()
? ResponseEntity.ok(userMapper.toUserResponse(user.get()))
: ResponseEntity.notFound().build();
}
}
Когда мы используем Spring, нам достаточно добавить аннотацию @RequiredArgsConstructor от Lombok, которая сгенерирует конструктор для всех final полей - в данном случае это обьекты UserRepository, UserMapper (Spring подставит нам эти обьекты благодаря принципу DI)
Без Spring:
Если мы не используем Spring - получить реализацию UserMapper мы можем с помощью объявления переменной этого типа в том классе, где хотим использовать конвертер:
private final UserMapper mapper = UserMapper.INSTANCE;
Благодаря библиотеке MapStruct, которую мы добавили ранее, во время запуска наш проект будет сканирован, и для всех интерфейсов, обозначенных как @Mapper, будет сгенерирован класс, который реализует все методы, объявленные в нашем маппере.
Теперь посмотрим, какой код сгенерировал нам MapStruct (для этого нажмем shift + shift в IntelijIdea и введем имя UserMapperImpl в поиске Classes)
Как видно, реализация интерфейсного метода максимально проста: вначале идет проверка source-объекта на null, а затем создаются переменные с null значениями, и поочередно вызываются get() методы у target-объекта.
Стопп.. source, target, ты о чем вообще?
Что-ж, давайте вспомним объявление первого метода в нашем UserMapper:
UserResponse toUserResponse(User user);
Source объект - это источник, ОТКУДА мы берем данные, в нашем случае это User.
Target объект - это результат, КУДА мы подставляем данные, в нашем случае это UserResponse.
В конце сгенерированной реализации мы видим, что MapStruct берет данные ИЗ User и ставит их В UserResponse. Таким образом, он берет только те поля, которые нужны для создания объекта UserResponse, а в нашем случае поля password у UserResponse нет, значит оно игнорируется!
Теперь посмотрим, какую реализацию сгенерировал MapStruct для получения списка объектов:
Как вы можете заметить, MapStruct делает довольно хитрую вещь: он проходит по списку users и просто вызывает уже сгенерированный ранее метод toUserResponse(), вот и все!
То есть для того, чтобы получить List<UserResponse>, нам нужно только создать сам метод конвертации User ---> UserResponse, а для конвертации всего списка таких же объектов MapStruct пройдется по всем элементам, и вызовет уже существующий метод.
Я уже сохранил 1 запись в базу, через POST метод (его вы увидите дальше, когда будем изучать способ более продвинутого маппинга):
На уровне базы данных сохранились все данные - ID, email, password, username.
Теперь сделаем GET запрос на получение информации о пользователе (напомню, что мы ожидаем ответ БЕЗ поля password, так как скрыли его на этапе маппинга, и передаем клиенту DTO UserResponse):
Как видно, мы не получили секретное поле password, которое хотели скрыть для клиента. Все сработало как надо - в БД все сохранилось как и должно, а в качестве ответа нам пришел UserResponse.
Что же произошло под капотом? Ответ прост: вначале мы получили оригинальную сущность User, с паролем и другими полями, а затем создали новый обьект (DTO) UserResponse, в котором скрыли поле password (этого поля попросту нет).
Более сложный маппинг
Мы разобрались, как выполнять базовый маппинг (конвертацию), но как добавить какую-то логику?
К примеру, при сохранении User, мы хотим поменять его пароль (как и делают в реальных приложениях: пароль шифруют по скрытому алгоритму, и сохраняют его в зашифрованном виде, а при попытке входа в систему, шифруют пароль который пришел из запроса, и сравнивают с уже зашифрованным паролем в Базе Данных).
Таким образом оригинальный пароль знает только сам пользователь, а сама система хранит его уже в зашифрованном виде.
Для начала создадим новую DTO - UserCreateDto, которая будет содержать данные, необходимые для создания пользователя:
В данном примере мы могли бы не создавать UserCreateDto, т.к он содержит те же поля, что и User, но в реальных проектах User имеет аудит-поля, (такие как createdAt, updatedAt, и др.), а также другие данные, которые создаются по умолчанию (к примеру статус пользователя, прим: CREATED) , поэтому желательно создавать отдельную DTO (но также не стоит плодить много DTO-шек, старайтесь везде придерживаться некого баланса).
Создадим UserCreateDto:
public record UserCreateDto(
String username,
String email,
String password) {
}
Добавим метод для преобразования UserCreateDto в User:
User fromUserCreateDto(UserCreateDto userCreateDto);
А также дополним наш UserController, добавив endpoint для сохранения пользователя:
@PostMapping
public ResponseEntity<UserResponse> createUser(@RequestBody UserCreateDto userCreateDto) {
User user = userMapper.fromUserCreateDto(userCreateDto);
User savedUser = userRepository.save(user);
UserResponse userResponse = userMapper.toUserResponse(savedUser);
return ResponseEntity.ok(userResponse);
}
@Mapping - кто ты, воин?
Знакомьтесь, Джо Блэк.. Шучу, @Mapping.
@Mapping - основная аннотация, с которой вы будете сталкиваться большую часть времени. Я не буду приводить тысячи примеров, т.к это займет много времени, и статья превратится в документацию.
Надеюсь, вы еще не забыли про target и source: они нужны чтобы указывать, какие поля нам брать для маппинга.
@Mapping(defaultValue)
Рассмотрим несложный пример:
@Mapping(target = "password", defaultValue = "pass123")
User fromUserCreateDto(UserCreateDto userCreateDto);
Hidden text
Здесь мы даем инструкцию MapStruct, что хотим установить в поле password нашего target объекта User значение по умолчанию ("pass123"), таким образом пароль из userCreateDto будет игнорироваться, и каждый вызов fromUserCreateDto будет возвращать нам User с полем password = "pass123"
Делаем запрос на сохранение нашего User с паролем "our-custom-password-222":
Так как в ответе нам не приходит пароль (мы скрываем его благодаря маппингу, как было сказано ранее), кажется что ничего не поменялось. А теперь заглянем в базу данных:
Как видно, мы создавали пользователя с паролем "our-custom-password-222", а по факту в базу сохранились совсем другое значение поля password - "test123".
Разберемся, что же произошло:
Мы послали запрос на создание User с полями (первый POST-запрос) на картинке выше
На уровне контроллера нашего приложения вызвался код:
User user = userMapper.fromUserCreateDto(userCreateDto);
User savedUser = userRepository.save(user);
В ходе которого мы преобразовали UserCreateDto в User, а в процессе установили значение в поле password самым в User установился пароль со значением defaultValue = "test123", которое было указано в аннотации @Mapping.
Таким образом, defaultValue устанавливает значение, если source == null, а если нужно вне зависимости от условий - это constant. В данном примере source мы не указали, поэтому установилось дефолтное значение.
Пример использования constant:
@Mapping(target = "allowed", constant = "Boolean.FALSE")
@Mapping(ignore)
Этот параметр максимально примитивен - он говорит MapStruct, что данное поле мы должны игнорировать:
@Mapping(target = "email", ignore = true)
UserResponse toUserResponse(User user);
С помощью "ignore" мы говорим MapStruct: игнорируй поле email при маппинге. В таком случае значение user.email будет проигнорировано, и в UserResponse не будет установлено это значение (это может использоваться для того, чтобы MapStruct не ругался на то, что таких полей нету у source-объекта)
Результат сохранения User будет выглядеть так:
Однако в случае, когда мы имеем множество полей, которые необходимо проигнорировать при маппинге, прописывать @Mapping(ignore = true) слишком долго.
И для такой проблемы у MapStruct есть решение: дополнительное свойство unmappedTargetPolicy у @Mapper, которое указывает, как должен реагировать MapStruct на те поля, которых он не нашел в объекте source:
ERROR - пробрасывание ошибки в случае, когда нужное поле отсутствует
WARN (default) - неприятное красное сообщение, которое пробрасывается по умолчанию, когда вы собираете свой проект
IGNORE - игнорировать все поля, которые не удалось смаппить (такой же эффект, как от "ignore = true")
В коде это будет выглядеть так:
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING,
unmappedTargetPolicy = ReportingPolicy.IGNORE
)
@Mapping(expression)
Expression - способ задавать любой Java код. Мы также можем вызывать методы, как внутри UserMapper, так и методы библиотеки.
Как пример, попробуем установливать текущее время в поле password вместо оригинального пароля:
@Mapping(target = "password", expression = "java(LocalDate.now().toString())")
User fromUserCreateDto(UserCreateDto userCreateDto);
Важно!
Необходимо поменять наш конфиг, чтобы добавить в импорты LocalDate.class (иначе MapStruct не найдет класс для подстановки в expression, и выкинет ошибку, можете проверить)
Сохраним пользователя через POST и посмотрим, какие данные лежат в БД:
Как видно, в поле password установилось значение текущей даты, несмотря на то, что при создании указывался пароль.
@Named и методы по умолчанию
Представим, что expression нам не хватает - мы хотим реализовать более сложную логику. На помощь приходят аннотации @Named и дефолтные методы.
Давайте создадим default-метод, который будет создавать пароль по следующей логике: брать некую строку, и добавлять к ней свое значение.
default String getPasswordFromUsername(String username) {
return username + " generatedPassword222";
}
Тогда наш @Mapping также немного поменяется:
@Mapping(target = "password", expression = "java(getPasswordFromUsername(userCreateDto.username()))")
User fromUserCreateDto(UserCreateDto userCreateDto);
В данном примере мы указываем имя метода внутри нашего UserMapper, а также вызываем get()-метод у userCreateDto (геттеры у record-классов именуются без get-префикса)
Делаем запрос, ии..
Что за приколы, скажете вы?
Все просто:
MapStruct сканирует интерфейс на методы, и если видит, что возвращаемое значение и аргумент метода совпадают - смело применяет это ко всем полям. В нашем случае все поля являются String - и MapStruct смело накидал нам ненужной логики.
Давайте исправлять! На помощь идет @Named - аннотация, которая позволяет решить множество проблем, а именно:
Помогает отличить методы, даже если их возвращаемое значение и аргумент совпадают
Позволяет нам использовать qualifiedByName - но к ней мы перейдем чуть позже
Перепишем наш метод:
@Named("getPasswordFromUsername")
default String getPasswordFromUsername(String username) {
return username + " generatedPassword222";
}
И получаем ожидаемый результат:
@Mapping(qualifiedByName)
Предыдущий способ, при котором мы вызывали default getPasswordFromUsername() метод через expression - не самый лучший вариант. Его стоит использовать, если мы хотим добавить какую-то логику к значению, которое возвращает метод. В других случаях рекомендуется использовать qualifiedByName()
В данном примере мы не будем менять логику генерации пароля - оставим все как есть. Я лишь покажу вам, как переписать предыдущий пример с использованием qualifiedByName
Изменим наш UserMapper:
@Mapping(target = "password", qualifiedByName = "getPasswordFromUsername", source = "username")
User fromUserCreateDto(UserCreateDto userCreateDto);
@Named("getPasswordFromUsername")
default String getPasswordFromUsername(String username) {
return username + " generatedPassword222";
}
Разберемся, какие изменения произошли:
Мы убрали expression, и указали qualifiedByName = "getPasswordFromUsername", где значением является имя, которое стоит в аннотации @Named
Мы указали source = "username", что дает инструкцию для MapStruct взять username из userCreateDto, когда тот будет вызывать метод getPasswordFromUsername(), и передать его в качестве параметра
Таким образом, qualifiedByName служит неким уточнением, которое говорит MapStruct, какой именно метод мы хотим использовать для преобразования значений. Если данный метод принимает какие-либо аргументы, мы можем указать их в source.
Вынесение логики маппинга в отдельные классы
Перед завершением статьи, я бы хотел показать вам еще один способ, который позволит избежать головной боли, с которой столкнулся я.
Представим, что вам нужно сделать отдельный класс для UserMapper (в нем будут собраны все методы, которые нужны для маппинга, а ваш UserMapper будет чист как младенец, вызывая методы из созданного util-класса)
Такое разбиение позволит:
Убрать все вспомогательные методы из UserMapper, что позволит нам видеть, какие поля маппятся, и какие методы вызываются, тем самым увеличив чистоту и понимание кода
-
Избежать танцев с бубном, если нужно внедрить бины в mapper. В интерфейсе это сделать либо невозможно, либо можно, но такого способа я еще не нашел (потому делал abstract class и добавлял туда нужные бины, помечая аннотацией @Autowired)
Итак, начнем!
Создадим UserMapperUtil в папке ustils.mapper, куда вынесем все методы для маппинга:
@Named("UserMapperUtil")
@Component
@RequiredArgsConstructor
public class UserMapperUtil {
private final Random randomGenerator;
@Named("getPasswordFromUsername")
public String getPasswordFromUsername(String username) {
return username + randomGenerator.nextInt(5000) + 2222;
}
}
Мы указали @Named на уровне класса и метода, чтобы в UserMapper вызывать нужные нам методы по имени класса и метода.
Бин Random был создан в ApplicationConfig классе, чтобы продемонстрировать суть такого подхода - мы можем внедрять нужные нам зависимости в Util классы, тем самым вынося всю логику из UserMapper в утилитные классы.
@Configuration
public class ApplicationConfig {
@Bean
public Random randomGenerator() {
return new Random(System.currentTimeMillis() / 72);
}
}
Финальное изменение - поменяем интерфейс UserMapper:
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING,
uses = {
UserMapperUtil.class
},
imports = {
LocalDate.class
})
public interface UserMapper {
UserResponse toUserResponse(User user); //map User to UserResponse
List<UserResponse> toUserResponseList(List<User> users); //map list of User to list of UserResponse
@Mapping(target = "password", qualifiedByName = {"UserMapperUtil", "getPasswordFromUsername"}, source = "username")
User fromUserCreateDto(UserCreateDto userCreateDto);
}
Здесь мы добавили uses в @Mapper конфигурацию, чтобы MapStruct подгрузил этот класс в целях маппинга, а самое главное - обратились к методу утилитного класса, используя значения, указанные в @Named у UserMapperUtil в qualifiedByName = {"UserMapperUtil", "getPasswordFromUsername"}
Теперь запускаем наш проект снова, и пробуем сохранить User:
Как видите, все сработало! Наш randomGenerator сгенерировал значение и добавил к username.
Ссылки и полезные материалы
Eсли вы дотянули до этого момента - поздравляю!
Надеюсь, что после прочтения этого материала вам стало чуть понятнее и проще, а если хочется изучить MapStruct получше, листайте вниз!
Список источников, которые помогут вам разобрать тему MapStruct еще больше:
Комментарии (33)
LeshaRB
31.05.2024 00:22+2Помню в 2017 задавал вопросы на StackOverflow вопросы, и ответы, что данный функционал стал поддерживаться приходили спустя пять лет...
Так что при ручном маппере больше места для маневраРаботая в Сбере, видел как люди пишут тесты на эти маперы. По сути тестируют не свой функционал и бизнес логику, а чужую библиотеку Mapstruct. Это ради прохождения порога 80 покрытия тестов
igoresha_s
31.05.2024 00:22Некоторые пакеты можно исключать из покрытия, что является классной практикой. У нас, например, сущности и мапперы исключены из подсчета покрытия. Это можно гибко настраивать в JacoCo или сонаре.
maxzh83
31.05.2024 00:22+3Это ради прохождения порога 80 покрытия тестов
Дурное дело - не хитрое. Но мапперы тут не при чем
panzerfaust
31.05.2024 00:22+3Полностью согласен с комментариями выше. Когда маппинг хоть чуть-чуть сложнее тривального, то работа с мапстрактом становится похожа на попытку вдеть нитку в иголку в пожарных крагах на руках.
Вообще жаль, что заповедь "Явное лучше неявного" находится в манифесте питона, а не джавы.
maxzh83
31.05.2024 00:22+2"Явное лучше неявного" находится в манифесте питона, а не джавы
Вы, наверное, не видели другие мапперы, где все мапится в runtime через reflection. Вот это да, неявное. А mapstruct генерит код, явный и понятный. Пошел и посмотрел, если есть вопросы
panzerfaust
31.05.2024 00:22+3Вопрос не что он генерит, а как заставить его генерить так, как требуется в нетривиальных случаях. Даже в этой статье виден целый новый DSL для изучения:
@Mapper, @Mapping, componentModel, uses, import, target, qualifiedByName
. И спринг еще сбоку со своими бинами лезет. Уильям Оккам тяжело вздыхает в садах Эдема.maxzh83
31.05.2024 00:22+1как требуется в нетривиальных случаях
Ну так нетривиальные случаи потому и нетривиальные, что требует дополнительных сил и знаний. Но если сложно или не хочется разобраться с mapStruct, то не обязательно его использовать для таких случаев. У него есть возможность вызывать для маппинга ваш метод. Напишите "явно" руками то, что нужно, и пусть mapStruct использует ваш метод.
Но для подавляющего большинства случаев ничего такого не требуется. И mapStruct при этом очень сильно экономит время и спасает от рутины.
aleksandy
31.05.2024 00:22Когда не знаешь как заставить, иногда это действительно невозможно. Объявляешь мапер не интерфейсом, а абстрактным классом, и пишешь конкретный метод руками, как тебе хочется. Вообще не проблема.
maxzh83
31.05.2024 00:22В интерфейсах default методы тоже никто не отменял, отлично работает с мапстрактом
redfox0
31.05.2024 00:22Теоретический вопрос: можно ли как-то в рантайме выяснить, что пришли лишние поля, о которых маппер не знает? Аналог растовского
#[serde(deny_unknown_fields)]
.ris58h
31.05.2024 00:22+2Вы путаете с сериализатором/десериализатором (в Java мире это, например, Jackson). MapStruct конвертирует одну структуру в другую и генерирует код для этого на этапе компиляции (перед ней, если быть точным) - все поля известны уже тогда.
Maxim42
31.05.2024 00:22+1defaultValue устанавливает указанное значение для всех полей, вне зависимости от условий
Здесь ошибка. defaultValue устанавливает значение, если source == null, а если нужно вне зависимости от условий - это constant.
Пример как использовать defaultValue из javaDoc'a https://mapstruct.org/documentation/1.6/api/org/mapstruct/Mapping.html#defaultValue()// We need map Human.name to HumanDto.fullName, but if Human.name == null, then set value "Somebody" // we can use defaultValue() or defaultExpression() for it @Mapper public interface HumanMapper { @Mapping(source="name", target="name", defaultValue="Somebody") HumanDto toHumanDto(Human human) }
moonster
Перепробовав несколько разных библиотек для маппинга, я пришел к выводу - лучший маппер из объекта в объект - это маппер, написанный вручную.
Вот причины:
Во-первых, библиотечный маппер не дает большой экономии, т.к. объем неизбежных тестов почти всегда существенно больше объема вручную написанного маппера.
Во-вторых, рано или поздно в недрах бибилиотечного маппера магия дает сбой, и приходится тратить время, чтоб понять, что же пошло не так, а то и костыль сбоку городить.
В-третьих, сложные мапперы, написанные вручную, можно декомпозировать, и тогда появляется возможность тестировать элементы композиции отдельно и саму композицию на моках, что зачастую позволяет уменьшить количество тестов или их сложность.
В-четвертых, библиотечный маппер привносит дополнительную когнитивную сложность и минимум одну дополнительную зависимость.
На долгоживущих проектах любая зависимость зачастую является миной замедленного действия.В-пятых, IDE при рефакторинге может не подхватить строковые литералы, символизирующие имена полей и методов.
А вот если бы были тесты...Но этот все не отменят пользы от такого рода мапперов, если перечисленные недостатки не имеют значения в силу специфики проекта.
igoresha_s
Тесты и так и так будут одинаковыми, ведь нужно протестировать всю логику маппинга) огромное преимущество - это null safe библиотека, как минимум все эти кейсы проверять не нужно
Про сбой - спорно, но окэй, такое бывает, нужно смотреть что генерируется.
В статье есть пример про вынос некоторой логики в отдельные классы, так же можно выносить общие маппинги в отдельные интерфейсы и тоже их использовать. Это очень удобно.
В IntelliJ idea есть шикарный плагин для map struct’a , который подсвечивает и видит весь синтаксис, даже в строках в expression
На самом деле map struct далеко не идеален, есть сложные вещи, например прикидывание в контексте нескольких параметров, но в статье про подводные камни не сказано(
yayauheny Автор
Да, в MapStruct есть достаточное количество нюансов и ограничений. Цель статьи другая - сэкономить время тех, кто только знакомится с библиотекой.
Лично я познакомился с этим инструментом на стажировке, когда писал проект на сервлетах, и там мапперы пригодились как никогда. Днями разбирался, что же не так, гуглил, читал документацию, чтобы просто смаппить несколько полей или внести доп. логику. На момент написания статьи я поделился всей информацией, которую успел собрать за время работы.
В целом все зависит от специфики проекта, насчет комментария выше согласен, что лучше не зависеть от множества библиотек, но и писать свои велосипеды тоже не хочется. Думаю, это вечный холивар, который в целом относится к тенденции последних лет, когда пишется большое количество решений, и люди начинают добавлять к себе в проекты тонны зависимостей, отчего общий билд начинает много весить, а также появляется больше уязвимостей и зависимости от внешних технологий.
maxzh83
По моему опыту, гораздо раньше и чаще наступает момент, когда разработчик в самописном маппере забывает прописать проверку на null или забывает добавить новое поле.
А вот с пониманием у mapStruct как раз все неплохо (в отличии от runtime-мапперов). Есть сгенеренный код, вполне читаемый.
Captain_Jack
Поэтому нужны тесты и Kotlin, чтобы проверять поля и не проверять null-ы.
maxzh83
Думаете, если разработчик забыл добавить поле в основной код, он не забудет добавить его в тест?
Captain_Jack
С mapstruct то же самое - если не протестил маппинг, то не факт что оно работает.
Может быть новое поле он и прокинет сам, а может и неправильно прокинет.
Самое противное с ним - код компилируется и запускается, но это не значит что всё хорошо. Он или в рантайме где-то потом упадёт, или неправильно значения передаст, например поле-список всегда будет пустым.
Один из больших минусов - код у него внутри на java. И когда используешь его с DTO на kotlin, он генерит код с супер-странной nullability, и в рантайме частенько можно ловить NPE, когда он пытается в not-null поле этот самый null положить.
maxzh83
Прописывайте поля явно, через @Mapping. Тогда вероятность, что упадет при компиляции сильно выше
Не видел, чтобы разработчики MapStruct заявляли о поддержке котлина. А если так, то претензия такая себе.
Captain_Jack
Ну извините, что вам не понравилось, у меня вот такая)
Сейчас на бэке проекты пишут либо с Lombok, либо на Kotlin. Тот же spring поддерживает kotlin, не обламывается.
Они мне конечно ничем не обязаны. Но от этого более удобно мне не становится ни с какой стороны.
Captain_Jack
А от забывчивости разработчика увы средств особо нет, кроме как проверять всю сделанную им работу на код ревью и на приемочных тестах. И то не 100% гарантия. И mapstruct это никак не меняет в целом.
maxzh83
Верно, поэтому считаю, что писать тесты на простой маппинг, где нет логики, а просто перекладываем из одного поля в другое - это работа ради работы.
Captain_Jack
Тогда можно пойти чуть дальше и сказать, что может быть вообще все тесты не нужны. Зачем - ведь разработчик так и так что-то забудет, не в коде так в тесте.
Тесты конечно же нужны. Они, хоть и не избавляют от ошибок принципиально, зато повышают качество относительно.
Опять же, тесты хороши тем, что через них можно сделать регресс. Вот например, захотите вы обновить версию мапструкта с 3 на 4, как будете убеждаться что в вашем большом проекте всё ок после апдейта, если нет на это тестов?
maxzh83
Я этого не говорил, читайте внимательно. Там где нет логики. Если поле вычисляется на основе каких-то правил, то тест конечно же нужен.
Мапперы же не в вакууме работают. Если тесты на сервисе, где используется маппер начал падать, то вот вам и сигнал. Но вообще проверять совместимость версии должны авторы мапстракта и писать об этом в release notes, а вы, соответственно, прочитать и принять решение.
Я вот тоже хочу пойти "чуть дальше" и спросить, а вы пишите тесты на сериализатор в json? А то вдруг вы обновите версию какого-нить jackson, а у вас все сломается.
Captain_Jack
Вот тут кажется пошла интересная мысль. Что если работа маппера проаеряется косвенно в более крупном тесте, то зачастую этого уже достаточно, чтобы проверить его работу.
В некотором смысле да, например если это rest-контроллер, то нужен тест на основе mockMvc, который проверит, какой именно json возвращает сервер. Вы может быть ожидаете, что это тестировать не нужно и там всё работает само и со 100% гарантией, но на практике это не так. Там стоит проверять например форматы даты-времени, или во что сериализуются какие-то нестандартные типы, типа BigDecimal
Вы так будете объяснять бизнесу, когда из-за бага в мапстракте баг будет уже у вас, и на проде будет инцидент? Сомневаюсь, что они оценят :)
maxzh83
Но у вас же нет теста именно сериализатора, как вы бизнесу будете объяснять, если что случится?) А ещё orm может неправильный sql сгенерить, давайте тоже тестами обмажем.
Короче, я считаю, что проверять отдельно маппер избыточно, если есть тест на сервис
Captain_Jack
Согласен, что проверять сам маппер избыточно, если маппинг уже участвует в каком-то тесте.
Сарказма в вашем первом абзаце не понял, кажется вы не уловили смысл моего предыдущего коммента. Смысл не в том, чтобы плодить избыточные тесты, а в том, чтобы то, что должно работать, какими-то тестами проверялось, и не держалось на доверии к авторам библиотеки и к тому, что вы её точно правильно к себе подключили и правильно используете.
И да, будете смеяться, но очень даже в ходу тесты, которые проверяют, какие запросы генерирует ORM, или хотя бы их количество. Потому что иначе вы не гарантируете, что ваш сервис соответствует требованиям, в этом случае нефункциональным. Особенно легко сломать что-то при изменении уже написанного.
Хотя на простых проектах, где нагрузки нет, и нет больших потерь денег из-за подобных ошибок, всё это в общем-то и не нужно.
maxzh83
Именно это я и хотел донести.
Я бы сказал, что очень на редких проектах это оправдано. Но, конечно же, такие проекты есть. Кстати, вспомнил статью про Oracle, может интересно будет
UnknownCat
Согласен по всем пунктам и даже без но.
Так же скажу, что это верно и для сериализаторов/десериализаторов.
Кто-то поменял username на user_name и отвалилась половина зависимых сервисов.
А тесты замоканы. Там всегда один и тот же json.
Тоже самое можно и про ORM сказать, если иметь достаточно компетенции можно заранее писать высокопроизводительные запросы в БД и поддерживать все в идеальном состоянии. Таким образом, исключая ряд проблем.
Но как мы знаем этого не происходит потому что это отнимает слишком много времени, один человек не справится с поддержкой 15+ сервисов. А у двух человек разные взгляды на разработку одного и того же продукта и далеко не факт, что вы договоритесь.
У команды из 15 человек все еще сложнее. Найти компромисс иногда сжирает времени больше, чем реализация.
И лучше иметь одинаковые проблемы и соответственно пути их решения на 15+ сервисах, чем за год сделать ничего продумывая, как же все это идеально реализовать. Постоянно переписывая код до идеального состояния.
По сути Mapstruct и другие "мапперы" это способ ускорить разработку здесь и сейчас в обмен на возможные проблемы в будущем. "Здесь и сейчас" по сути это то, что крайне важно для бизнеса потому, как, если не успеешь за конкурентами, они тебя обгонят и съедят.
Да и в случае личных проектов мне и наверное большинству не хочется всю жизнь провести за компьютером, пока твои друзья 3 раз за год летают забугор.
Тебе всегда, когда ты начинаешь новый пет проект хочется увидеть результат здесь и сейчас и чаще всего из-за подобных ожиданий он и не развивается потому что ожидания не были оправданы.
Вывод: важно реалистично подходить к разработке проекта, и там где необходимо использовать ручной подход, а там где это не критично использовать mapstruct или другие инструменты.