Wstęp
Pewnie nieraz spotkałeś się z potrzebą transformacji jednego obiektu Javy w inny. Czy to przy przekazywaniu danych między warstwami aplikacji (np. encja JPA -> DTO), czy przy integracji z zewnętrznymi serwisami. Klasycznie podchodzimy do tego, pisząc kod, który ręcznie przepisuje wartości z pól jednego obiektu do drugiego. Niby proste, ale przy większej liczbie pól i obiektów robi się z tego całkiem sporo powtarzalnej i, co gorsza, podatnej na błędy pracy. Dzisiaj przyjrzymy się, jak MapStruct może nam w tym pomóc i czym tak naprawdę różni się od takiego tradycyjnego podejścia.
Klasyczne podejście do mapowania obiektów
Zanim przejdziemy do MapStruct, przypomnijmy sobie, jak zazwyczaj wygląda ręczne mapowanie obiektów. Mamy dwie klasy, powiedzmy UserEntity
i UserDto
, i chcemy stworzyć metodę, która przekształci nam encję na DTO.
public class UserEntity { private Long id; private String firstName; private String lastName; private String email; private int age; // Gettery i settery } public class UserDto { private Long userId; private String fullName; private String contactEmail; private int yearsOld; // Gettery i settery }
Teraz dla powyższych struktur chcielibyśmy napisać klasyczny, ręcznie napisany mapper:
public class UserMapper { public UserDto entityToDto(UserEntity entity) { if (entity == null) { return null; } UserDto dto = new UserDto(); dto.setUserId(entity.getId()); dto.setFullName(entity.getFirstName() + " " + entity.getLastName()); dto.setContactEmail(entity.getEmail()); dto.setYearsOld(entity.getAge()); return dto; } public UserEntity dtoToEntity(UserDto dto) { if (dto == null) { return null; } UserEntity entity = new UserEntity(); entity.setId(dto.getUserId()); // Rozdzielenie fullName wymagałoby dodatkowej logiki if (dto.getFullName() != null) { String[] names = dto.getFullName().split(" "); if (names.length > 0) entity.setFirstName(names[0]); if (names.length > 1) entity.setLastName(names[1]); } entity.setEmail(dto.getContactEmail()); entity.setAge(dto.getYearsOld()); return entity; } }
Co tu widzimy?
- Mnóstwo kodu „klepanego” –
dto.setX(entity.getX())
. - Podatność na błędy – Możemy przez przypadek ustawić źle przypisywanie pola z jednego obiektu do drugiego, bo na przykład piszać kod nie zauważyliśmy, że ustawiamy
age
jakouserId
. - Trudności w utrzymaniu – Zmienisz nazwę pola w jednej klasie? Musisz pamiętać, żeby zaktualizować mapper. Dodasz nowe pole? Znowu ręczna robota.
- Logika mapowania rozproszona – Czasem proste przepisanie wartości nie wystarczy (jak w przypadku
fullName
). Taka logika ląduje bezpośrednio w metodzie mapującej.
Przy kilku polach to jeszcze nie tragedia. Ale wyobraź sobie, że mamy w aplikacji kilkadziesiąt takich mapperów albo skomplikowane transformacje. Taki kod szybko staje się koszmarem.
Jak działa MapStruct
MapStruct to biblioteka, która generuje kod mapujący w czasie kompilacji. Na podstawie zdefiniowanego interfejsu, podczas kompilacji (budowania) aplikacji generuje nam automatycznie implementacje tych interfejsów, podobne do tych, które napisalibyśmy ręcznie.
Warto też zaznaczyć, że MapStruct to zewnętrzna biblioteka, którą musimy dodać do naszego projektu (np. poprzez Maven lub Gradle). Nie jest to część standardowej biblioteki Javy.
Główne założenia MapStruct:
- Konwencja ponad konfiguracją- Dla pól o tych samych nazwach i typach, MapStruct sam sobie poradzi bez dodatkowych adnotacji.
- Bezpieczeństwo typów – Wszystkie mapowania są sprawdzane w czasie kompilacji. Jeśli coś jest nie tak (np. niezgodność typów, brakujące mapowanie), dowiesz się o tym od razu, a nie od klienta na produkcji.
- Generowanie czytelnego kodu – Wygenerowany kod jest bardzo podobny do tego, który napisałbyś ręcznie – prosty i wydajny.
Zobaczmy, jak wyglądałby nasz mapper UserMapper
przy użyciu MapStruct:
import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.factory.Mappers; @Mapper public interface UserMapStructMapper { UserMapStructMapper INSTANCE = Mappers.getMapper(UserMapStructMapper.class); @Mapping(source = "id", target = "userId") @Mapping(source = "email", target = "contactEmail") @Mapping(source = "age", target = "yearsOld") @Mapping(target = "fullName", expression = "java(entity.getFirstName() + \" \" + entity.getLastName())") UserDto userEntityToDto(UserEntity entity); @Mapping(source = "userId", target = "id") @Mapping(source = "contactEmail", target = "email") @Mapping(source = "yearsOld", target = "age") // Mapowanie fullName na firstName i lastName wymagałoby własnej metody lub bardziej złożonego expression // Dla prostoty tutaj pominiemy pełne mapowanie fullName na części @Mapping(target = "firstName", ignore = true) // Lub bardziej zaawansowane mapowanie @Mapping(target = "lastName", ignore = true) // Lub bardziej zaawansowane mapowanie UserEntity userDtoToEntity(UserDto dto); }
Co się tutaj dzieje?
- Tworzymy interfejs (nie klasę!) i oznaczamy go adnotacją
@Mapper
. - MapStruct automatycznie spróbuje zmapować pola o tych samych nazwach (np.
age
nayearsOld
by nie zadziałało bez@Mapping
). - Do mapowania pól o różnych nazwach używamy adnotacji
@Mapping(source = "nazwaPolaZrodlowego", target = "nazwaPolaDocelowego")
. - Dla bardziej skomplikowanych transformacji, jak połączenie
firstName
ilastName
wfullName
, możemy użyćexpression
(dla prostych operacji) lub zdefiniować własne metody mapujące w interfejsie (dla bardziej złożonej logiki). W naszym przykładzie użyliśmyexpression
dlauserEntityToDto
. UserMapStructMapper.INSTANCE
to standardowy sposób na uzyskanie instancji wygenerowanego mappera.- Jeśli byśmy używali Springa to moglibyśmy pominąć tworzenie
UserMapStructMapper.INSTANCE
i zamiast tego używać tego mappera jako komponentu Springa. Pozwoli nam to wtedy wstrzykiwać taki mapper do interesujących nas klas bez tworzenia statycznego obiektu.
- Jeśli byśmy używali Springa to moglibyśmy pominąć tworzenie
Podczas kompilacji projektu, MapStruct „przejrzy” ten interfejs i wygeneruje jego pełną implementację. Nie musisz pisać ani linijki kodu implementującego settery i gettery!
Kiedy wybrać MapStruct?
Czy to oznacza, że klasycznych mapperów nigdy nie powinniśmy używać? Niekoniecznie.
- MapStruct będzie idealny, gdy:
- Masz do czynienia z wieloma obiektami DTO/encji i skomplikowanymi transformacjami/mapowaniami.
- Cenisz sobie czysty, zwięzły kod i wysoką niezawodność.
- Pracujesz w zespole i chcesz ustandaryzować sposób mapowania obiektów.
- Chcesz uniknąć błędów runtime związanych z mapowaniem.
- Nie masz nic przeciwko dodaniu kolejnej, bardzo użytecznej, zależności do projektu.
- Klasyczny mapper może nadal wystarczyć, gdy:
- Mapujesz dosłownie kilka pól między dwoma prostymi obiektami.
- Projekt ma bardzo ścisłe restrykcje co do dodawania nowych zależności lub używania procesorów adnotacji.
- Potrzebujesz bardzo specyficznej, niestandardowej logiki, której wyrażenie w MapStruct byłoby bardziej skomplikowane niż napisanie jej ręcznie.
W większości przypadków korzyści płynące z MapStruct znacznie przewyższają wysiłek potrzebny na jego konfigurację i dodanie jako zależności.
Wskazówki z praktyki
Poniżej opisałem kilka wskazówek jak najlepiej korzystać z MapStructa:
- Lombok – Jeśli używamy już w projekcie tej biblioteki do generowania getterów, setterów, konstruktorów (np. przez
@Data
,@Value
,@Builder
), MapStruct świetnie z nim współpracuje. Należy tylko pamiętać o odpowiedniej konfiguracji procesorów adnotacji, aby Lombok działał przed MapStruct. - Domyślne mapowanie i
@Mapping
– Domyślnie MapStruct najpierw spróbuje zmapować pola o tych samych nazwach. Adnotacji@Mapping
powinniśmy używać tylko tam, gdzie nazwy się różnią lub potrzebna jest specjalna logika. - Metody niestandardowe – Dla bardziej złożonych transformacji (np. parsowanie daty, konwersja typów enum) można zdefiniować własne metody w interfejsie mappera. MapStruct będzie wiedział, jak ich użyć.
@InheritInverseConfiguration
– Jeśli mamy przypadek, gdzie jest mapowanieA -> B
i potrzebne jestB -> A
z podobnymi regułami, to można wykorzystać tą adnotację, którą pozwoli oszczędzić sporo pisania.- Testowanie – Mimo że MapStruct generuje kod, warto napisać testy jednostkowe dla bardziej skomplikowanych mapowań, aby upewnić się, że logika biznesowa jest poprawnie zaimplementowana.
- Sprawdzanie wygenerowanego kodu – Czasami, szczególnie na początku przygody z MapStruct, warto zajrzeć do wygenerowanej implementacji mappera (zazwyczaj znajduje się w katalogu
target/generated-sources/annotations
). Pomoże to zrozumieć, jak narzędzie interpretuje zdefiniowane adnotacje.
Podsumownie
MapStruct to świetna zewnętrzna biblioteka, która znacząco upraszcza i automatyzuje proces mapowania obiektów w Javie. Co prawa wymaga dodania jej do projektu i konfiguracji, ale w zamian redukuje ilość powtarzalnego kodu, zwiększa bezpieczeństwo dzięki sprawdzaniu w czasie kompilacji i generuje wydajne implementacje. Choć ręczne pisanie mapperów wciąż ma swoje miejsce w prostych przypadkach, dla dużej ilości projektów MapStruct będzie wyborem, który zaoszczędzi czas, nerwy i pomoże uniknąć wielu potencjalnych błędów.