Зарегистрируйтесь, чтобы продолжить обучение

Автоматическая конвертация сущностей в DTO и обратно Spring Boot

Преобразование сущностей в DTO и обратно — это довольно утомительная операция с большим объемом однообразного кода. Например, такого:

// Импорты

@RestController
@RequestMapping("/api")
public class PostsController {
    // Здесь обработчики

    private PostDTO toDTO(Post post) {
        var dto = new PostDTO();
        dto.setId(post.getId());
        dto.setSlug(post.getSlug());
        dto.setName(post.getName());
        dto.setBody(post.getBody());
        dto.setCreatedAt(post.getCreatedAt());
        return dto;
    }

    private Post toEntity(PostCreateDTO postDTO) {
        var post = new Post();
        post.setSlug(postDTO.getSlug());
        post.setName(postDTO.getName());
        post.setBody(postDTO.getBody());
        return post;
    }

    private Post toEntity(PostUpdateDTO postDTO, Post post) {
        post.setName(postDTO.getName());
        post.setBody(postDTO.getBody());
        return post;
    }
}

Кто-то в своих проектах выбирает такой подход, но есть и альтернатива. Существуют библиотеки, позволяющие автоматизировать конвертацию в обе стороны. Самая популярная из них — это MapStruct. В этом уроке мы подключим ее и научимся использовать.

Установка

Для начала установим MapStruct:

implementation("org.mapstruct:mapstruct:1.5.5.Final")
annotationProcessor("org.mapstruct:mapstruct-processor:1.5.5.Final")

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

Использование

MapStruct работает следующим образом. С его помощью создаются специальные мапперы под каждую сущность. Внутри них определяются правила конвертирования в DTO или из DTO в зависимости от потребностей. Дальше эти мапперы используются в нужных местах, сводя преобразования к одной строчке.

Разберем работу библиотеки на примере сущности Post, взятой из предыдущего урока. Чтобы было понятнее, мы начнем с конца. Сначала посмотрим, как использовать мапперы, а затем научимся их писать.

Сущность

Определение сущности выглядит так:

package io.hexlet.spring.model;

import static jakarta.persistence.GenerationType.IDENTITY;

import java.time.LocalDate;

import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import jakarta.persistence.EntityListeners;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
@Table(name = "posts")
@EntityListeners(AuditingEntityListener.class)
public class Post {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @Column(unique = true)
    private String slug;

    private String name;

    @Column(columnDefinition = "TEXT")
    private String body;

    @CreatedDate
    private LocalDate createdAt;
}

DTO

Для этой сущности реализуем три DTO для разных целей:

  • Для создания сущности
  • Для обновления сущности
  • Для отображения сущности

Реализация выглядит так:

// Создание поста
package io.hexlet.spring.dto;

import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
public class PostCreateDTO {
    private String slug;
    private String name;
    private String body;
}
// Обновление поста
package io.hexlet.spring.dto;

import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
public class PostUpdateDTO {
    private String name;
    private String body;
}
// Вывод поста
package io.hexlet.spring.dto;

import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
public class PostDTO {
    private Long id;
    private String slug;
    private String name;
    private String body;
    private LocalDate createdAt;
}

Контроллер

В конце мы напишем маппер, а пока посмотрим, как изменится код контроллера с их использованием. Все преобразование сведется к вызову postMapper.map():

// Остальные импорты
import io.hexlet.spring.mapper.PostMapper;

@RestController
@RequestMapping("/api")
public class PostsController {
    @Autowired
    private PostRepository repository;

    @Autowired
    private PostMapper postMapper;

    @PostMapping("/posts")
    @ResponseStatus(HttpStatus.CREATED)
    public PostDTO create(@RequestBody PostCreateDTO postData) {
        // Преобразование в сущность
        var post = postMapper.map(postData);
        repository.save(post);
        // Преобразование в DTO
        var postDTO = postMapper.map(post);
        return postDTO;
    }

    @PutMapping("/posts/{id}")
    @ResponseStatus(HttpStatus.OK)
    public PostDTO update(@RequestBody @Valid PostUpdateDTO postData, @PathVariable Long id) {
        var post = repository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Not Found"));
        postMapper.update(postData, post);
        repository.save(post);
        var postDTO = postMapper.map(post);
        return postDTO;
    }

