Oszczędzaj czas dzięki bibliotece MapStruct

Michał Nowak

Zaktualizowaliśmy ten tekst dla Ciebie!
Data aktualizacji: 19.12.2024
Autor aktualizacji: Mateusz Morawski

Biblioteka MapStruct to świetne narzędzie, które może pomóc zaoszczędzić czas i ułatwić pracę. W środowisku mikroserwisów często potrzebujemy różnych obiektów w zależności od warstw. Na przykład, kiedy chcemy oddzielić nasze obiekty DTO od obiektów bazy danych lub gdy chcemy tworzyć nowe obiekty na podstawie istniejących, aby wywołać inny mikroserwis.

MapStruct ułatwia życie

Biblioteka MapStruct to świetne narzędzie, które może pomóc zaoszczędzić czas i ułatwić pracę. W środowisku mikroserwisów często potrzebujemy różnych obiektów w zależności od warstw. Na przykład, kiedy chcemy oddzielić nasze obiekty DTO od obiektów bazy danych lub gdy chcemy tworzyć nowe obiekty na podstawie istniejących, aby wywołać inny mikroserwis.

Przygotowanie projektu

Stworzę prosty projekt SpringBoot, aby zobaczyć, jak działa MapStruct. Dzięki temu projektowi będziesz mógł odczytywać i dodawać informacje o samochodzie z różnymi wartościami pomiarowymi (w zależności od wywołanego endpointu). Celem jest mapowanie między trzema typami obiektów:

  • CarInfoEU (europejski system pomiarowy)
  • CarInfoUS (amerykański system pomiarowy)
  • CarInfo (obiekt domenowy zapisany w bazie danych)

Oto diagram sekwencji zapisu i pobierania obiektu CarInfoUS:

Zapis

Odczyt

Zależności MapStructa

W naszym projekcie użyję Mavena, jednak oczywiście możesz użyć innych systemów zarządzania projektem. (Wersja 1.6.3 była ostatnią wersją z dnia 20 grudnia 2024 r.)

     <dependency>
        <groupid>org.mapstruct</groupid>
        <artifactid>mapstruct</artifactid>
        <version>1.6.3/version>
    </dependency>

Ponieważ biblioteka MapStruct buduje nasze klasy automatycznie, podczas kompilacji, musimy dodać kolejną konfigurację do pliku pom.

<build>
    <plugins>
        <plugin>
            <groupid>org.apache.maven.plugins</groupid>
            <artifactid>maven-compiler-plugin</artifactid>
            <version>3.8.1</version>
            <configuration>
                <source>11 
                <target>11</target> 
                <annotationprocessorpaths>
                    <path>
                        <groupid>org.mapstruct</groupid>
                        <artifactid>mapstruct-processor</artifactid>
                        <version>1.6.1.Final</version>
                    </path>
                    <!-- other annotation processors -->
                </annotationprocessorpaths>
            </configuration>
        </plugin>
    </plugins>
</build>

Spójrzmy na cały plik pom:

<!--?xml version="1.0" encoding="UTF-8"?-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemalocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelversion>4.0.0</modelversion>
    <parent>
        <groupid>org.springframework.boot</groupid>
        <artifactid>spring-boot-starter-parent</artifactid>
        <version>2.6.1</version>
        <relativepath> <!-- lookup parent from repository -->
    </relativepath></parent>
    <groupid>com.jlabs</groupid>
    <artifactid>mapstruct</artifactid>
    <version>0.0.1-SNAPSHOT</version>
    <name>mapstruct</name>
    <description>mapstruct project</description>
    <properties>
        <java.version>11</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupid>org.springframework.boot</groupid>
            <artifactid>spring-boot-starter-web-services</artifactid>
        </dependency>
        <dependency>
            <groupid>org.mapstruct</groupid>
            <artifactid>mapstruct</artifactid>
            <version>1.6.3/version>
        </dependency>
        <dependency>
            <groupid>org.springframework.boot</groupid>
            <artifactid>spring-boot-starter-test</artifactid>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupid>org.springframework.boot</groupid>
                <artifactid>spring-boot-maven-plugin</artifactid>
            </plugin>
            <plugin>
                <groupid>org.apache.maven.plugins</groupid>
                <artifactid>maven-compiler-plugin</artifactid>
                <version>3.8.1</version>
                <configuration>
                    <source>11
                    <target>11</target>
                    <annotationprocessorpaths>
                        <path>
                            <groupid>org.mapstruct</groupid>
                            <artifactid>mapstruct-processor</artifactid>
                            <version>1.6.3</version>
                        </path>
                        <!-- other annotation processors -->
                    </annotationprocessorpaths>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Proste mapowanie przy użyciu MapStructa

W tym kroku, postaramy się zmapować obiekt CarInfoEU do obiektu CarInfo. Spójrzmy na strukturę tych klas:

public class CarInfo {

    private int temp = 0;
    private double speed = 0;
    private int tank = 0;
}
public class CarInfoEU {

    private int insideCelsiusTemperature = 0;
    private double kilometersPerHour = 0;
    private int literTank = 0;
}

Aby łatwo mapować obiekty z CarInfoEU do CarInfo, musimy stworzyć interfejs z adnotacją @Mapper i componentModel=”spring”, aby utworzyć bean Springowy. Dzięki temu możemy wstrzyknąć obiekt CarInfoMapper.

@Mapper(componentModel="spring")
public interface CarInfoMapper {

}

Teraz dodajmy metodę by zmapować obiekt CarInfoEU do obiektu CarInfo.

@Mapper
public interface CarInfoMapper {

    @Mapping(source = "insideCelsiusTemperature", target = "temp")
    @Mapping(source = "kilometersPerHour", target = "speed")
    @Mapping(source = "literTank", target = "tank")
    CarInfo mapToCarInfo(CarInfoEU carInfoEU);
}

W tym przypadku musimy jedynie przepisać wartości, więc możemy opisać wszystko za pomocą adnotacji. Po kompilacji klasa CarInfoMapperImpl.class zostanie automatycznie wygenerowana.

    value = "org.mapstruct.ap.MappingProcessor",
    date = "2024-12-19T11:16:14+0100",
    comments = "version: 1.6.3, compiler: javac, environment: Java 21.0.2 (Oracle Corporation)"
)
public class CarInfoMapperImpl implements CarInfoMapper {

    @Override
    public CarInfo mapToCarInfo(CarInfoEU carInfoEU) {
        if ( carInfoEU == null ) {
            return null;
        }

        CarInfo carInfo = new CarInfo();

        carInfo.setTemp( carInfoEU.getInsideCelsiusTemperature() );
        carInfo.setSpeed( carInfoEU.getKilometersPerHour() );
        carInfo.setTank( carInfoEU.getLiterTank() );

        return carInfo;
    }
}

Złożone mapowanie za pomocą MapStruct

Przyjrzyjmy się teraz bardziej złożonemu przypadkowi. Chcemy zapisać CarInfo z pomiarami zgodnymi z normami UE, ale przesyłamy CarInfoUS z pomiarami amerykańskimi. Oprócz mapowania wartości, chcemy wykonać dodatkowe operacje, aby przekształcić Fahrenheity na Celsjusze, mile na kilometry oraz galony na litry. Obiekty do zmapowania:

public class CarInfo {

    private int temp = 0;
    private double speed = 0;
    private int tank = 0;
}
public class CarInfoUS {

    private double insideFahrenheitTemperature = 0;
    private double milesPerHour = 0;
    private double gallonTank = 0;

}

Do mapowania tych obiektów, użyjemy tego kodu:

@Mapper(componentModel="spring")
public interface CarInfoMapper {

    @Mapping(source = "insideCelsiusTemperature", target = "temp")
    @Mapping(source = "kilometersPerHour", target = "speed")
    @Mapping(source = "literTank", target = "tank")
    CarInfo mapToCarInfo(CarInfoEU carInfoEU);

    @Mapping(source = "insideFahrenheitTemperature", target = "temp", qualifiedByName = "temperatureCelsius")
    @Mapping(source = "milesPerHour", target = "speed", qualifiedByName = "miles")
    @Mapping(source = "gallonTank", target = "tank", qualifiedByName = "literTank")
    CarInfo mapToCarInfo(CarInfoUS carInfoUS);

    @Named("temperatureCelsius")
    default double fahrenheitToCelsius(double temperature) {
        return ((5 * (temperature - 32)) / 9);
    }

    @Named("miles")
    default double milesToKilometers(double miles) {
        return miles * 1.6;
    }

    @Named("literTank")
    default double gallonsToLiter(double gallons) {
        return gallons * 3.7854;
    }
}

Jak widać, ten kod jest bardzo podobny do poprzedniego. Nowością tutaj jest właściwość `qualifiedByName`, która wskazuje na metodę używaną do mapowania jednej wartości na inną za pomocą operacji. Po kompilacji generowany jest następujący kod:

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2024-12-19T11:16:14+0100",
    comments = "version: 1.6.3, compiler: javac, environment: Java 21.0.2 (Oracle Corporation)"
)
public class CarInfoMapperImpl implements CarInfoMapper {

    @Override
    public CarInfo mapToCarInfo(CarInfoEU carInfoEU) {
        if ( carInfoEU == null ) {
            return null;
        }

        CarInfo carInfo = new CarInfo();

        carInfo.setTemp(carInfoEU.getInsideCelsiusTemperature());
        carInfo.setSpeed(carInfoEU.getKilometersPerHour());
        carInfo.setTank(carInfoEU.getLiterTank());

        return carInfo;
    }

