W tym artykule na warsztat znowu wzięty zostanie framework MapStruct, zamieszczę tutaj resztę konfiguracji i przypadków użycia kilku przydatnych adnotacji. Dla tych, którzy nie mieli nigdy do czynienia z tytułowym frameworkiem odsyłam do pierwszej części (tutaj). Zapoznanie się z nią jest niezbędne do zrozumienia tej zawartości.
Uwaga: Tak samo jak w części 1 kawałki kodu na które powinniśmy zwrócić szczególną uwagę zostaną podświetlone na żółto.
Mapowanie typów enumowych – @ValueMapping
Często przy pracy np. z web serwisami zewnętrzny model danych zdecydowanie różni się od tego używanego wewnątrz aplikacji. Zachodzi wtedy konieczność mapowania np. wartości enumowych z jednego typu na drugi. MapStruct domyślnie mapuje wartości z enuma źródłowego na wartości o tej samej nazwie w enumie docelowym.
Jednak rzadko mamy taką sytuację, iż enumy te mają wszystkie takie same wartości. jeżeli by tak było nie zachodziła by konieczność ich mapowania. Z pomocą przychodzi adnotacja @ValueMapping, która działa na podobnej zasadzie jak wcześniej poznana adnotacja @Mapping. Różnica polega na tym, iż nie wskazujemy nazwy zmiennej tylko nazwę stałej enumowej. Mamy także możliwość zmapowania kilku wartości źródłowych do jednej stałej w enumie docelowym. Dodatkową zaletą wykorzystania MapStruct jest możliwość zastosowania poznanej wcześniej adnotacji @InheritInverseConfiguration dzięki której małym kosztem wygenerujemy mapowanie w drugą stronę.
package demo.packages; public enum AgreementStatus { NEW, WAITING, CANCELED, CLOSED, ACCEPTED, DELETED, OTHER } public enum ExternalAgreementStatus { FRESH, PENDING, REFUSED, CLOSED, OTHER }Uwaga: Należy zauważyć, iż jeden enum posiada więcej wartości niż drugi, dlatego trzeba pamiętać żeby zmapować WSZYSTKIE wartości z enumu źródłowego. W przeciwnym razie nie będziemy w stanie skompilować aplikacji. Można to zauważyć w metodzie mapującej AgreementStatus na ExternalAgreementStatus mimo, iż korzystamy z dziedziczenia konfiguracji to i tak musimy zmapować brakujące wartości. W przeciwieństwie do adnotacji @Mapper nie mamy tutaj możliwości ignorowania pól, zamiast tego możemy skorzystać z innej techniki, mapującej pozostałe pola do wartości domyślnej.
Jednak aby nie wprowadzać zbędnego zamieszania tą technikę poznamy w następnym akapicie.
Jak widzimy wyżej OTHER i CLOSED nie różnią się od siebie w obu enumach, więc nie ma sensu mapować ich manualnie. Twórcy frameworka udostępniają dwie strategie jakie możemy wykorzystać w takim przypadku: MappingConstants.ANY_REMAINING i MappingConstants.ANY_UNMAPPED. Należy jednak wiedzieć, iż jest między nimi jedna istotna różnica:
- ANY_REMAINING „@ValueMapping(source = MappingConstants.ANY_REMAINING ,target = „OTHER”)” – przy użyciu tej wartości informujemy Procesor Adnotacji o tym, iż wartości o tej samej nazwie w enumie źródłowym mają mapować się bezpośrednio na takie same wartości w enumie docelowym. Zaś te dla których nie ma odwzorowania w postaci takiej samej nazwy, a także nie zdefiniowaliśmy mapowania manualnie zostaną zmapowane na wartość podaną w parametrze target. Poniżej znajduje się konfiguracja mappera z wykorzystaniem ANY_REMAINING wraz z wygenerowanym mapowaniem.
- ANY_UNMAPPED “@ValueMapping(source = MappingConstants.ANY_UNMAPPED ,target = “OTHER“)” – dzięki tej konstrukcji informujemy Procesor Adnotacji, iż wszystkie zmapowane manualnie przez nas wartości powinny się mapować na wartość podaną w parametrze target. Różnica polega na tym, iż jesli nie zdefiniujemy mapowania dla stałych dzięki @ValueMapping , to choćby wartości posiadające takie same nazwy w obydwu enumach zostaną zmapowane do wartości domyślnej.
Poniżej znajduje się konfiguracja mappera z wykorzystaniem ANY_UNMAPPED wraz z wygenerowanym mapowaniem.
Została jeszcze jedna wartość, którą mogli byśmy chcieć mapować w określonych sytuacjach. Aby obsłużyć wartość null i zamienić ją na stałą w enumie docelowym należy skorzystać z @ValueMapping(source = MappingConstants.NULL ,target = “OTHER“). Taka konfiguracja poinformuje procesor adnotacji aby mapowął wartości null na stałą OTHER.
Uwaga: Nie możemy używać konfiguracji ANY_REMAINING lub ANY_UNMAPPED jednocześnie. Możemy jednak łączyć je z konfiguracją MappingConstants.NULL.
Uwaga2: @InheritInverseConfiguration nie działa jeżeli używamy ANY_REMAINING lub ANY_UNMAPPED w metodzie z której dziedziczymy konfigurację.
Kwalifikowanie metody mapującej dzięki jej nazwy
@Named
Wyobraźmy sobie sytuację iż w klasie ProductMapper znajdują się 2 mapowania: jedno standardowe, a drugie z pominięciem pola details. Pytanie jak zachowa się AgreementMapper gdy w jej skład wchodzi klasa Product zawierająca 2 mapowania? Gdy nie skonfigurujemy żadnych reguł framework wyrzuci błąd podczas kompilacji. Stanie się tak ponieważ MapStruct nie będzie potrafił jednoznacznie stwierdzić z którego mapowania klasy Product powinien skorzystać. Z tej sytuacji także możemy wyjść obronną ręką wykorzystując metody nazwane, oznaczone dzięki adnotacji @Named. Wykorzystanie tej adnotacji pozwala na wykorzystanie opatrzonej przez nią metody w konfiguracji mapowania pola.
W lini 8 poniższego przykładu dodaliśmy atrybut qualifiedByName wraz z nazwą odnoszącą się do wcześniej podanej w adnotacji @Named.
@IterableMapping
W części 1 artykułu mówiliśmy, iż Mapstruct zajmuje się za nas mapowaniem kolekcji typów core Java oraz tych dla których zdefiniowaliśmy manualnie mapowanie.
Dla przypomnienia poniżej został załączony przykład:
Opisane zostało także dziedziczenie konfiguracji. Mówiliśmy, iż jeżeli istnieje więcej niż jedna konfiguracja to musimy wskazać z której chcemy skorzystać przy dziedziczeniu .
Było to przedstawione na przykładzie ignorowania załączników w klasie Agreement. Dodając do tego mapowanie kolekcji pojawia się pewien problem, mianowicie MapStruct nie wie której z 2 konfiguracji mapowania AgreementDTO -> Agreement użyć. W takim przypadku przy próbie kompilacji zostanie wyrzucony błąd. Sposobem na rozwiązanie problemu jest użycie adnotacji @IterableMapping połączonej z wcześniej poznaną @Named. Pozwala to na wskazanie konkretnej implementacji z której chcemy skorzystać podczas mapowania kolekcji. Jej wykorzystanie przedstawione zostało w poniższym przykładzie:
Kwalifikowanie metody mapującej dzięki własnej Adnotacji
Istnieje jeszcze jeden sposób manualnego kwalifikowania metod mianowicie wykorzystanie qualifiedBy zamiast qualifiedByName. Sposób ten wykorzystuje manualnie napisane przez nas adnotacje. w uproszczeniu w miejsce adnotacji @Named wstawiamy własnoręcznie stworzoną adnotację. przykład poniżej:
import org.mapstruct.Mapper; import org.mapstruct.Mapping; @Mapper(uses = {AttachmentMapper.class,ProductMapper.class}) public interface AgreementMapper { @Mapping(source = "agreementName", target = "name") @Mapping(source = "productDTO", target = "product", qualifiedBy = ProductWithoutDescription.class) @Mapping(source = "agreementType", target = "type") @Mapping(source = "attachmentsDTO", target = "attachments") Agreement mapToAgreement(AgreementDTO agreementDTO); } @Mapper(componentModel = "spring") public interface ProductMapper { @Mapping(source = "description", target = "details") Product mapToProduct(ProductDTO productDTO); @Mapping(ignore = true, target = "details") @ProductWithoutDescription Product mapToProductWithoutDescription(ProductDTO productDTO); } import org.mapstruct.Qualifier; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Qualifier @Target(ElementType.METHOD) @Retention(RetentionPolicy.CLASS) public @interface ProductWithoutDescription { }Ten sam zabieg można wykorzystać tworząc adnotację @MapWithoutAttachments, którą opatrzymy metodę mapującą mapToAgreementWithoutAttachments oraz dodając ją do @IterableMapping(qualifiedBy = MapWithoutAttachments.class”).
Sposób z adnotacją może być wygodniejszy ze względu na wyszukanie jej użycia w kodzie lub refaktor. Z drugiej strony jeżeli zdecydujemy się na adnotacje w dużym projekcie może przerazić nas ich ilość.
Uwaga: Wykorzystując własne adnotacje do kwalifikowania metod mapujących koniecznie musimy pamiętać o załączeniu do niej adnotacji @Qualifier z pakietu MapStruct.
Metody typu Callback
@AfterMapping
Czasami bywa tak, iż po przeprowadzeniu mapowania potrzebujemy jeszcze wykonać jakąś operację na mapowanym obiekcie. Przykładowo musimy uzupełnić pola w zależności od przekazanych warunków lub pobrać dodatkowe dane. Załóżmy, iż mamy taką sytuację w której klasa Agreement posiada obiekt anex typu Agreement zaś nasze DTO posiada tylko id anexu. Po zmapowaniu obiektu chcemy dodatkowo dociągnąć anex dzięki AgreementService. Tutaj pomóc nam może metoda opatrzona adnotacją @AfterMapping. Jest to metoda typu callback, która w tym przypadku wykona się zawsze pod koniec mapowania typów przekazanych do jej parametrów. W poniższym przypadku metoda afterAgreementDtoTOAgreementMapping wykona się zawsze pod koniec mapowania AgreementDTO –> Agreement. Stronę mapowania określa adnotacja @MappingTarget, bez niej procesor adnotacji nie uwzględni tej metody w implementacji mapperów i zostanie ona pominięta.
Uwaga: Abyśmy mogli wstrzyknąć bean springowy do implementacji mappera musimy zamienić interface na klasę abstrakcyjną i skorzystać z @Autowired. Zamiana interfejsu na klasę abstrakcyjną powinna przebiec bezkonfliktowo.
@BeforeMapping
Gdy już zapoznaliśmy się z działaniem @AfterMapping łatwo się domyślić, iż @BeforeMapping będzie podobną metodą. Różnica jest taka, iż metoda ta zostanie wykonana przed mapowaniem typów przekazanych jako argumenty. Tak jak w poprzednim przykładzie tak i tu niezbędna jest obecność adnotacji @MappingTarget która wskazuje kierunek mapowania oraz jest konieczna do wygenerowania ciała metody. BeforeMapping możemy wykorzystać np. do wykonania flush na encji ( jeżeli jest taka potrzeba ) w celu upewnienia się, iż obiekt został zapisany w bazie zanim zmapujemy go na DTO.
Uwaga: Do metody możemy przekazać tylko jeden argument opatrzony @MappingTarget. dodatkowo możemy dodać drugi argument tak jak w przypadku @AfterMappign, który potraktowany zostanie jako źródło.
@After/BeforeMapping tylko dla wybranych konfiguracji
Powyższe rozwiązanie ma według mnie jedną drobną aczkolwiek znaczącą wadę. Mianowicie, gdy posiadamy więcej niż jedno mapowanie AgreementDTO –> Agreement metody @BeforeMapping i @AfterMapping zostaną dodane do każdego z nich. Może to nie być do końca pożądane przez nas zachowanie. Sam spotkałem się z takim problemem i trudno było znaleźć jakieś rozwiązanie. I choć znalazłem wyjście z sytuacji to nie działa ono tak jak powinno. W dokumentacji znajdziemy dosłownie kilka zdań na ten temat:
All before/after-mapping methods that can be applied to a mapping method will be used. Mapping method selection based on qualifiers can be used to further control which methods may be chosen and which not. For that, the qualifier annotation needs to be applied to the before/after-method and referenced in BeanMapping#qualifiedBy or IterableMapping#qualifiedBy. Żródło
Z powyższego fragmentu możemy wywnioskować, iż da się to obsłużyć wykorzystując adnotację @BeanMapping/IterableMapping (w zależności, czy jest to pojedyńczy obiekt czy kolekcja) oraz jej atrybuty qualifiedBy / qualifiedByName. Atrybuty te poznaliśmy w sekcjach ‘kwalifikowanie metody mapującej dzięki nazwy’ i ‘kwalifikowanie metody mapującej dzięki własnej Adnotacji’.
Teoretycznie powinniśmy być w stanie manualnie w mapowanej metodzie wskazać, które metody before/after powinny być do niej załączone. W praktyce próbowałem to zrobić na różne sposoby, ale uzyskałem tylko połowiczny sukces. Wydaje mi się, iż jest to błąd w implementacji, który zgłoszę do Twórców na githubie. jeżeli zostanie w przyszłości poprawiony to zaktualizuje tego posta z uwzględnieniem poprawek. Na chwilę obecną możemy sterować tym do jakiej metody mają być załączone metody before/after jednak są one nie rozłączne. Oznacza to iż albo metoda mapująca skorzysta z obydwu albo z żadnej przykład powinien to wyjaśnić.
Uwaga: W tym przypadku raczej powinniśmy omijać dziedziczenie konfiguracji. Ponieważ gdy korzystamy z dziedziczenia konfiguracji, która zawiera
zakwalifikowany before/after odziedziczymy także @BeanMapping#qualifiedBy, a tego chcemy uniknąć.
Jak możemy zauważyć w powyższej implementacji w podświetlonych linijkach dodaliśmy adnotację @IncludeBeforeMapping oraz @IncludeAfterMapping nad metody after/before mapping. Zabieg ten pozwolił na wyłączenie automatycznego dołączania tychże metod do mapperów AgreementDTO agreementDTO –> Agreement agreement. Od tej chwili we wszystkie mapowaniach z AgreementDTO na Agreement musimy dołączyć manualnie do metody poprzez @BeanMapping#qualifiedBy. Co interesujące niezależnie co podamy w qualifiedBy czy to będzie IncludeBeforeMapping.class czy IncludeAfterMapping.class lub jakakolwiek inna adnotacja to w ciele metody zostaną załączone obydwie metody zarówno after jak i before mapping.
Tak jak pisałem wcześniej zakładam, iż jest to bug który zostanie przeze mnie zgłoszony.
Proponuję jednak pobawić się powyższym przykładem i sprawdzić wynik implementacji na własnej skórze. Plus na pewno jest taki iż możemy ograniczyć automatyczne dołączanie callback method do wybranych mapowań. Niestety na chwilę obecną jest to możliwe tylko w tandemie.
Pozostałe opcje Mapowania
Ta sekcja zawiera zbiór opcji mapowań rzadziej przeze mnie używanych aczkolwiek czasami bardzo pomocnych. Nie będę się tutaj rozpisywał na temat ich działania, ponieważ w większości przypadków można łatwo domyślić się ich działania z załączonego kodu źródłowego. jeżeli jednak będą jakieś pytania chętnie odpowiem na nie w komentarzach pod postem.
Wartości domyślne i stałe
Mapstruct daje nam mozliwość korzystania z wartości domyślnych oraz stałych podczas mapowaniu pól np.:
Jak można zauważyć wartość domyślna zostaje ustawiona tylko wtedy gdy wartość w source okaże się pusta. Inaczej to wygląda w przypadku stałych: atrybutu constant nie możemy łączyć z source. Wartość z tego pola ustawiana jest bezwarunkowo w zmiennej docelowej którą wskazuje atrybut target.
Format danych
Mamy także możliwość kontrolowania formatu daty oraz liczb dzięki odpowiednich atrybutów adnotacji @Mapping
@Mapper(componentModel = "spring") public interface SourceTargetMapper { @Mapping(target = "date", source = "stringProp", dateFormat = "dd-MM-yyyy") @Mapping(target = "stringProp", source = "intProp", numberFormat = "$#.00") Target mapToSource(Source source); @IterableMapping(numberFormat = "$#.00") List<String> prices(List<Integer> prices); @IterableMapping(dateFormat = "dd.MM.yyyy") List<String> stringListToDateList(List<LocalDate> dates); @MapMapping(valueDateFormat = "dd.MM.yyyy", keyNumberFormat = "$#.00") Map<String, String> longDateMapToStringStringMap(Map<Long, LocalDate> source); } @Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2019-02-28T23:02:58+0100", comments = "version: 1.3.0.Final, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)" ) @Component public class SourceTargetMapperImpl implements SourceTargetMapper { @Override public Target mapToSource(Source source) { if ( source == null ) { return null; } Target target = new Target(); if ( source.getStringProp() != null ) { target.setDate( LocalDate.parse( source.getStringProp(), DateTimeFormatter.ofPattern( "dd-MM-yyyy" ) ) ); } target.setStringProp( new DecimalFormat( "$#.00" ).format( source.getIntProp() ) ); return target; } @Override public List<String> prices(List<Integer> prices) { if ( prices == null ) { return null; } List<String> list = new ArrayList<String>( prices.size() ); for ( Integer integer : prices ) { list.add( new DecimalFormat( "$#.00" ).format( integer ) ); } return list; } @Override public List<String> stringListToDateList(List<LocalDate> dates) { if ( dates == null ) { return null; } List<String> list = new ArrayList<String>( dates.size() ); for ( LocalDate localDate : dates ) { list.add( DateTimeFormatter.ofPattern( "dd.MM.yyyy" ).format( localDate ) ); } return list; } @Override public Map<String, String> longDateMapToStringStringMap(Map<Long, LocalDate> source) { if ( source == null ) { return null; } Map<String, String> map = new HashMap<String, String>( Math.max( (int) ( source.size() / .75f ) + 1, 16 ) ); for ( java.util.Map.Entry<Long, LocalDate> entry : source.entrySet() ) { String key = new DecimalFormat( "$#.00" ).format( entry.getKey() ); String value = DateTimeFormatter.ofPattern( "dd.MM.yyyy" ).format( entry.getValue() ); map.put( key, value ); } return map; } }Wyrażenia i wyrażenia domyślne
Co interesujące mamy także możliwość wstrzykiwania kodu javy bezpośrednio z poziomu adnotacji @Mapper, który zostanie dodany podczas mapowania pola określonego w atrybucie target. Jednak moim zdaniem jeżeli to jest coś bardziej skomplikowanego to lepszym sposobem będzie manualne napisanie metody opatrzonej @Named/własną adnotacją i zakwalifikowanie jej do mapowania docelowego pola za pomocą qualifiedBy/qualifiedByName lub @After/BeforeMapping jeżeli potrzebujemy mapować kilka wartości źródłowych na jedną docelową tak jak to widać w przypadku linni 8.
@Mapper(imports = UUID.class,componentModel = "spring") public interface SourceTargetMapper { @Mapping(target = "id", source = "sourceId", defaultExpression = "java( UUID.randomUUID().toString() )") Target sourceToTarget(Source s); @Mapping(target = "dictionary", expression = "java( new SomeDictionary( s.getKey(), s.getValue() ) )") Target sourceToTargetWithCustomObject(Source s); }Jest jeszcze jedna kwestia o której należy wspomnieć. Niektórzy może zauważyli, iż w adnotacji @Mapper(imports = UUID.class,componentModel = “spring”) pojawił się dodatkowy atrybut imports = UUID.class jak sama nazwa wskazuje importuje on do implementacji wygenerowanego mappera klasę podaną jako wartość. Atrybut przyjmuje tablicę klas więc jest możliwość załączenia więcej niż jednej pozycji. W naszym przypadku było to konieczne ponieważ użyliśmy tej klasy we wstrzykiwanym kodzie Javy.
Podsumowanie
MapStruct to bardzo zaawansowane narzędzie, które w odpowiednich rękach może znacznie przyspieszyć pracę programisty. Jak można było zauważyć w tej części dzięki wysokiej możliwości konfiguracji z łatwością możemy dostosować generowane mappery do naszych potrzeb. Niewykluczone jednak, iż w niektórych przypadkach nie obędzie się bez dodania manualnej metody mapującej. Zachęcam do ściągnięcia przerabianego kodu z github (tutaj) i pobawienie się konfiguracją w domowym zaciszu.