    @GetMapping("/posts/{id}")
    @ResponseStatus(HttpStatus.OK)
    public PostDTO show(@PathVariable Long id) {
        var post = repository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Not Found: " + id));
        // Преобразование в DTO
        var postDTO = postMapper.map(post);
        return postDTO;
    }
}

Код контроллера стал предельно простым. Вместо ручного копирования данных здесь используется маппер PostMapper, который содержит:

  • Метод update()
  • Перегруженный метод map(), работающий сразу с тремя классами:
    • PostCreateDTO
    • PostDTO
    • Post

Мапперы

Перейдем к мапперам:

// src/main/java/io/hexlet/spring/mapper/PostMapper.java
package io.hexlet.spring.mapper;

import org.mapstruct.Mapper;
import org.mapstruct.MappingConstants;
import org.mapstruct.MappingTarget;
import org.mapstruct.NullValuePropertyMappingStrategy;
import org.mapstruct.ReportingPolicy;

import io.hexlet.spring.dto.PostCreateDTO;
import io.hexlet.spring.dto.PostUpdateDTO;
import io.hexlet.spring.dto.PostDTO;
import io.hexlet.spring.model.Post;

@Mapper(
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
    componentModel = MappingConstants.ComponentModel.SPRING,
    unmappedTargetPolicy = ReportingPolicy.IGNORE
)
public abstract class PostMapper {
    public abstract Post map(PostCreateDTO dto);
    public abstract PostDTO map(Post model);
    public abstract void update(PostUpdateDTO dto, @MappingTarget Post model);
}

Маппер — это абстрактный класс с абстрактными методами для конвертации одних объектов в другие. Класс должен быть помечен аннотацией @Mapper с минимально указанной опцией componentModel = MappingConstants.ComponentModel.SPRING. Расположение класса, название класса и методов не фиксированы — программисты сами определяют, как все это организовать. MapStruct не ограничивает нас в DTO, мы можем преобразовывать объекты любых классов.

В документации MapStruct показаны примеры с интерфейсом, а не абстрактным классом. Технически эта библиотека работает и с интерфейсами, и абстрактными классами. Использовать последние удобнее, потому что в абстрактные классы можно сделать инъекцию зависимостей, если это необходимо.

Во время компиляции происходит генерация конкретных мапперов. Посмотреть исходник этих классов можно в директории build/generated/sources/annotationProcessor/java/main/io/spring/mapper. Это очень упрощает отладку. Код маппера PostMapperImpl созданного на базе абстрактного класса PostMapper:

// PostMapperImpl.java
package io.hexlet.blog.mapper;

import io.hexlet.blog.dto.PostCreateDTO;
import io.hexlet.blog.dto.PostDTO;
import io.hexlet.blog.model.Post;
import io.hexlet.blog.model.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

// Автоматически помечается как компонент, что дает возможность внедрять как зависимость
@Component
public class PostMapperImpl extends PostMapper {
   @Override
   public Post map(PostCreateDTO dto) {
      if (dto == null) {
         return null;
      } else {
         Post post = new Post();
         post.setSlug(dto.getSlug());
         post.setName(dto.getName());
         post.setBody(dto.getBody());
         return post;
      }
   }

   @Override
   public PostDTO map(Post model) {
      if (model == null) {
         return null;
      } else {
         PostDTO postDTO = new PostDTO();
         postDTO.setId(model.getId());
         postDTO.setSlug(model.getSlug());
         postDTO.setName(model.getName());
         postDTO.setBody(model.getBody());
         postDTO.setCreatedAt(model.getCreatedAt());
         return postDTO;
      }
   }

    @Override
    public void update(PostUpdateDTO dto, Post model) {
        if ( dto == null ) {
            return;
        }
        model.setName(dto.getName());
        model.setBody(dto.getBody());
    }
}

MapStruct самостоятельно написал тот код, который мы до этого писали руками. Но как он это сделал? MapStruct сравнивает методы обоих классов и автоматически распознает те, что совпадают. Кроме этого, MapStruct автоматически пытается преобразовать типы, если они не совпадают. В большинстве случаев это работает автоматически, но там где нет, всегда есть возможность дописать правила конвертации и преобразования типов. Для примера представим, что поле name переименовали в title. Если нам нужно сохранить внешнее API без изменений, то мы можем определить правила преобразования в маппере:

package io.hexlet.spring.mapper;

import org.mapstruct.Mapper;
import org.mapstruct.MappingConstants;
import org.mapstruct.NullValuePropertyMappingStrategy;
import org.mapstruct.ReportingPolicy;

