
По известным причинам, бэкенд не может отдавать данные из репозитория как есть. Самая известная — сущностные зависимости берутся из базы не в таком виде, в котором их может понять фронт. Сюда же можно добавить и сложности с парсингом 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; } }
Такие самописные мапперы имеют явные недостатки:
- Не масштабируются.
- При добавлении/удалении даже самого незначительного поля придётся править маппер.
Поэтому, правильным решением является использование библиотеки-маппера. Мне известны 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.
Любители чистого кода наверняка усмотрели уже возможность загнать многие компоненты кода в абстракции. Если Вы из их числа, предлагаю под кат.
Повышаем уровень абстракции
Для начала, определим интерфейс для основных методов класса-обёртки.
Унаследуем от него абстрактный класс.
Постконвертеры и методы заполнения специфичных полей смело отправляем туда. Также, создаём два объекта типа Class и конструктор для их инициализации:
Теперь количество кода в DroidMapper сокращается до следующего:
Маппер без специфичных полей выглядит вообще просто:
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); } }
