Różne przypadki wykorzystania biblioteki MapStruct
2022-03-28
W dzisiejszym wpisie chciałbym przedstawić Ci różne przypadki wykorzystania biblioteki MapStruct w Twoim kodzie. Nie będą to jakieś skomplikowane rozwiązania, jednak dzięki nim dowiemy się co jeszcze potrafi to z pozoru proste narzędzie. W tym wpisie sprawdzimy w jaki sposób MapStruct radzi sobie z zagnieżdżonymi strukturami, jak można wykorzystać jeden mapper w drugim oraz jak mapować wartości jednego enuma do drugiego.
Bardziej skomplikowane struktury
Załóżmy, że chcemy przekonwertować strukturę, która agreguje w sobie inne struktury w postaci klas czy też kolekcji. Stwórzmy, więc odpowiednie elementy naszej układanki - klasy podlegające konwersji, interfejs podstawowego mapper oraz test weryfikujący jego działanie.
Oczywiście dla klas Country, Province oraz City tworzymy ich odpowiednik w postaci DTO. W ramach mappera dodajemy tylko jedną metodę mapującą Country na CountryDto. Jak się zaraz okaże jest to wystarczająca ilość kodu w tym interfejsie. Pozostaje nam napisać test weryfikujący mapowanie, który po uruchomieniu przejdzie bez najmniejszych problemów. MapStruct wygenerował za nas dodatkowe metody w mapperze, które konwertują Province i City na klasy z sufiksem *Dto.
//// Source code recreated from a .class file by IntelliJ IDEA// (powered by FernFlower decompiler)//packagepl.devcezz.mapstruct.tips;importjava.util.ArrayList;importjava.util.Iterator;importjava.util.List;publicclassCountryMapperImplimplementsCountryMapper{publicCountryMapperImpl(){}publicCountryDtomap(Countrycountry){if(country==null){returnnull;}else{List<ProvinceDto>provinces=null;Stringname=null;provinces=this.provinceListToProvinceDtoList(country.getProvinces());name=country.getName();CountryDtocountryDto=newCountryDto(name,provinces);returncountryDto;}}protectedCityDtocityToCityDto(Citycity){if(city==null){returnnull;}else{Stringname=null;name=city.getName();CityDtocityDto=newCityDto(name);returncityDto;}}protectedList<CityDto>cityListToCityDtoList(List<City>list){if(list==null){returnnull;}else{List<CityDto>list1=newArrayList(list.size());Iteratorvar3=list.iterator();while(var3.hasNext()){Citycity=(City)var3.next();list1.add(this.cityToCityDto(city));}returnlist1;}}protectedProvinceDtoprovinceToProvinceDto(Provinceprovince){if(province==null){returnnull;}else{List<CityDto>cities=null;Stringname=null;cities=this.cityListToCityDtoList(province.getCities());name=province.getName();ProvinceDtoprovinceDto=newProvinceDto(name,cities);returnprovinceDto;}}protectedList<ProvinceDto>provinceListToProvinceDtoList(List<Province>list){if(list==null){returnnull;}else{List<ProvinceDto>list1=newArrayList(list.size());Iteratorvar3=list.iterator();while(var3.hasNext()){Provinceprovince=(Province)var3.next();list1.add(this.provinceToProvinceDto(province));}returnlist1;}}}
Wychodzi na to, że biblioteka znając wewnętrzną strukturę klasy, dla której zdefiniujemy metodę mapującą, sama stworzy wymagane metody konwertujące dla agregowanych struktur. Oczywiście, aby to rozwiązanie zadziałało prawidłowo trzeba upewnić się, że nazwy i typy pól są takie same. W innym przypadku musimy użyć wcześniej przedstawionych adnotacji@Mappings oraz @Mapping na własnoręcznie utworzonych sygnaturach metod dla zawieranych klas. Trzeba jednak uważać na jedną rzecz. Jeśli po wygenerowaniu mappera zmienimy nazwę dla jednej z agregowanych struktur, dla których explicite nie zdefiniowaliśmy metody mapującej to test wyrzuci nam błąd. Aby to zweryfikować to przemianujmy ProvinceDto na VoivodeshipDto. W tym momencie po uruchomieniu testu dostaniemy komunikat (java.lang.NoClassDefFoundError) o nieznalezionej klasie pl/devcezz/mapstruct/tips/ProvinceDto. Spróbujmy to naprawić usuwając katalog target i odpalmy ponownie test. W tym momencie MapStruct wygeneruje poprawnie klasę mapującą i dostaniemy zielony pasek w swoim środowisku programistycznym.
Osobiście uważam, że nie warto mapować klas, których nazwy tak drastycznie różnią się od siebie oraz mają sporo agregowanych struktur danych. Wprowadza to duży chaos do kodu i osoby czytające go później będą musiały poświęcić więcej czasu, aby dojść do tego co dokładnie na co się mapuje.
Wykorzystanie Optional
Korzystanie z Optional pomaga nam pozbyć się problematycznego wyjątkuNullPointerException. Natomiast czasami może powstać potrzeba, aby jednak nie opakowywać w ten sposób wybranego pola klasy. Z jakiegoś powodu chcielibyśmy, aby mogło ono przyjąć wartość null. Załóżmy, więc że mamy wymaganie, aby przekonwertować klasę z polem Optional na klasę bez takiego wspomagania.
Jeśli teraz spróbujemy uruchomić powyższy test to MapStruct niestety nam nie pozwoli na jego wykonanie. Dostaniemy w zamian następującą wskazówkę: “java: Can’t map property "Optional<String> content” to “String content”. Consider to declare/implement a mapping method: "String map(Optional<String> value)”.”. Wychodzi na to, że musimy ręcznie dodać metodę mapującą daną wartość na Optional. Aby dokładnie wskazać jak ma odbyć się konwersja należy wykorzystać domyślną implementację metody w interfejsie BoxMapper.
Po napisaniu tego kawałka kodu test się uruchamia i przechodzi na zielono. Robota wykonana, ale co w przypadku, gdyby doszły kolejne typy wymagające mapowania na Optional i odwrotnie? Musielibyśmy dodawać kolejne specjalizowane metody? Na szczęście nie. Trzeba po prostu zmienić naszą implementację, aby wykorzystywała metody generyczne.
Z mappera BoxMapper usuwamy nowopowstałe metody i ustawiamy, aby rozszerzał on nasz nowy interfejs. Robimy następnie sprawdzenie za pomocą testu czy wszystko działa. Jak najbardziej kod jest prawidłowy. Nie musimy się już przejmować tym, że zaskoczy nas jakiś nowy typ pola opakowanego w Optional.
Mapper w mapperze
Czasami mamy już gotowy mapper dla jednej z wcześniej zdefiniowanych, rozbudowanych struktur. W pewnym momencie przychodzi wymaganie, aby dodać i zmapować nową klasę zawierającą w sobie właśnie tą strukturę. Zamiast przenosić wszystko do jednego interfejsu, możemy stworzyć kolejny i wykorzystać w nim uprzednio utworzony mapper. Użyjemy do tego właściwość uses z adnotacji @Mapper.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
packagepl.devcezz.mapstruct.tips.agregation;importjava.util.Objects;publicclassPerson{privateStringfirstname;privateStringsurname;privateIntegerage;//... more fieldspublicPerson(Stringfirstname,Stringsurname,Integerage){this.firstname=firstname;this.surname=surname;this.age=age;}//... getters & setter, equals & hashCode}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
packagepl.devcezz.mapstruct.tips.agregation;importjava.util.Objects;publicclassPersonDto{privateStringfullName;privateIntegeryearOfBirth;//... more fieldspublicPersonDto(StringfullName,IntegeryearOfBirth){this.fullName=fullName;this.yearOfBirth=yearOfBirth;}//... getters & setter, equals & hashCode}
Tą skomplikowaną strukturą w naszym przypadku jest klasa Person. W powyższym kodzie ma ona tylko 3 pola, ale załóżmy, że jest ich o wiele więcej (mniejsza ilość nie zaciemnia tak problemu). Stworzyliśmy dla tej struktury mapper wraz z dekoratorem, napisaliśmy testy i wszystko działa. Teraz dla nowego wymagania dojdzie nam klasa agregująca w sobie kolekcję typu Person.
Niestety te dwa przypadki testowe już nie przechodzą. Jak się okazuje DepartmentMapper nie wie w jaki sposób ma zmapować Person na PersonDto i odwrotnie. W pola reprezentujące nazwę i wiek wpisuje wartości null. Naprawa tego problemu jest naprawdę prosta. Wystarczy tylko mała zmiana, o której napisałem powyżej.
Teraz testy już przejdą bez problemu, ponieważ nauczyliśmy nasz mapper jak radzić sobie ze swoją zagnieżdżoną strukturą.
Mapowanie wartości enuma
Ostatnią rzeczą, na którą chciałbym zwrócić uwagę jest mapowanie wartości jednego enuma na drugiego. Może nastąpić taka sytuacja, że te same stałe będą miały inne nazwy, ale będą oznaczały to samo jak np. SUCCESS i ACCOMPLISHED. Jak zatem rozwiązać taki problem? Z pomocą przychodzą nam dwie adnotacje: @ValueMappings i @ValueMapping.
W tym momencie jeśli nie nauczymy naszego mappera jak ma dokonywać konwersji wartości to otrzymamy następujące komunikaty: “java: The following constants from the source enum have no corresponding constant in the target enum and must be be mapped via adding additional mappings: _FF0000, _0000FF, _008000, _FF9900.” oraz “java: The following constants from the source enum have no corresponding constant in the target enum and must be be mapped via adding additional mappings: RED, BLUE, GREEN.”. Aby je zlikwidować spróbujmy wykorzystać nowopoznane adnotacje.
Dwa z naszych przypadków testowych przeszły. Natomiast co w przypadku, gdy jeden z typów wyliczeniowych ma więcej wartości niż drugi? Jak wtedy zachowa się MapStruct? Przed uruchomieniem się testu dostaniemy do razu komunikat, że nie wiadomo jak ma zostać przekonwertowana nowa wartość: “java: The following constants from the source enum have no corresponding constant in the target enum and must be be mapped via adding additional mappings: _FF9900.” Tutaj z pomocą przyjdą nam stałe zdefiniowane w MappingConstants. Możemy wykorzystać wartość ANY_REMAINING, aby przemapować pozostałe wartości na np. jakąś wybraną wartość z danego enuma lub na null.
To by było na tyle tych dodatkowych, nieskomplikowanych przypadków użycia dla MapStruct. Wydaje mi się, że udało mi się zawszeć wszystkie informacje jakie chciałem przekazać na temat tej biblioteki w tych kilku artykułach. Sam nie byłem zwolennikiem tego rozwiązania, ale z czasem się do niego przekonałem. Głównie wpłynął na to fakt, że znacznie szybciej można dostarczyć działający kod mapujący dane. Zamiast samemu ręcznie tworzyć coś od nowa można skorzystać z gotowego, przetestowanego rozwiązania. Zachęcam Cię do spróbowania tych kilka prostych instrukcji w swoim kodzie i przekonania się o możliwościach MapStruct.