import io.hexlet.spring.dto.PostCreateDTO;
import io.hexlet.spring.dto.PostDTO;
import io.hexlet.spring.model.Post;

@Mapper(
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
    componentModel = MappingConstants.ComponentModel.SPRING,
    unmappedTargetPolicy = ReportingPolicy.IGNORE
)
public abstract class PostMapper {
    @Mapping(target = "title", source = "name")
    public abstract Post map(PostCreateDTO dto);

    @Mapping(target = "title", source = "name")
    public abstract void update(PostUpdateDTO dto, @MappingTarget Post model);

    @Mapping(target = "name", source = "title")
    public abstract PostDTO map(Post model);
}

Аннотация @Mapping позволяет указать правила преобразования свойств. Самый частый случай — это когда имя свойства в исходном объекте не совпадает с целевым. В аннотации source указывает на объект, который передается как параметр, target — это объект, возвращаемый из метода.


Самостоятельная работа

Когда количество DTO и сущностей растет, ручное копирование полей быстро превращается в источник ошибок и дублирования кода. Чтобы упростить работу, можно использовать библиотеку MapStruct, которая автоматически генерирует мапперы на этапе компиляции.

В этом задании вы замените ручную конвертацию на использование MapStruct.

  1. Добавьте зависимости в Gradle В build.gradle.kts подключите MapStruct и процессор аннотаций:

    Пример: build.gradle.kts
    dependencies {
        implementation("org.mapstruct:mapstruct:1.5.5.Final")
        annotationProcessor("org.mapstruct:mapstruct-processor:1.5.5.Final")
    
        testAnnotationProcessor("org.mapstruct:mapstruct-processor:1.5.5.Final")
    }
    

    Также убедитесь, что у вас подключен kapt (если используете Kotlin в сборке):

    Пример: plugins
    plugins {
        id("org.springframework.boot") version "3.3.0"
        id("io.spring.dependency-management") version "1.1.5"
        id("java")
        kotlin("kapt") version "1.9.25" // важно для генерации
    }
    
  2. Создайте интерфейс маппера

    • Для сущности Post и DTO создайте интерфейс:

      Пример: PostMapper
      import org.mapstruct.Mapper;
      import org.mapstruct.MappingTarget;
      
      @Mapper(componentModel = "spring")
      public interface PostMapper {
      
          PostDTO toDTO(Post post);
      
          Post toEntity(PostCreateDTO dto);
      
          void updateEntityFromDTO(PostUpdateDTO dto, @MappingTarget Post post);
      }
      
    • componentModel = "spring" позволяет внедрять маппер как Spring-бин.

    • @MappingTarget используется для обновления существующей сущности.

  3. Используйте маппер в контроллере

    Пример: PostController
    @RestController
    @RequestMapping("/posts")
    public class PostController {
    
        private final PostRepository postRepository;
        private final PostMapper postMapper;
    
        public PostController(PostRepository postRepository, PostMapper postMapper) {
            this.postRepository = postRepository;
            this.postMapper = postMapper;
        }
    
        @PostMapping
        public ResponseEntity<PostDTO> create(@Valid @RequestBody PostCreateDTO dto) {
            Post post = postMapper.toEntity(dto);
            postRepository.save(post);
            return ResponseEntity.ok(postMapper.toDTO(post));
        }
    
        @PutMapping("/{id}")
        public ResponseEntity<PostDTO> update(@PathVariable Long id,
                                                @Valid @RequestBody PostUpdateDTO dto) {
            Post post = postRepository.findById(id)
                    .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    
            postMapper.updateEntityFromDTO(dto, post);
            postRepository.save(post);
    
            return ResponseEntity.ok(postMapper.toDTO(post));
        }
    }
    
  4. Добавьте аналогичный маппер для пользователей

    • Создайте UserMapper.
    • Определите методы для преобразования UserCreateDTO, UserUpdateDTO, UserDTO.
    • Подключите в UserController.

Итог

Теперь в проекте используется MapStruct вместо ручного копирования полей. Это дало:

  • Автоматическую генерацию кода конвертации на этапе компиляции.
  • Чистый и лаконичный код контроллеров и сервисов.
  • Простое добавление новых DTO и полей без дублирования логики.

Дополнительные материалы

  1. Официальная документация
  2. Mapstruct Spring Extensions
  3. Аннотация @Mapping

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

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff