MapStruct - Czyli Jak gwałtownie i Wygodnie Mapować obiekty - cz.1

cupofcodes.pl 5 lat temu

Zazwyczaj w aplikacjach jakie piszemy istnieje potrzeba mapowania Obiektów java na inny typ w zależności od przeznaczenia np. piszemy aplikacje webową i chcieli byśmy uniknąć tzw podejścia:


“Encja na twarz i pchasz”. 2013 - Paweł Szulc - Architektura to nie bzdura

Zamiast tego chcemy aby przed każdym wysłaniem danych na front odbywała się konwersja ENCJI na DTO(Data Transfer Object) i odwrotnie. W obiekcie tym ograniczamy informacje zwracając tylko niezbędne dane. Dodatkowo zmniejszając ich wielkość, co może pozytywnie wpłynąć na szybkość działania aplikacji

Uwaga: W przykładach poniżej kawałki kodu, na które powinniśmy zwrócić szczególną uwagę zostaną podświetlone na żółto.

W niektórych projektach przy których pracowałem do mapowania encji na dto wykorzystywane były manualnie pisane mappery. Wykorzystywano do tego metody statyczne stworzonej klasy odpowiadającej za mapowanie np. ProductMapper.map(product). Według mnie już sama nazwa metody jest słaba ponieważ nie mówi dokładnie jakie mapowanie w niej zachodzi. Oczywiście gdy w klasie ProductMapper jest tylko jedna taka metoda, wtedy można jeszcze dość prosto się domyślić. Gorzej jak takich metod jest więcej a jedyna różnica to jej argumenty – sprawa zaczyna się komplikować. Product może mapować się, a także przyjmować różne dto/encje np. ProductDTO, ShortProductDTO lub ProductDictionary.

Lepszym podejściem było by zastosowanie konkretnej nazwy opisującej działanie np. ProductMapper.mapToProductDictionary(product). Pomijając kwestię clean code można zauważyć jeszcze co najmniej jeden problem. Wykorzystując mapowanie manualne zachodzi konieczność manualnej konwersji typów w obiektach, jak i mapowania typów zagnieżdżonych. Za każdym razem jak dokonamy zmiany w kodzie musimy zaktualizować mapper. Może to wpłynąć negatywnie na komfort i szybkość pracy oraz długość wprowadzania zmian. Gdy wykorzystujemy MapStruct wyręcza nas on w większości spraw.

Niżej zamieszczam klasy, które będą wykorzystywane w tej części artykułu.

import lombok.Data; @Data public class Agreement { private Long id; private String name; private Product product; private AgreementType type; private List<Attachments> attachments; private LocalDate conclusionDate; } @Data public class AgreementDTO { private Long id; private String agreementName; private Long productId; private String agreementType; private List<AttachmentsDTO> attachments; private LocalDate conclusionDate; } public enum AgreementType { AGREEMENT,ANEX } import lombok.Data; @Data public class Attachments { private Long id; private String name; private Byte[] file; } import lombok.Data; @Data public class AttachmentsDTO { private Long id; private String fileName; private Byte[] file; } import lombok.Data; @Data public class Product { private Long id; private String name; private Double price; }

A więc… Co to jest ten cały MapStruct ??

MapStruct Jest to Framework, a za razem procesor adnotacji java, który generuje kod dla zdefiniowanych przez nas mapperów. Jedną z jego zalet jest zapewnienie zgodności mapowanych typów( ang. type-safe). Mechanizm działania oparty jest na interfejsach( Java >= 8 ) lub klasach abstrakcyjnych ( Java 6 i 7) dostarczonych przez użytkownika. W procesie kompilacji projektu Framework generuje implementację interfejsów lub klas abstrakcyjnych oznaczonych adnotacją @Mapper

Zalety:

  • W odróżnieniu od innych narzędzi do mapowania Mapstruct uruchamia się podczas kompilacji projektu dzięki czemu zapewnia doskonałą wydajność ponieważ nie musi używać refleksji lub manipulacji kodem bajtowym podczas wykonywania programu.
  • Szybkie informowanie programisty o błędach lub niekompletnych mapowaniach podczas kompilacji dzięki konsoli lub IDE.
  • Generowana implementacja zawiera proste do zrozumienia metody co przekłada się na łatwe testowanie i debugowanie.
  • Plugin do popularnych IDE Intellij Idea oraz Eclipse wspomagający auto kompletowanie i nawigacje.
  • Możliwość użycia MapStruct z projektem Lombok co jeszcze bardziej może przyspieszyć pracę z mapperami.
  • Bezpieczeństwo typów.

Przygotowanie środowiska

Framework możemy dodać do projektu na kilka sposobów, ja wykorzystam do tego narzędzie Maven. Aby móc korzystać z dobrodziejstw MapStruct w projekcie dodajemy odpowiednią zależność oraz plugin w pliku pom.xml.

  • org.mapstruct:mapstruct – Zawiera wszystkie adnotacje wykorzystywane do generowania implementacji np. @Mapping
  • org.mapstruct:mapstruct-processor – Procesor adnotacji generujący implementacje
... <properties> <org.mapstruct.version>1.3.0.Beta2</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.5.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> <path> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>${lombok.version}</version> </path> </annotationProcessorPaths> </configuration> </plugin> </plugins> </build> ...

Update: Używana wersja przeszła już z fazy 1.3.0.Beta2 do 1.3.0.Final

Na żółto podświetliłem sposób na zintegrowanie lomboka z MapStruct. Bez dodania path z informacjami o lomboku procesor adnotacji nie będzie w stanie wygenerować implementacji. Stanie się tak dlatego, iż wygenerowane pola dzięki biblioteki lombook nie będą jeszcze istnieć.

Plugin do IDE