    @Override
    public CarInfo mapToCarInfo(CarInfoUS carInfoUS) {
        if ( carInfoUS == null ) {
            return null;
        }

        CarInfo carInfo = new CarInfo();

        carInfo.
          setTemp(fahrenheitToCelsius(
            carInfoUS.getInsideFahrenheitTemperatur() ));
        carInfo.setSpeed(
          milesToKilometers( carInfoUS.getMilesPerHour()));
        carInfo.
          setTank(gallonsToLiter( carInfoUS.getGallonTank()));

        return carInfo;
    }

Zanim ustawimy wartości w nowym obiekcie, wywołujemy domyślne metody z interfejsu: fahrenheitToCelsius, milesToKilometers, gallonsToLiter.

Błąd komplikacji

Podczas kompilacji, kompilator łatwo wykryje wszelkie błędy, które popełniłeś i wskaże, gdzie popełniłeś błąd. Oto co się dzieje, gdy popełnisz błąd w mapperze:

Użyteczne adnotacje MapStructa

Możesz łatwo zarządzać zachowaniem mappera za pomocą adnotacji. Oto lista najbardziej przydatnych adnotacji dla metod:

  • @AfterMapping
  • @BeforeMapping
  • @InheritConfiguration
  • @InheritInverseConfiguration

@AfterMapping

Gdy dodamy do naszej metody Mapper adnotację @AfterMapping, metoda ta zostanie wywołana po głównym mapowaniu. Możemy przeprowadzić dodatkowe operacje na zmapowanym obiekcie. W tym przykładzie, po mapowaniu, ustawiamy ID na obiekcie:

   @AfterMapping
    default void setId(@MappingTarget CarInfo carInfo) {
        String id = UUID.randomUUID().toString();
        carInfo.setId(id);
    }

Wskazujemy na zmapowany obiekt za pomocą adnotacji @MappingTarget.

@BeforeMapping

Ta adnotacja działa podobnie do @AfterMapping, ale operacja dla metody z tą adnotacją zostanie wywołana przed wszystkimi operacjami mapowania. Na przykład, możemy wykonać pewne operacje na obiektach do zmapowania. Oto przykład:

  @BeforeMapping
    default void checkSpeedEU(CarInfoEU carInfoEU) {
        if (carInfoEU.getKilometersPerHour() > 100) {
            System.out.println("Too fast");
        }
    }

@InheritConfiguration

Jeśli chcemy dodać kolejny mapper z mniejszą liczbą wartości, możemy odziedziczyć z poprzedniego mappera tylko te wartości, które są nam potrzebne i nadpisać nowe wartości. Oto przykład, w którym dodamy nowy mapper i pominiemy pole literTank.

    @Mapping(source = "insideCelsiusTemperature", target = "temp")
    @Mapping(source = "kilometersPerHour", target = "speed")
    @Mapping(source = "literTank", target = "tank")
    CarInfo mapToCarInfo(CarInfoEU carInfoEU);

    @InheritConfiguration
    @Mapping(source = "literTank", target = "tank", ignore = true)
    CarInfo mapToCarInfoWithoutTank(CarInfoEU carInfoEU);

W tym przypadku wszystkie pola na mapie zostaną odziedziczone z metody mapToCarInfo, z wyjątkiem pola literTank.

@InheritInverseConfiguration

Czasami, gdy mamy do czynienia z prostym mapowaniem (mapujemy te same wartości po dwóch stronach), możemy użyć adnotacji @InheritInverseConfiguration. Dzięki temu uzyskujemy odwrotną operację mapowania:

    @Mapping(source = "temp", target = "insideCelsiusTemperature")
    @Mapping(source = "speed", target = "kilometersPerHour")
    @Mapping(source = "tank", target = "literTank")
    CarInfoEU mapToCarInfoEU(CarInfo carInfo);

    @InheritInverseConfiguration
    CarInfo mapToCarInfo(CarInfoEU carInfoEU);

W tym przypadku mamy dwa mapowania: z CarInfo do CarInfoEU i z CarInfoEU do CarInfo. Nie jestem wielkim zwolennikiem tej metody. Wolę, aby wszystko było w osobnej klasie: mapowanie CarInfo powinno być w klasie CarInfoMapper, natomiast operacja mapowania do CarInfoEU powinna być w klasie CarInfoEUMapper.

Podsumowanie

Jak widzisz, korzystając z biblioteki MapStruct, można zaoszczędzić dużo czasu zgodnie z zasadą DRY (don’t repeat yourself – nie powtarzaj się), ale należy pamiętać o znalezieniu równowagi między świetnymi narzędziami, które MapStruct dostarcza, a czystym, czytelnym kodem. Czasami lepiej jest coś powtórzyć, niż za wszelką cenę umieszczać wszystko w jednym miejscu. 🙂

Główne zalety korzystania z MapStruct to:

– Twój kod jest czystszy i znacznie łatwiejszy do odczytania i zarządzania
– Unikasz pisania dużej ilości powtarzającego się kodu
– Dzięki temu, że MapStruct generuje klasy podczas kompilacji, nie ma problemu z wydajnością
– Łatwo go dodać do projektu
– Łatwo jest znaleź błędy podczas kompilacji

Poznaj mageek of j‑labs i daj się zadziwić, jak może wyglądać praca z j‑People!

Skontaktuj się z nami