MapStruct ułatwi Ci życie

Michał Nowak

Biblioteka MapStruct to świetne narzędzie, które ułatwia życia\e programistom i pomaga im oszczędzać czas. Przydaje się zwłaszcza podczas pracy w środowisku mikrousług, gdzie jest zapotrzebowanie na różne obiekty w zależności od warstw. Możesz wykorzystać ją np. wtedy, gdy chcesz oddzielić obiekty DTO od bazodanowych lub utworzyć nowe obiekty na podstawie poprzednich, aby wywołać inną mikrousługę. Sprawdź, jak jeszcze może pomóc Ci MapStruct.

Przewidywania dla projektu

Stworzę prosty projekt Spring Boot, aby zobaczyć, jak działa MapStruct. Dzięki temu będzie można odczytywać i dodawać informacje o samochodzie z różnymi wartościami pomiarowymi (w zależności od tego, który punkt endpoint będzie wywołany). Celem jest mapowanie między trzema typami obiektów:

  • CarInfoEU – europejski system pomiarowy;
  • CarInfoUS – system pomiarowy USA;
  • CarInfo – obiekt domeny zapisany w bazie danych.

Poniżej znajdziesz diagramy sekwencji zapisywania i pobierania obiektu CarInfoUS. Wszystkie przypadki z tego artykułu są dostępne w projekcie: https://github.com/michaltomasznowak/mapstruct

Zapisywanie

Pobieranie

Zależności MapStruct

W tym projekcie używam Mavena (wersja MapStruct to 1.4.1.Final, ostatnia na dzień 20 grudnia 2021 r.). Możesz też oczywiście skorzystać z innych systemów zarządzania projektami.

   <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>1.4.1.Final</version>
    </dependency>

Ponieważ biblioteka MapStruct automatycznie buduje klasy podczas kompilacji, potrzebuję dodatkowej konfiguracji pom:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version>
            <configuration>
                <source>11</source> 
                <target>11</target> 
                <annotationProcessorPaths>
                    <path>
                        <groupId>org.mapstruct</groupId>
                        <artifactId>mapstruct-processor</artifactId>
                        <version>1.4.1.Final</version>
                    </path>
                    <!-- other annotation processors -->
                </annotationProcessorPaths>
            </configuration>
        </plugin>
    </plugins>
</build>

Zobacz cały nasz 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 -->
    </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.4.1.Final</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</source>
                    <target>11</target>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.mapstruct</groupId>
                            <artifactId>mapstruct-processor</artifactId>
                            <version>1.4.1.Final</version>
                        </path>
                        <!-- other annotation processors -->
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Proste mapowanie z MapStruct

W tym kroku spróbuję zmapować obiekt CarInfoEU do CarInfo. Spójrz na strukturę tych obiektów:

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, muszę utworzyć interfejs z adnotacją @Mapper i componentModel="spring". Dzięki temu powstanie spring bean i można wstrzyknąć obiekt CarInfoMapper.

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

}

Teraz pora na dodanie metody mapującej CarInfoEU do 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 muszę tylko poprzypisywać wartości, więc mogę zrobić wszystko za pomocą adnotacji. Po kompilacji klasa CarInfoMapperImpl.class zostanie wygenerowana automatycznie.

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2021-12-20T18:20:34+0100",
    comments = "version: 1.4.1.Final, compiler: javac, environment: Java 15.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;
    }
}

Bardziej skomplikowane mapowanie z MapStruct

Rozważmy teraz bardziej złożony przypadek. Chcę zapisać CarInfo z pomiarami UE, ale wysyłamy CarInfoUS z pomiarami USA. Oprócz mapowania wartości chcę wykonać kilka dodatkowych operacji, aby przekształcić stopnie Fahrenheita na Celsjusza, mile na kilometry i galony na litry. Obiekty do zmapowania to:

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;

}

Aby je zmapować, użyję następującego 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ć, jest on bardzo podobny do poprzedniego. Nowością jest tutaj właściwość qualifiedByName, która wskazuje na metodę używaną do mapowania jednej wartości na inną przy użyciu operacji. Po kompilacji generowany jest następujący kod:

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2021-12-20T19:16:14+0100",
    comments = "version: 1.4.1.Final, compiler: javac, environment: Java 15.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;
    }

Przed ustawieniem wartości w nowym obiekcie wywołuję domyślne metody z interfejsu w tym kodzie: fahrenheitToCelsiusmilesToKilometersgallonsToLiter.

Błąd kompilacji

Podczas kompilacji kompilator z łatwością wykryje wszelkie popełnione błędy i wskaże ich miejsce. Spójrz, co się stanie, gdy popełnisz błąd w mapperze:

Przydatne adnotacje

Możesz łatwo kontrolować swój mapper za pomocą adnotacji. Oto lista najbardziej przydatnych:

  • @AfterMapping;
  • @BeforeMapping;
  • @InheritConfiguration;
  • @InheritInverseConfiguration.

@AfterMapping

Gdy dodam do mappera metodę z adnotacją @AfterMapping, wówczas metoda ta zostanie wywołana po głównym mapowaniu. Można też wykonać kilka dodatkowych operacji na zmapowanym obiekcie. W tym przykładzie po mapowaniu ustawiam ID na obiekcie:

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

Wskazuję zmapowany obiekt adnotacją @MappingTarget.

@BeforeMapping

Ta adnotacja działa podobnie do @AfterMapping, ale operacja w tym przypadku zostanie wywołana przed wszystkimi operacjami mapowania. Można więc wykonać kilka operacji na obiektach do mapowania. Oto przykład:

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

@InheritConfiguration

    @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 będą dziedziczone z metody mapToCarInfo – z wyjątkiem pola literTank.

@InheritInverseConfiguration

Czasami, gdy masz łatwy przebieg mapowania (mapujemy te same wartości w dwie strony), możesz użyć @InheritInverseConfiguration. Dzięki temu uzyskasz 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 fanem tej metody. Wolę mieć wszystko w osobnej klasie i mapowanie CarInfo powinno być w klasie CarInfoMapper, a operacja mapowania do CarInfoEU powinna być w CarInfoEUMapper.

Podsumowanie

Jak widać, dzięki bibliotece MapStruct można zaoszczędzić sporo czasu zgodnie z zasadą DRY (nie powtarzaj się). Trzeba jednak pamiętać o znalezieniu równowagi między świetnymi narzędziami, które zapewnia MapStruct a czystym, czytelnym kodem. Czasem lepiej coś powtórzyć, zamiast za wszelką cenę umieszczać wszystko w jednym miejscu 🙂

Główne zalety korzystania z MapStruct:

  • kod jest czystszy i znacznie łatwiejszy do czytania i zarządzania;
  • unikasz pisania dużej ilości powtarzającego się kodu;
  • brak problemów z wydajnością dzięki generowaniu klasy podczas kompilacji;
  • łatwość dodania do projektu;
  • łatwo znalezienia błędów podczas kompilacji.

Źródło

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

Skontaktuj się z nami