Jeną z największych korzyści z wykorzystywania MapStruct jest wczesny fedback dotyczący konfiguracji maperów. jeżeli korzystacie z Intellij idea lub eclipse dobrze jest zainstalować plugin wspomagający edycję i tworzenie mapperów. Plugin ten można znaleźć w oficjalnym repozytorium pluginów Jetbrains lub w Eclipse Marketplace. Pozwala on między innymi na:

  • Podpowiedzi oraz autouzupełnianie wartości i enumów.
  • Nawigacja do deklaracji zmiennej z poziomu konfiguracji mappera source/target.
  • Wyszukiwanie użycia zmienych.
  • Refaktoring nazw zmiennych w konfiguracji mappera source/target.

Konfiguracja mapperów

Pierwszy @Mapper

Poniżej znajduje się najprostsza wersja mapera wraz z wygenerowaną implementacją. Tak skonfigurowany interface opatrzony adnotacją @Mapper, podczas kompilacji programu wygeneruje mapowanie dla pól nazywających się tak samo w obu obiektach. Przykładowo zarówno Agreement jak i AgreementDTO zawiera pola id, conclusionDate dlatego zmienne te zostały uwzględnione w wygenerowanej implementacji. Pola których nazwa się różni zostały zignorowane ponieważ nie dostarczyliśmy dla nich schematu mapowania.

import org.mapstruct.Mapper; import org.mapstruct.Mapping; @Mapper public interface AgreementMapper { Agreement mapToAgreement(AgreementDTO agreementDTO); } import javax.annotation.Generated; @Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2019-01-21T11:51:29+0100", comments = "version: 1.3.0.Beta2, compiler: javac, environment: Java 1.8.0_181 (Oracle Corporation)") public class AgreementMapperImpl implements AgreementMapper { @Override public Agreement mapToAgreement(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Agreement agreement = new Agreement(); agreement.setId(agreementDTO.getId()); agreement.setConclusionDate(agreementDTO.getConclusionDate()); return agreement; } }

Definiowanie schematu @Mapping

Po przeanalizowaniu powyższego przykładu pewnie niektórym nasuwa się pytanie “a co z pozostałymi zmiennymi? Jak zmapować pola których nazwy nie są takie same? A także jak zignorować zmienną aby nie była mapowana ? Tutaj z pomocą przychodzi adnotacja @Mapping oraz jej konfiguracja, a w jej skład wchodzą między innymi atrybuty :

  • Source – nazwa zmiennej znajdująca się w klasie, której obiekt został przekazany do metody. W poniższym przykładzie jest to klasa AgreementDTO.
  • Target – nazwa zmiennej znajdującej się w instancji klasy zwracanej przez metodę. W poniższym przykładzie jest to obiekt klasy Agreement.
  • Ignore – wymaga atrybutu target określa czy dana zmienna powinna być mapowana. Przyjmuje wartość true lub false, domyślnie jest to false.

Dzięki takiej konfiguracji procesor adnotacji wie jaką implementację wygenerować dla poszczególnych pól. Poniżej znajduje się przykład mapowania wraz z wygenerowanym kodem. Uwzględnienia on różne nazwy, różne typy oraz ignorowanie zmiennej. Mamy także zawarte tutaj mapowanie zagnieżdżone co widać w linni 8 productId(Long ) na obiekt klasy Product zmienna id.

import org.mapstruct.Mapper; import org.mapstruct.Mapping; @Mapper public interface AgreementMapper { @Mapping(source = "agreementName", target = "name") @Mapping(source = "productId", target = "product.id") @Mapping(source = "agreementType", target = "type") @Mapping(ignore = true, target = "attachments") Agreement mapToAgreement(AgreementDTO agreementDTO); } import javax.annotation.Generated; @Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2019-01-21T12:50:46+0100", comments = "version: 1.3.0.Beta2, compiler: javac, environment: Java 1.8.0_181 (Oracle Corporation)") public class AgreementMapperImpl implements AgreementMapper { @Override public Agreement mapToAgreement(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Agreement agreement = new Agreement(); agreement.setProduct(agreementToProduct(agreementDTO)); agreement.setName(agreementDTO.getAgreementName()); if (agreementDTO.getAgreementType() != null) { agreement.setType(Enum.valueOf(AgreementType.class, agreementDTO.getAgreementType())); } agreement.setId(agreementDTO.getId()); agreement.setConclusionDate(agreementDTO.getConclusionDate()); return agreement; } protected Product agreementToProduct(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Product product = new Product(); product.setId(agreementDTO.getProductId()); return product; } }

Konwersja Typów i mapowanie kolekcji

W tym Akapicie wspomnieć należy o tym, iż MapStruct zapewnia nam także konwersję wszystkich typów zmiennych wbudowanych w core Java. Dzięki temu podczas ich mapowania nie ma potrzeby pisania metod, które pokazują kompilatorowi jak to robić. Inaczej to wygląda jeżeli w obiekcie mapowanym występują klasy stworzone przez nas, wtedy sami musimy zadbać o dostarczenie schematu mapowania. Można to zrobić na 3 sposoby przedstawione poniżej:

Chwilowo na potrzeby przykładów zamienimy List<AttachmentsDTO> attachmentsDTO na pojedynczy obiekt attachment

1 Sposób – “Domyślny”

W opisywanej wersji dla klas napisanych przez nas Mapstruct domyślnie podejmuje próbę wygenerowania implementacji bez dostarczonego schematu. W skutek czego wygenerowana zostaje metoda bazująca na polach aktualnie przetwarzanego obiektu. Można zauważyć to w klasie Agreement która mapowana jest na klasę AgreementDTO. Znajduje się w niej zagnieżdżona klasaAttachment, która powinna być mapowana na AttachmentDTO. MapStruct spróbuje sam wygenerować takie mapowanie niestety w rezultacie tak jak w przypadku wcześniejszym pola nie posiadające takiej samej nazwy zostaną zignorowane.

import org.mapstruct.Mapper; import org.mapstruct.Mapping; @Mapper() public interface AgreementMapper { @Mapping(source = "agreementName", target = "name") @Mapping(source = "productId", target = "product.id") @Mapping(source = "agreementType", target = "type") @Mapping(source = "attachmentDTO", target = "attachment") Agreement mapToAgreement(AgreementDTO agreementDTO); } import javax.annotation.Generated; import java.util.Arrays; @Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2019-01-22T09:12:47+0100", comments = "version: 1.3.0.Beta2, compiler: javac, environment: Java 1.8.0_181 (Oracle Corporation)") public class AgreementMapperImpl implements AgreementMapper { @Override public Agreement mapToAgreement(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Agreement agreement = new Agreement(); agreement.setProduct(agreementDTOToProduct(agreementDTO)); agreement.setName(agreementDTO.getAgreementName()); agreement.setAttachments(attachmentsDTOToAttachments(agreementDTO.getAttachmentsDTO())); if (agreementDTO.getAgreementType() != null) { agreement.setType(Enum.valueOf(AgreementType.class, agreementDTO.getAgreementType())); } agreement.setId(agreementDTO.getId()); agreement.setConclusionDate(agreementDTO.getConclusionDate()); return agreement; } protected Product agreementDTOToProduct(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Product product = new Product(); product.setId(agreementDTO.getProductId()); return product; } protected Attachment attachmentDTOToAttachment(AttachmentDTO attachmentDTO) { if (attachmentDTO == null) { return null; } Attachment attachment = new Attachment(); attachment.setId(attachmentDTO.getId()); Byte[] file = attachmentsDTO.getFile(); if (file != null) { attachment.setFile(Arrays.copyOf(file, file.length)); } return attachment; } } import lombok.Data; import java.time.LocalDate; @Data public class Agreement { private Long id; private String name; private Product product; private AgreementType type; private Attachment attachment; private LocalDate conclusionDate; } import lombok.Data; import java.time.LocalDate; @Data public class AgreementDTO { private Long id; private String agreementName; private Long productId; private String agreementType; private AttachmentDTO attachmentDTO; private LocalDate conclusionDate; }

2 Sposób – metoda mapująca

Następnym omawianym sposobem będzie wykorzystanie nowości z javy 8 czyli domyślnych metod w interfejsach(w starszej javie możemy zastosować Klasę abstrakcyjną zamiast interfejsu i przeprowadzić mapowanie dzięki metody Abstrakcyjnej). Zabieg ten poinformuje procesor adnotacji, iż istnieje mapowanie dla obiektu Attachment i należy z niego skorzystać. Metoda ta może być przydatna gdy chcemy wykonać jakieś niestandardowe mapowanie lub operację na mapowanych zmiennych.

Załóżmy, iż chcemy dodać do nazwy prefix/surfix wykorzystując metodę mapującą, możemy wpiąć się w implementację całego mappera na poziomie określonego pola i manualnie napisać mapowanie danego typu. Sposób ten może być dobrym rozwiązaniem jeżeli potrzebujemy w jednym lub kilku mapperach, zmapować klasę inaczej niż w pozostałych. jeżeli ma być to standardowe zachowanie mapujące we wszystkich mapperach korzystających z klasy Attachment, rozwiązanie to skończy się koniecznością powtarzania danej metody w każdym z nich. Takiej sytuacji z pewnością chcieli byśmy uniknąć, ponieważ w razie jakichkolwiek zmian wymusza to wyszukanie i zmianę wszystkich metod. Jak wydzielić mapowania do osobnych klas zostanie wyjaśnione w następnym akapicie.

import org.mapstruct.Mapper; import org.mapstruct.Mapping; @Mapper public interface AgreementMapper { @Mapping(source = "agreementName", target = "name") @Mapping(source = "productId", target = "product.id") @Mapping(source = "agreementType", target = "type") @Mapping(source = "attachmentDTO", target = "attachment") Agreement mapToAgreement(AgreementDTO agreementDTO); default Attachment attachmentDTOToAttachment(AttachmentDTO attachmentDTO) { if (attachmentDTO == null) { return null; } Attachment attachment = new Attachment(); attachment.setId(attachmentDTO.getId()); attachment.setName("prefix_" + attachmentDTO.getFileName()); attachment.setFile(attachmentDTO.getFile()); return attachment; } } import javax.annotation.Generated; @Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2019-01-23T23:34:47+0100", comments = "version: 1.3.0.Beta2, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)") public class AgreementMapperImpl implements AgreementMapper { @Override public Agreement mapToAgreement(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Agreement agreement = new Agreement(); agreement.setProduct(agreementDTOToProduct(agreementDTO)); agreement.setName(agreementDTO.getAgreementName()); agreement.setAttachment(attachmentDTOToAttachment(agreementDTO.getAttachmentDTO())); if (agreementDTO.getAgreementType() != null) { agreement.setType(Enum.valueOf(AgreementType.class, agreementDTO.getAgreementType())); } agreement.setId(agreementDTO.getId()); agreement.setConclusionDate(agreementDTO.getConclusionDate()); return agreement; } protected Product agreementDTOToProduct(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Product product = new Product(); product.setId(agreementDTO.getProductId()); return product; } }

3 Sposób – Załączenie Klasy mapującej


Ostatnim znanym mi sposobem, a zarazem najczęściej wykorzystywanym ze względu na jego reużywalność jest załączenie informacji z jakiej klasy mapującej powinien skorzystać nasz interfejs przeprowadzając konwersję typów. Co ważne do mappera możemy załączyć więcej niż jedną klasę mapującą. Odbywa się to dzięki adnotacji @Mapper i jej atrybutu uses przekazujemy do niego tablicę klas mapperów, których chcemy użyć podczas mapowań. Widać to na poniższym przykładzie:

Uwaga: Jeśli mamy już w aplikacji jakieś mappery napisane manualnie, bo np. wprowadzamy MapStruct do istniejącego już projektu to dzięki tej metody również możemy z nich skorzystać wystarczy podpiąć je w @Mapper(uses = {}). Wtedy Framework będzie wyszukiwał mapowań również w tych załączonych klasach bazując na typach przekazywanych i zwracanych.

import org.mapstruct.Mapper; import org.mapstruct.Mapping; @Mapper(uses = {AttachmentMapper.class}) public interface AgreementMapper { @Mapping(source = "agreementName", target = "name") @Mapping(source = "productId", target = "product.id") @Mapping(source = "agreementType", target = "type") @Mapping(source = "attachmentDTO", target = "attachment") Agreement mapToAgreement(AgreementDTO agreementDTO); } import org.mapstruct.Mapper; import org.mapstruct.Mapping; @Mapper public interface AttachmentMapper { @Mapping(source = "fileName", target = "name") Attachment mapToAttachment(AttachmentDTO attachmentDTO); } import javax.annotation.Generated; import org.mapstruct.factory.Mappers; @Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2019-01-23T22:53:51+0100", comments = "version: 1.3.0.Beta2, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)") public class AgreementMapperImpl implements AgreementMapper { private final AttachmentMapper attachmentMapper = Mappers.getMapper(AttachmentMapper.class); @Override public Agreement mapToAgreement(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Agreement agreement = new Agreement(); agreement.setProduct(agreementDTOToProduct(agreementDTO)); agreement.setName(agreementDTO.getAgreementName()); agreement.setAttachment(attachmentMapper.mapToAttachment(agreementDTO.getAttachmentDTO())); if (agreementDTO.getAgreementType() != null) { agreement.setType(Enum.valueOf(AgreementType.class, agreementDTO.getAgreementType())); } agreement.setId(agreementDTO.getId()); agreement.setConclusionDate(agreementDTO.getConclusionDate()); return agreement; } protected Product agreementDTOToProduct(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Product product = new Product(); product.setId(agreementDTO.getProductId()); return product; } } package demo.packages; import java.util.Arrays; import javax.annotation.Generated; @Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2019-01-23T22:53:51+0100", comments = "version: 1.3.0.Beta2, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)") public class AttachmentMapperImpl implements AttachmentMapper { @Override public Attachment mapToAttachment(AttachmentDTO attachmentDTO) { if (attachmentDTO == null) { return null; } Attachment attachment = new Attachment(); attachment.setName(attachmentDTO.getFileName()); attachment.setId(attachmentDTO.getId()); Byte[] file = attachmentDTO.getFile(); if (file != null) { attachment.setFile(Arrays.copyOf(file, file.length)); } return attachment; } }

Jak widać do zmapowania Agreement na AgreementDTO został dodatkowo wykorzystany inny mapper. AttachmentMapper podłączony został dzięki atrybutu uses adnotacji @Mapper. Metodę Mappers.getMapper, która została użyta do pobrania implementacji mappera Attachment opiszę to w dalszej części artykułu.

Mapowanie kolekcji


MapStruct przejmuje także na siebie zadanie mapowania kolekcji. Wystarczy zdefiniować mapper dla pojedyńczego obiektu dzięki jednej z 3 wyżej podanych metod, a on zajmie się resztą.
Jeśli w typach mapowanych mamy zmienne, które są kolekcjami wygenerowana dla nich zostanie implementacja korzystająca z mapowania pojedynczego obiektu.

import org.mapstruct.Mapper; import org.mapstruct.Mapping; @Mapper(uses = {AttachmentMapper.class}) public interface AgreementMapper { @Mapping(source = "agreementName", target = "name") @Mapping(source = "productId", target = "product.id") @Mapping(source = "agreementType", target = "type") @Mapping(source = "attachmentsDTO", target = "attachments") Agreement mapToAgreement(AgreementDTO agreementDTO); } @Data public class Agreement { private Long id; private String name; private Product product; private AgreementType type; private Set<Attachment> attachments; private LocalDate conclusionDate; } @Data public class AgreementDTO { private Long id; private String agreementName; private Long productId; private String agreementType; private List<AttachmentDTO> attachmentsDTO; private LocalDate conclusionDate; } import java.util.HashSet; import java.util.List; import java.util.Set; import javax.annotation.Generated; import org.mapstruct.factory.Mappers; @Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2019-01-27T19:08:46+0100", comments = "version: 1.3.0.Beta2, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)" ) public class AgreementMapperImpl implements AgreementMapper { private final AttachmentMapper attachmentMapper = Mappers.getMapper( AttachmentMapper.class ); @Override public Agreement mapToAgreement(AgreementDTO agreementDTO) { if ( agreementDTO == null ) { return null; } Agreement agreement = new Agreement(); agreement.setProduct( agreementDTOToProduct( agreementDTO ) ); agreement.setName( agreementDTO.getAgreementName() ); agreement.setAttachments( attachmentDTOListToAttachmentSet( agreementDTO.getAttachmentsDTO() ) ); if ( agreementDTO.getAgreementType() != null ) { agreement.setType( Enum.valueOf( AgreementType.class, agreementDTO.getAgreementType() ) ); } agreement.setId( agreementDTO.getId() ); agreement.setConclusionDate( agreementDTO.getConclusionDate() ); return agreement; } protected Product agreementDTOToProduct(AgreementDTO agreementDTO) { if ( agreementDTO == null ) { return null; } Product product = new Product(); product.setId( agreementDTO.getProductId() ); return product; } protected Set<Attachment> attachmentDTOListToAttachmentSet(List<AttachmentDTO> list) { if ( list == null ) { return null; } Set<Attachment> set = new HashSet<Attachment>( Math.max( (int) ( list.size() / .75f ) + 1, 16 ) ); for ( AttachmentDTO attachmentDTO : list ) { set.add( attachmentMapper.mapToAttachment( attachmentDTO ) ); } return set; } }

W przypadku w którym chcemy wystawić metodę mapującą kolekcję Attachments -> AttachmentsDTO na zewnątrz musimy zadeklarowac to w interfejsie AgreementMapper. Aby tego dokonać należy dodać deklarację metody, która określa z jakiej kolekcji na jaką powinno odbyć się mapowanie. Niżej znajduje się zamiana List<AttachmentDTO> na Set<Attachment> z wykorzystaniem deklaracji metody oraz wygenerowaną dla niej implementacją.

import org.mapstruct.Mapper; import org.mapstruct.Mapping; import java.util.List; import java.util.Set; @Mapper(componentModel = "spring") public interface AttachmentMapper { @Mapping(source = "fileName", target = "name") Attachment mapToAttachment(AttachmentDTO attachmentDTO); Set<Attachment> mapToAttachmentSet(List<AttachmentDTO> attachmentDTOS); } import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Set; import javax.annotation.Generated; import org.springframework.stereotype.Component; @Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2019-03-05T20:17:57+0100", comments = "version: 1.3.0.Final, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)" ) @Component public class AttachmentMapperImpl implements AttachmentMapper { @Override public Attachment mapToAttachment(AttachmentDTO attachmentDTO) { if ( attachmentDTO == null ) { return null; } Attachment attachment = new Attachment(); attachment.setName( attachmentDTO.getFileName() ); attachment.setId( attachmentDTO.getId() ); Byte[] file = attachmentDTO.getFile(); if ( file != null ) { attachment.setFile( Arrays.copyOf( file, file.length ) ); } return attachment; } @Override public Set<Attachment> mapToAttachmentSet(List<AttachmentDTO> attachmentDTOS) { if ( attachmentDTOS == null ) { return null; } Set<Attachment> set = new HashSet<Attachment>( Math.max( (int) ( attachmentDTOS.size() / .75f ) + 1, 16 ) ); for ( AttachmentDTO attachmentDTO : attachmentDTOS ) { set.add( mapToAttachment( attachmentDTO ) ); } return set; } }

Współdzielenie Konfiguracji

Dziedziczenie konfiguracji


Wyobraźmy sobie, iż potrzebujemy stworzyć nową metodę mapujacą AgreementDTO na Agreement ale z pominięciem załączników. Abyśmy nie musieli od nowa konfigurować reguł mapowania twórcy MapStruct udostępniają do tego celu mechanizm dziedziczenia konfiguracji dzięki adnotacji @InheritConfiguration. Dodanie tej adnotacji do metody mapującej sprawia, iż dziedziczy ona wszystkie reguły mapujące z rodzica. Po użyciu dziedziczenia możemy także zdefiniować dodatkowe reguły. W naszym przypadku dodamy ignorowanie pola attachment.

Uwaga: Reguły w klasie dziedziczącej nadpisują te z klasy dziedziczone jeżeli dotyczą tego samego pola target. Można to zauważyć na przykładzie pola attachment

import org.mapstruct.InheritConfiguration; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @Mapper(uses = {AttachmentMapper.class}) public interface AgreementMapper { @Mapping(source = "agreementName", target = "name") @Mapping(source = "productId", target = "product.id") @Mapping(source = "agreementType", target = "type") @Mapping(source = "attachmentDTO", target = "attachment") Agreement mapToAgreement(AgreementDTO agreementDTO); @InheritConfiguration @Mapping(ignore = true, target = "attachment") Agreement mapToAgreementWithoutAttachment(AgreementDTO agreementDTO); } import org.mapstruct.factory.Mappers; import javax.annotation.Generated; @Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2019-01-23T23:43:06+0100", comments = "version: 1.3.0.Beta2, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)") public class AgreementMapperImpl implements AgreementMapper { private final AttachmentMapper attachmentMapper = Mappers.getMapper(AttachmentMapper.class); @Override public Agreement mapToAgreement(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Agreement agreement = new Agreement(); agreement.setProduct(agreementDTOToProduct(agreementDTO)); agreement.setName(agreementDTO.getAgreementName()); agreement.setAttachment(attachmentMapper.mapToAttachment(agreementDTO.getAttachmentDTO())); if (agreementDTO.getAgreementType() != null) { agreement.setType(Enum.valueOf(AgreementType.class, agreementDTO.getAgreementType())); } agreement.setId(agreementDTO.getId()); agreement.setConclusionDate(agreementDTO.getConclusionDate()); return agreement; } @Override public Agreement mapToAgreementWithoutAttachment(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Agreement agreement = new Agreement(); agreement.setProduct(agreementDTOToProduct1(agreementDTO)); agreement.setName(agreementDTO.getAgreementName()); if (agreementDTO.getAgreementType() != null) { agreement.setType(Enum.valueOf(AgreementType.class, agreementDTO.getAgreementType())); } agreement.setId(agreementDTO.getId()); agreement.setConclusionDate(agreementDTO.getConclusionDate()); return agreement; } protected Product agreementDTOToProduct(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Product product = new Product(); product.setId(agreementDTO.getProductId()); return product; } protected Product agreementDTOToProduct1(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Product product = new Product(); product.setId(agreementDTO.getProductId()); return product; } }

Bilet tylko w jedną stronę ?


Analizując powyższe przykłady można pomyśleć “Ale zaraz zaraz chwileczkę, a co jeżeli chcemy mapować obiekt w drugą stronę ? Czy musimy spisywać całą konfigurację jeszcze raz ale w odwrotnym kierunku?” Odpowiedź brzmi: oczywiście, iż nie. W większości przypadków mapowanie w drugą stronę jest podobne i wystarczy odwrócić jego kierunek. MapStruct oprócz dziedziczenia konfiguracji udostępnia nam mechanizm odwróconego dziedziczenia który zajmie się odwróceniem mapowania bazując na istniejącej konfiguracji.

Należy zauważyć, iż jeżeli mamy zdefiniowaną więcej niż jedną konfigurację, to podczas jej dziedziczenia musimy podać z którego schematu chcemy skorzystać. Wspomnianą sytuację widać w podświetlonej linni 19 poniższego przykładu

import org.mapstruct.InheritConfiguration; import org.mapstruct.InheritInverseConfiguration; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @Mapper(uses = {AttachmentMapper.class}) public interface AgreementMapper { @Mapping(source = "agreementName", target = "name") @Mapping(source = "productId", target = "product.id") @Mapping(source = "agreementType", target = "type") @Mapping(source = "attachmentDTO", target = "attachment") Agreement mapToAgreement(AgreementDTO agreementDTO); @InheritConfiguration(name = "mapToAgreement") @Mapping(ignore = true, target = "attachment") Agreement mapToAgreementWithoutAttachment(AgreementDTO agreementDTO); @InheritInverseConfiguration(name = "mapToAgreement") AgreementDTO mapToAgreementDTO(Agreement agreement); } import org.mapstruct.Mapper; import org.mapstruct.Mapping; @Mapper public interface AttachmentMapper { @Mapping(source = "fileName", target = "name") Attachment mapToAttachment(AttachmentDTO attachmentDTO); } package demo.packages; import java.util.Arrays; import javax.annotation.Generated; import org.mapstruct.factory.Mappers; @Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2019-01-27T17:37:20+0100", comments = "version: 1.3.0.Beta2, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)" ) public class AgreementMapperImpl implements AgreementMapper { private final AttachmentMapper attachmentMapper = Mappers.getMapper(AttachmentMapper.class); @Override public Agreement mapToAgreement(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Agreement agreement = new Agreement(); agreement.setProduct(agreementDTOToProduct(agreementDTO)); agreement.setName(agreementDTO.getAgreementName()); agreement.setAttachment(attachmentMapper.mapToAttachment(agreementDTO.getAttachmentDTO())); if (agreementDTO.getAgreementType() != null) { agreement.setType(Enum.valueOf(AgreementType.class, agreementDTO.getAgreementType())); } agreement.setId(agreementDTO.getId()); agreement.setConclusionDate(agreementDTO.getConclusionDate()); return agreement; } @Override public Agreement mapToAgreementWithoutAttachment(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Agreement agreement = new Agreement(); agreement.setProduct(agreementDTOToProduct1(agreementDTO)); agreement.setName(agreementDTO.getAgreementName()); if (agreementDTO.getAgreementType() != null) { agreement.setType(Enum.valueOf(AgreementType.class, agreementDTO.getAgreementType())); } agreement.setId(agreementDTO.getId()); agreement.setConclusionDate(agreementDTO.getConclusionDate()); return agreement; } @Override public AgreementDTO mapToAgreementDTO(Agreement agreement) { if (agreement == null) { return null; } AgreementDTO agreementDTO = new AgreementDTO(); if (agreement.getType() != null) { agreementDTO.setAgreementType(agreement.getType().name()); } agreementDTO.setAttachmentDTO(attachmentToAttachmentDTO(agreement.getAttachment())); Long id = agreementProductId(agreement); if (id != null) { agreementDTO.setProductId(id); } agreementDTO.setAgreementName(agreement.getName()); agreementDTO.setId(agreement.getId()); agreementDTO.setConclusionDate(agreement.getConclusionDate()); return agreementDTO; } protected Product agreementDTOToProduct(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Product product = new Product(); product.setId(agreementDTO.getProductId()); return product; } protected Product agreementDTOToProduct1(AgreementDTO agreementDTO) { if (agreementDTO == null) { return null; } Product product = new Product(); product.setId(agreementDTO.getProductId()); return product; } protected AttachmentDTO attachmentToAttachmentDTO(Attachment attachment) { if (attachment == null) { return null; } AttachmentDTO attachmentDTO = new AttachmentDTO(); attachmentDTO.setId(attachment.getId()); Byte[] file = attachment.getFile(); if (file != null) { attachmentDTO.setFile(Arrays.copyOf(file, file.length)); } return attachmentDTO; } private Long agreementProductId(Agreement agreement) { if (agreement == null) { return null; } Product product = agreement.getProduct(); if (product == null) { return null; } Long id = product.getId(); if (id == null) { return null; } return id; } }

MapStruct wygenerował nam mapowanie w drugą stronę, choć czai się tutaj jeden bug, Mianowicie zapomnieliśmy dodać takiej samej adnotacji w klasie AttachmentMapper co poskutkowało próbą wygenerowania odwrotnego mappera dla AttachmentDTO i zignorowaniem pól różniących się nazwą. Aby to naprawić musimy dodać mapowanie obiektów w drugą stronę w mapperze odpowiedzialnym za załączniki.

import org.mapstruct.InheritInverseConfiguration; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @Mapper public interface AttachmentMapper { @Mapping(source = "fileName", target = "name") Attachment mapToAttachment(AttachmentDTO attachmentDTO); @InheritInverseConfiguration AttachmentDTO mapToAttachmentDTO(Attachment attachment); } import java.util.Arrays; import javax.annotation.Generated; @Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2019-01-23T23:53:11+0100", comments = "version: 1.3.0.Beta2, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)") public class AttachmentMapperImpl implements AttachmentMapper { @Override public Attachment mapToAttachment(AttachmentDTO attachmentDTO) { if (attachmentDTO == null) { return null; } Attachment attachment = new Attachment(); attachment.setName(attachmentDTO.getFileName()); attachment.setId(attachmentDTO.getId()); Byte[] file = attachmentDTO.getFile(); if (file != null) { attachment.setFile(Arrays.copyOf(file, file.length)); } return attachment; } @Override public AttachmentDTO mapToAgreementDTO(Attachment attachment) { if (attachment == null) { return null; } AttachmentDTO attachmentDTO = new AttachmentDTO(); attachmentDTO.setFileName(attachment.getName()); attachmentDTO.setId(attachment.getId()); Byte[] file = attachment.getFile(); if (file != null) { attachmentDTO.setFile(Arrays.copyOf(file, file.length)); } return attachmentDTO; } }

Aktualizowanie istniejących obiektów

W niektórych przypadkach nie potrzebujemy mappera, który tworzy nowy obiekt, zamiast tego chcemy go zaktualizować informacjami z obiektu przekazanego jako parametr. Możemy to zrealizować tworząc metodę typu void oraz dodając do niej drugi parametr oznaczony adnotacją @MappingTarget. Należy tutaj zauważyć, iż w przypadku aktualizacji obiektu również możemy stosować wszystkie poznane adnotacje mapujące.

import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingTarget; @Mapper public interface AgreementMapper { @Mapping(source = "agreementName", target = "name") @Mapping(source = "productId", target = "product.id") @Mapping(source = "agreementType", target = "type") @Mapping(ignore = true, target = "attachments") void updateAgreementFromDTO(AgreementDTO agreementDTO, @MappingTarget Agreement agreement); } import javax.annotation.Generated; @Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2019-01-27T20:38:57+0100", comments = "version: 1.3.0.Beta2, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)" ) public class AgreementMapperImpl implements AgreementMapper { @Override public void updateAgreementFromDTO(AgreementDTO agreementDTO, Agreement agreement) { if (agreementDTO == null) { return; } if (agreement.getProduct() == null) { agreement.setProduct(new Product()); } agreementDTOToProduct(agreementDTO, agreement.getProduct()); agreement.setName(agreementDTO.getAgreementName()); if (agreementDTO.getAgreementType() != null) { agreement.setType(Enum.valueOf(AgreementType.class, agreementDTO.getAgreementType())); } agreement.setId(agreementDTO.getId()); agreement.setConclusionDate(agreementDTO.getConclusionDate()); } protected void agreementDTOToProduct(AgreementDTO agreementDTO, Product mappingTarget) { if (agreementDTO == null) { return; } mappingTarget.setId(agreementDTO.getProductId()); } }

Metoda zaktualizuje przekazany obiekt agreement danymi z obiektu agreementDTO. Jednocześnie w tej samej metodzie może istnieć tylko jeden parametr oznaczony adnotacją @MappingTarget. Można także zamiast metody typu void zwrócić obiekt tak jak to robiliśmy we wszystkich poprzednich mapperach z tą różnicą, iż wygenerowana implementacja zaktualizuje oraz zwróci obiekt oznaczony jako @MappingTarget zamiast tworzyć nowy.

import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingTarget; @Mapper public interface AgreementMapper { @Mapping(source = "agreementName", target = "name") @Mapping(source = "productId", target = "product.id") @Mapping(source = "agreementType", target = "type") @Mapping(ignore = true, target = "attachments") Agreement updateAgreementFromDTO(AgreementDTO agreementDTO, @MappingTarget Agreement agreement); } import javax.annotation.Generated; @Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2019-01-27T21:17:33+0100", comments = "version: 1.3.0.Beta2, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)" ) public class AgreementMapperImpl implements AgreementMapper { @Override public Agreement updateAgreementFromDTO(AgreementDTO agreementDTO, Agreement agreement) { if ( agreementDTO == null ) { return null; } if ( agreement.getProduct() == null ) { agreement.setProduct( new Product() ); } agreementDTOToProduct( agreementDTO, agreement.getProduct() ); agreement.setName( agreementDTO.getAgreementName() ); if ( agreementDTO.getAgreementType() != null ) { agreement.setType( Enum.valueOf( AgreementType.class, agreementDTO.getAgreementType() ) ); } agreement.setId( agreementDTO.getId() ); agreement.setConclusionDate( agreementDTO.getConclusionDate() ); return agreement; } protected void agreementDTOToProduct(AgreementDTO agreementDTO, Product mappingTarget) { if ( agreementDTO == null ) { return; } mappingTarget.setId( agreementDTO.getProductId() ); } }

Pobieranie instancji mappera

Aby móc korzystać z mappera musimy albo stworzyć jego instancję manualnie albo pobrać już istniejącą. Tworzenie instancji mapperów za każdym razem kiedy są nam potrzebne nie było by za dobrym rozwiązaniem. Ponieważ obiekt ten jest niezmienny ponieważ nie posiada żadnych stanów oraz danych Twórcy MapStruct wykorzystali do jego pobierania wzorzec Singleton. Niżej zostaną przedstawione dwie techniki pozwalające pobrać instancję zdefiniowanych wcześniej klas mapujących.

Za pomocą fabryki


