ModelMapper: путешествие туда и обратно

    image

    По известным причинам, бэкенд не может отдавать данные из репозитория как есть. Самая известная — сущностные зависимости берутся из базы не в таком виде, в котором их может понять фронт. Сюда же можно добавить и сложности с парсингом enum (если поля enum содержат дополнительные параметры), и многие другие сложности, возникающие при автоматическом приведении типов (или невозможности автоматического их приведения). Отсюда вытекает необходимость в использовании Data Transfer Object — DTO, понятном и для бэка, и для фронта.
    Конвертацию сущности в DTO можно решить по-разному. Можно применить библиотеку, можно (если проект маленький) наколхозить что-то вроде такого:

    @Component
    public class ItemMapperImpl implements ItemMapper {
    
        private final OrderRepository orderRepository;
    
        @Autowired
        public ItemMapperImpl(OrderRepository orderRepository) {
            this.orderRepository = orderRepository;
        }
    
        @Override
        public Item toEntity(ItemDto dto) {
            return new Item(
                    dto.getId(),
                    obtainOrder(dto.getOrderId()),
                    dto.getArticle(),
                    dto.getName(),
                    dto.getDisplayName(),
                    dto.getWeight(),
                    dto.getCost(),
                    dto.getEstimatedCost(),
                    dto.getQuantity(),
                    dto.getBarcode(),
                    dto.getType()
            );
        }
    
        @Override
        public ItemDto toDto(Item item) {
            return new ItemDto(
                    item.getId(),
                    obtainOrderId(item),
                    item.getArticle(),
                    item.getName(),
                    item.getDisplayName(),
                    item.getWeight(),
                    item.getCost(),
                    item.getEstimatedCost(),
                    item.getQuantity(),
                    item.getBarcode(),
                    item.getType()
            );
        }
    
        private Long obtainOrderId(Item item) {
            return Objects.nonNull(item.getOrder()) ? item.getOrder().getId() : null;
        }
    
        private Order obtainOrder(Long orderId) {
            return Objects.nonNull(orderId) ? orderRepository.findById(orderId).orElse(null) : null;
        }
    }

    Такие самописные мапперы имеют явные недостатки:

    1. Не масштабируются.
    2. При добавлении/удалении даже самого незначительного поля придётся править маппер.

    Поэтому, правильным решением является использование библиотеки-маппера. Мне известны modelmapper и mapstruct. Поскольку я работал с modelmapper, я расскажу о нём, но если ты, мой читатель, хорошо знаком с mapstruct и можешь рассказать обо всех тонкостях её применения, напиши об этом, пожалуйста, статью, и я первый её заплюсую (мне эта библиотека также очень интересна, но въезжать в неё пока нет времени).

    Итак, modelmapper.

    Сразу хочу сказать, что если вам что-то непонятно, Вы можете скачать готовый проект с рабочим тестом, ссылка в конце статьи.

    Первый шаг — это, конечно, добавление зависимости. Я использую gradle, но вам не составит труда добавить зависимость в maven-проект.

    compile group: 'org.modelmapper', name: 'modelmapper', version: '2.3.2'

    Этого достаточно, чтобы маппер заработал. Далее, нам необходимо создать бин.

    @Bean
        public ModelMapper modelMapper() {
            ModelMapper mapper = new ModelMapper();
            mapper.getConfiguration()
                    .setMatchingStrategy(MatchingStrategies.STRICT)
                    .setFieldMatchingEnabled(true)
                    .setSkipNullEnabled(true)
                    .setFieldAccessLevel(PRIVATE);
            return mapper;
        }

    Обычно достаточно просто вернуть new ModelMapper, но не лишним будет настроить маппер для наших нужд. Я задал строгую стратегию соответствия, включил сопоставление полей, пропуск нулловых полей и задал приватный уровень доступа к полям.

    Далее, создаём следующую структуру сущностей. У нас будет единорог (Unicorn), у которого в подчинении будет какое-то количество дроидов (Droid), и у каждого дроида будет какое-то количество капкейков (Cupcake).

    Сущности
    Абстрактный родитель:

    @MappedSuperclass
    @Setter
    @EqualsAndHashCode
    @NoArgsConstructor
    @AllArgsConstructor
    public abstract class AbstractEntity implements Serializable {
    
        Long id;
        LocalDateTime created;
        LocalDateTime updated;
    
        @Id
        @GeneratedValue
        public Long getId() {
            return id;
        }
    
        @Column(name = "created", updatable = false)
        public LocalDateTime getCreated() {
            return created;
        }
    
        @Column(name = "updated", insertable = false)
        public LocalDateTime getUpdated() {
            return updated;
        }
    
        @PrePersist
        public void toCreate() {
            setCreated(LocalDateTime.now());
        }
    
        @PreUpdate
        public void toUpdate() {
            setUpdated(LocalDateTime.now());
        }
    }

    Unicorn:

    @Entity
    @Table(name = "unicorns")
    @EqualsAndHashCode(callSuper = false)
    @Setter
    @AllArgsConstructor
    @NoArgsConstructor
    public class Unicorn extends AbstractEntity {
    
        private String name;
        private List<Droid> droids;
        private Color color;
    
        public Unicorn(String name, Color color) {
            this.name = name;
            this.color = color;
        }
    
        @Column(name = "name")
        public String getName() {
            return name;
        }
    
        @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "unicorn")
        public List<Droid> getDroids() {
            return droids;
        }
    
        @Column(name = "color")
        public Color getColor() {
            return color;
        }
    }

    Droid:

    @Setter
    @EqualsAndHashCode(callSuper = false)
    @Entity
    @Table(name = "droids")
    @AllArgsConstructor
    @NoArgsConstructor
    public class Droid extends AbstractEntity {
    
        private String name;
        private Unicorn unicorn;
        private List<Cupcake> cupcakes;
        private Boolean alive;
    
        public Droid(String name, Unicorn unicorn, Boolean alive) {
            this.name = name;
            this.unicorn = unicorn;
            this.alive = alive;
        }
    
        public Droid(String name, Boolean alive) {
            this.name = name;
            this.alive = alive;
        }
    
        @Column(name = "name")
        public String getName() {
            return name;
        }
    
        @ManyToOne(fetch = FetchType.EAGER)
        @JoinColumn(name = "unicorn_id")
        public Unicorn getUnicorn() {
            return unicorn;
        }
    
        @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "droid")
        public List<Cupcake> getCupcakes() {
            return cupcakes;
        }
    
        @Column(name = "alive")
        public Boolean getAlive() {
            return alive;
        }
    }

    Cupcake:

    @Entity
    @Table(name = "cupcakes")
    @Setter
    @EqualsAndHashCode(callSuper = false)
    @AllArgsConstructor
    @NoArgsConstructor
    public class Cupcake extends AbstractEntity {
    
        private Filling filling;
        private Droid droid;
    
        @Column(name = "filling")
        public Filling getFilling() {
            return filling;
        }
    
        @ManyToOne(fetch = FetchType.EAGER)
        @JoinColumn(name = "droid_id")
        public Droid getDroid() {
            return droid;
        }
    
        public Cupcake(Filling filling) {
            this.filling = filling;
        }
    }


    Эти сущности мы будем конвертировать в DTO. Существует как минимум два подхода к конвертации зависимостей из сущности в DTO. Один подразумевает сохранение только ID вместо сущности, но тогда каждую сущность из зависимости при нужде мы будем дёргать по ID дополнительно. Второй подход подразумевает сохранение DTO в зависимости. Так, при первом подходе мы бы конвертировали List droids в List droids (в новом списке храним только ID), а при втором подходе мы будем сохранять в List droids.

    DTO
    Абстрактный родитель:

    @Data
    public abstract class AbstractDto implements Serializable {
    
        private Long id;
    
        @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss.SSS")
        LocalDateTime created;
    
        @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss.SSS")
        LocalDateTime updated;
    }

    UnicornDto:

    @EqualsAndHashCode(callSuper = true)
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class UnicornDto extends AbstractDto {
    
        private String name;
        private List<DroidDto> droids;
        private String color;
    }

    DroidDto:

    @EqualsAndHashCode(callSuper = true)
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class DroidDto extends AbstractDto {
    
        private String name;
        private List<CupcakeDto> cupcakes;
        private UnicornDto unicorn;
        private Boolean alive;
    }

    CupcakeDto:

    @EqualsAndHashCode(callSuper = true)
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class CupcakeDto extends AbstractDto {
    
        private String filling;
        private DroidDto droid;
    }


    Для тонкой настройки маппера под наши нужды нам будет необходимо создать собственный класс-обёртку и переопределить логику для маппинга коллекций. Для этого мы создаём класс-компонент UnicornMapper, автовайрим туда наш маппер и переопределяем нужные нам методы.

    Самый простой вариант класса-обёртки выглядит так:

    @Component
    public class UnicornMapper {
    
        @Autowired
        private ModelMapper mapper;
    
        @Override
        public Unicorn toEntity(UnicornDto dto) {
            return Objects.isNull(dto) ? null : mapper.map(dto, Unicorn.class);
        }
    
        @Override
        public UnicornDto toDto(Unicorn entity) {
            return Objects.isNull(entity) ? null : mapper.map(entity, UnicornDto.class);
        }
    }

    Теперь нам достаточно заавтовайрить наш маппер в какой-нибудь сервис и дёргать по методам toDto и toEntity. Найденные в объекте сущности маппер будет превращать в DTO, DTO — в сущности.

    @Service
    public class UnicornServiceImpl implements UnicornService {
    
        private final UnicornRepository repository;
        private final UnicornMapper mapper;
    
        @Autowired
        public UnicornServiceImpl(UnicornRepository repository, UnicornMapper mapper) {
            this.repository = repository;
            this.mapper = mapper;
        }
    
        @Override
        public UnicornDto save(UnicornDto dto) {
            return mapper.toDto(repository.save(mapper.toEntity(dto)));
        }
    
        @Override
        public UnicornDto get(Long id) {
            return mapper.toDto(repository.getOne(id));
        }
    }

    Но если мы попробуем таким образом законвертировать что-нибудь, а потом вызвать, к примеру, toString, то мы получим StackOverflowException, и вот почему: в UnicornDto находится список DroidDto, в котором находится UnicornDto, в котором находятся DroidDto, и так до того момента, пока не закончится стековая память. Поэтому для обратных зависимостей я обычно использую не UnicornDto unicorn, а Long unicornId. Мы, таким образом, сохраняем связь с Unicorn, но обрубаем циклическую зависимость. Поправим наши DTO таким образом, чтобы вместо обратных DTO они хранили ID своих зависимостей.

    @EqualsAndHashCode(callSuper = true)
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class DroidDto extends AbstractDto {
    ...
        //private UnicornDto unicorn;
        private Long unicornId;
    ...
    }

    и так далее.

    Но теперь, если мы вызовём DroidMapper, мы получим unicornId == null. Это происходит потому, что ModelMapper не может определить точно, что такое Long. И просто не сетит его. И нам придётся заняться тонкой настройкой необходимых мапперов, чтобы научить их мапить сущности в ID.

    Вспоминаем, что с каждым бином после его инициализации можно поработать вручную.

        @PostConstruct
        public void setupMapper() {
            mapper.createTypeMap(Droid.class, DroidDto.class)
                    .addMappings(m -> m.skip(DroidDto::setUnicornId)).setPostConverter(toDtoConverter());
            mapper.createTypeMap(DroidDto.class, Droid.class)
                    .addMappings(m -> m.skip(Droid::setUnicorn)).setPostConverter(toEntityConverter());
        }

    В @PostConstruct мы зададим правила, в которых укажем, какие поля маппер трогать не должен, потому что для них мы определим логику самостоятельно. В нашем случае, это как определение unicornId в DTO, так и определение Unicorn в сущности (поскольку что делать с Long unicornId, маппер так же не знает).

    TypeMap — это и есть правило, в котором мы указываем все нюансы маппинга, а также, задаём конвертер. Мы указали, что для конвертирования из Droid в DroidDto мы пропускаем setUnicornId, а при обратной конвертации — setUnicorn. Конвертировать мы всё будем в конвертере toDtoConverter() для UnicornDto и в toEntityConverter() для Unicorn. Эти конвертеры мы должны описать в нашем компоненте.

    Самый простой постконвертер выглядит так:

        Converter<UnicornDto, Unicorn> toEntityConverter() {
            return MappingContext::getDestination;
        }

    Нам необходимо расширить его функциональность:

         public Converter<UnicornDto, Unicorn> toEntityConverter() {
            return context -> {
                UnicornDto source = context.getSource();
                Unicorn destination = context.getDestination();
                mapSpecificFields(source, destination);
                return context.getDestination();
            };
        }

    То же самое делаем и с обратным конвертером:

        public Converter<Unicorn, UnicornDto> toDtoConverter() {
            return context -> {
                Unicorn source = context.getSource();
                UnicornDto destination = context.getDestination();
                mapSpecificFields(source, destination);
                return context.getDestination();
            };
        }

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

        public void mapSpecificFields(Droid source, DroidDto destination) {
            destination.setUnicornId(Objects.isNull(source) || Objects.isNull(source.getId()) ? null : source.getUnicorn().getId());
        }
    
        void mapSpecificFields(DroidDto source, Droid destination) {
            destination.setUnicorn(unicornRepository.findById(source.getUnicornId()).orElse(null));
        }

    При мапинге в DTO мы сетим ID сущности. При мапинге в DTO достаём сущность из репозитория по ID.

    И всё.

    Я показал необходимый минимум для начала работы с modelmapper и особо не рефакторил код. Если у тебя, читатель, есть что добавить к моей статье, я буду рад услышать конструктивную критику.

    Проект можно посмотреть тут:
    Проект на GitHub.

    Любители чистого кода наверняка усмотрели уже возможность загнать многие компоненты кода в абстракции. Если Вы из их числа, предлагаю под кат.

    Повышаем уровень абстракции
    Для начала, определим интерфейс для основных методов класса-обёртки.

    public interface Mapper<E extends AbstractEntity, D extends AbstractDto> {
    
        E toEntity(D dto);
    
        D toDto(E entity);
    }

    Унаследуем от него абстрактный класс.

    public abstract class AbstractMapper<E extends AbstractEntity, D extends AbstractDto> implements Mapper<E, D> {
    
        @Autowired
        ModelMapper mapper;
    
        private Class<E> entityClass;
        private Class<D> dtoClass;
    
        AbstractMapper(Class<E> entityClass, Class<D> dtoClass) {
            this.entityClass = entityClass;
            this.dtoClass = dtoClass;
        }
    
        @Override
        public E toEntity(D dto) {
            return Objects.isNull(dto)
                    ? null
                    : mapper.map(dto, entityClass);
        }
    
        @Override
        public D toDto(E entity) {
            return Objects.isNull(entity)
                    ? null
                    : mapper.map(entity, dtoClass);
        }
    
        Converter<E, D> toDtoConverter() {
            return context -> {
                E source = context.getSource();
                D destination = context.getDestination();
                mapSpecificFields(source, destination);
                return context.getDestination();
            };
        }
    
        Converter<D, E> toEntityConverter() {
            return context -> {
                D source = context.getSource();
                E destination = context.getDestination();
                mapSpecificFields(source, destination);
                return context.getDestination();
            };
        }
    
        void mapSpecificFields(E source, D destination) {
        }
    
        void mapSpecificFields(D source, E destination) {
        }
    }

    Постконвертеры и методы заполнения специфичных полей смело отправляем туда. Также, создаём два объекта типа Class и конструктор для их инициализации:

        private Class<E> entityClass;
        private Class<D> dtoClass;
    
        AbstractMapper(Class<E> entityClass, Class<D> dtoClass) {
            this.entityClass = entityClass;
            this.dtoClass = dtoClass;
        }

    Теперь количество кода в DroidMapper сокращается до следующего:

    @Component
    public class DroidMapper extends AbstractMapper<Droid, DroidDto> {
    
        private final ModelMapper mapper;
        private final UnicornRepository unicornRepository;
    
        @Autowired
        public DroidMapper(ModelMapper mapper, UnicornRepository unicornRepository) {
            super(Droid.class, DroidDto.class);
            this.mapper = mapper;
            this.unicornRepository = unicornRepository;
        }
    
        @PostConstruct
        public void setupMapper() {
            mapper.createTypeMap(Droid.class, DroidDto.class)
                    .addMappings(m -> m.skip(DroidDto::setUnicornId)).setPostConverter(toDtoConverter());
            mapper.createTypeMap(DroidDto.class, Droid.class)
                    .addMappings(m -> m.skip(Droid::setUnicorn)).setPostConverter(toEntityConverter());
        }
    
        @Override
        public void mapSpecificFields(Droid source, DroidDto destination) {
            destination.setUnicornId(getId(source));
        }
    
        private Long getId(Droid source) {
            return Objects.isNull(source) || Objects.isNull(source.getId()) ? null : source.getUnicorn().getId();
        }
    
        @Override
        void mapSpecificFields(DroidDto source, Droid destination) {
            destination.setUnicorn(unicornRepository.findById(source.getUnicornId()).orElse(null));
        }
    }

    Маппер без специфичных полей выглядит вообще просто:

    @Component
    public class UnicornMapper extends AbstractMapper<Unicorn, UnicornDto> {
    
        @Autowired
        public UnicornMapper() {
            super(Unicorn.class, UnicornDto.class);
        }
    }

    Поделиться публикацией

    Комментарии 10

      +1
      Нужно добавить что магические маперы имеют свои недостатки?
      — Затрудняют поиск использования полей в коде
      — При внесении изменении в entity/dto проблемы сломанного мапинга будут видны только в рантайме
        0
        Ну, не такие уж они и магические :)

        Затрудняют поиск использования полей в коде

        Каким образом? Можете привести пример?

        При внесении изменении в entity/dto проблемы сломанного мапинга будут видны только в рантайме

        Для защиты от таких ситуаций программисты пишут тесты.
          0
          с ModelMapper не работал, использую MapStruct, и там это решается следующим образом
          — Затрудняют поиск использования полей в коде

          маппинг делается в интерфейсах, для которых генерируются реализации. в реализациях маппинг прописан явно, проблема с поиском отпадает
          — При внесении изменении в entity/dto проблемы сломанного мапинга будут видны только в рантайме

          если что-то пойдёт не так, приложение просто не соберётся
          0
          Поэтому, правильным решением является использование библиотеки-маппера.

          Не могу согласиться. В данном примере код, завязанный на библиотеку тоже имеет ряд недостатков:
          • Возможность конфликтов с другими библиотеками (несовместимость аннотаций)
          • Нет гарантии, что после обновлении версии библиотеки не придется переписывать маппинг всех Entities, DTOs (breaking changes)
          • Изменилось поле, напиши конвертер
          • Опять не уйти от абстракции, чтобы скрыть реализацию. В сути получаем тот же «колхозный» ItemMapper на базе библиотеки.

          Как решить проблему, если DTO имеет другую (схожую) структуру с Entity. Например в DTO вычислимое поле А, которое состоит из суммы полей B и C соответствующей Entity. Писать для каждого случая постконвертер (TypeMap)?

          Конечно, если Entities толстые, может и имеет смысл использовать библиотеку. Но скорее всего проблема вытекает из другого места.
            0
            Возможность конфликтов с другими библиотеками (несовместимость аннотаций)

            С тем же Lombok ModelMapper не конфликтует. Если Вы пишете свою какую-то аннотацию, то да, будьте готовы, что библиотеки не будут её понимать.

            Нет гарантии, что после обновлении версии библиотеки не придется переписывать маппинг всех Entities, DTOs (breaking changes)

            А есть примеры таких конфликтов и переписывания сущностей на примере ModelMapper?

            Изменилось поле, напиши конвертер

            Сущность является фундаментом любого приложения, если Вы меняете поля в сущности, Вам придётся всё приложение перелопатить. А вот ModelMapper, кстати, по умолчанию работает с полями и пытается их корректно замапить. Конвертер нужно писать только для специфичного мапинга. Так что, вполне вероятно, как раз он и сэкономит Ваше время :) Но проверять, конечно, надо. Тесты в помощь.

            Опять не уйти от абстракции, чтобы скрыть реализацию. В сути получаем тот же «колхозный» ItemMapper на базе библиотеки.

            Не понял, что Вы хотели сказать.

            Как решить проблему, если DTO имеет другую (схожую) структуру с Entity. Например в DTO вычислимое поле А, которое состоит из суммы полей B и C соответствующей Entity. Писать для каждого случая постконвертер (TypeMap)?

            В этом и смысл использования библиотек. Все специфичные поля библиотека обрабатывает сама. Обработку неспецифичных полей никто кроме Вас не напишет.
              0
              Не понял, что Вы хотели сказать.

              В месте использования предпочтительнее работать с некоторой абстракцией над маппингом.

              В этом и смысл использования библиотек. Все специфичные поля библиотека обрабатывает сама. Обработку неспецифичных полей никто кроме Вас не напишет.

              Выходит тащим библиотеку для решение проблемы, в итоге получаем частичное решение проблемы + зависимости.
                0
                Ни одна библиотека не решает проблему целиком, тут Вы правы.
            +1
            А в modelMapper можно посмотреть получившиеся мапперы? Он их генерирует или как? Рефлексирует в runtime как Dozer? Если что-то не маппится, то он в runtime упадёт или при компиляции? Хотелось бы увидеть описание того, как он работает под капотом, подробностей реализации. Чем он лучше других решений?

            MapStruct прекрасен тем, что по интерфейсам с аннотациями(это поле в вот это поле, если есть разногласия) или абстрактным классам (если надо сделать что-то больше, чем просто «из этого поля в это») просто сгенерит реализующий класс, где будет тупо использование геттеров-сеттеров и создание объектов. Как будто вы писали это влоб руками. Сгенерированный конвертер можно тестить, можно дебажить. Он просто используется в runtime. Никаких оверхедов на рефлексию. Если что-то не маппится, то падает при компиляции.
              0
              Хорошо бы увидеть обстоятельную статью по Mapstruct на хабре, но её нет…
              –1
              Если есть шанс отрефакторить фронт или энтити, чтоб они понимали друг-друга без компараторов лучше это сделать, а не городить компараторы.

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

              Если структура Энтити и требуемого ДТО({id:1, fio:{name:dima}}->{id:1.name:dima}) кардинально различается, то можно опять же попытаться обойтись стандартным спринговским маппером, написав нативный SQL/HQL запрос, который автоматом попытается наложиться на требуемую ДТО. Это будет быстродейственней и короче.

              Ну и если уж компарить надо сильно много и сильно кастомно, то это пардон уже не компаратор, а бизнес-логика.

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

              Самое читаемое