Jeśli nie wykorzystujemy w naszej aplikacji Dependency Injection możemy skorzystać z metody fabrykującej dostarczonej przez Framework. Tworzy ona instancje mappera w definicji interfejsu jako pole statyczne. Do pola tego od tej pory będziemy mogli odwołać się wpisując nazwę interfejsu i podając nazwę jego instancji. Zapobiega to wielokrotnemu tworzeniu takich samych obiektów, zamiast tego istnieje tylko jedna instancja Mappera w całej aplikacji. Obiekt ten jest w pełni bezpieczny wątkowo ponieważ realizuje mapowanie dzięki metod więc stworzone obiekty wewnątrz nich nie są współdzielone, a także nie posiada żadnych zmiennych globalnych mogących wpłynąć na wynik operacji . Zostało to podświetlone w przykładzie poniżej. Linnia 7 w klasie AgreementMapper przedstawia inicjializacje mappera korzystając z fabryki natomiast ta sama linia w klasie AgreementService obrazuje korzystanie z uprzednio zdefiniowanego mappera.

import org.mapstruct.*; import org.mapstruct.factory.Mappers; @Mapper(uses = {AttachmentMapper.class}) public interface AgreementMapper { AgreementMapper INSTANCE = Mappers.getMapper(AgreementMapper.class); @Mapping(source = "agreementName", target = "name") @Mapping(source = "productId", target = "product.id") @Mapping(source = "agreementType", target = "type") @Mapping(ignore = true, target = "attachments") void updateAgreementFromDTO(AgreementDTO agreementDTO, @MappingTarget Agreement agreement); @Mapping(source = "agreementName", target = "name") @Mapping(source = "productId", target = "product.id") @Mapping(source = "agreementType", target = "type") @Mapping(source = "attachmentsDTO", target = "attachments") Agreement mapToAgreement(AgreementDTO agreementDTO); @InheritConfiguration(name = "mapToAgreement") @Mapping(ignore = true, target = "attachments") Agreement mapToAgreementWithoutAttachment(AgreementDTO agreementDTO); @InheritInverseConfiguration(name = "mapToAgreement") AgreementDTO mapToAgreementDTO(Agreement agreement); } package demo.packages; public class AgreementService { .... Agreement agreement = AgreementMapper.INSTANCE.mapToAgreement(agreementDTO); .... }

Wstrzykiwanie Maperów, oddelegowanie zarządzania do kontenera IoC


W projektach, które używają kontenera IoC np wykorzystują framework spring mamy możliwość skonfigurowania mappera jako komponent. Oddelegowujemy wtedy zarządzanie nim do kontenera Ioc, który załatwia za nas tworzenie i wstrzykiwanie zależności do innych komponentów. Aby tego dokonać musimy zmodyfikować adnotacje @Mapper dodając do niej atrybut component model=”spring”. Spowoduje to dodanie adnoacji @Component do wygenerowanej implementacji mappera..

UWAGA: nazwa w component model zależy od wykorzystywanego przez nas Dependency Injection. W naszym przypadku jest to DI od springa, ale możemy także użyć innych wspieranych przez MapStruct np CDI.

import org.mapstruct.*; @Mapper(uses = {AttachmentMapper.class},componentModel = "spring") public interface AgreementMapper { @Mapping(source = "agreementName", target = "name") @Mapping(source = "productId", target = "product.id") @Mapping(source = "agreementType", target = "type") @Mapping(ignore = true, target = "attachments") void updateAgreementFromDTO(AgreementDTO agreementDTO, @MappingTarget Agreement agreement); .... import org.springframework.stereotype.Service; import java.math.BigDecimal; @Service public class AgreementService { private final AgreementMapper agreementMapper; public AgreementService(AgreementMapper agreementMapper) { this.agreementMapper = agreementMapper; } public BigDecimal calculatePrice(){ AgreementDTO agreementDTO = new AgreementDTO(); Agreement agreement = agreementMapper.mapToAgreement(agreementDTO); return null; } } import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import javax.annotation.Generated; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2019-02-02T18:46:54+0100", comments = "version: 1.3.0.Beta2, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)" ) @Component public class AgreementMapperImpl implements AgreementMapper { @Autowired private AttachmentMapper attachmentMapper; ....

Jak widzimy na powyższym przykładzie podczas generowania implementacji mappera w klasie AgreementMapperImpl została dodana adnotacja @Component pozwoliło to na oddelegowanie całego zarządzania obiektem do springa framework. Dzięki temu w klasie AgreementService mogliśmy wstrzyknąć zależność przez konstruktor ( możemy to też zrobić dzięki adnotacji @Autowired nad zmienną lub setterem, ja jednak preferuje wstrzykiwanie przez konstruktor).

Uwaga: Wstrzykiwanie przez konstruktor bez adnotacji @Autowired została dodane dopiero w wersji springa 4.3, niższe wersje frameworka wymagają dodania wyżej wymienionej adnotacji.

TIP1: Strategię wstrzykiwania mapperów w wygenerowanych klasach możemy zmieniać dzięki odpowiedniej konfiguracji adnotacji @Mapper, dodając “injectionStrategy = InjectionStrategy.CONSTRUCTOR”. Może to być przydatne np. podczas testów.

TIP2: Strategie otrzymywania Mapperów możemy także zdefiniować globalnie odpowiednio konfigurując procesor konfiguracji dodany do naszego pom.xml na początku artykułu.
Wystarczy Zmodyfikować:

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.5.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> <path> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>${lombok.version}</version> </path> </annotationProcessorPaths> <compilerArgs> <arg> -Amapstruct.defaultComponentModel=spring </arg> </compilerArgs> </configuration> </plugin>

Koniec części 1

Temat okazał się bardzo obszerny więc postanowiłem podzielić go na dwie części. W kolejnym artykule przedstawię bardziej zaawansowane aspekty i konfigurację jakie skrywa ten Framework.
Jeśli na tym etapie pojawiły się jakieś pytania lub jest coś niejasne zachęcam do zostawienia komentarza. Zachęcam także do zapoznania się z następną częścią (tutaj).

Idź do oryginalnego materiału