Ich mischte mich ein: „Sag mal, kennst Du MapStruct? Das ist ein Code-Generator, welcher dir die mapping-Arbeit weitestgehend abnimmt. Du schreibst einfach nur ein Interface und lässt den Code generieren. „Nein, das kenne ich noch gar nicht. Kannst du mir das vielleicht mal zeigen?“ antwortete Klaas. „Ja klar. Das geht ganz schnell.“
MapStruct
Einfach Mappen mit MapStruct
„Och nö, auf diese langweiligen Aufgaben habe ich keinen Bock. Diese blöden Mapping-Klassen mit den ganzen getter/setter-Aufrufen nerven einfach nur“. Klaas, unser Junior-Entwickler war absolut nicht begeistert.
Ein Projekt einrichten
Im ersten Schritt setzen ich ein Springboot-Project mit Kotlin und Maven auf. Dafür nutze ich entweder im Web die Seite https://start.spring.io/ oder den ‚Neues-Projekt‘-Dialog in IntelliJ.
Als Nächstes trage ich mapstruct als dependency in die pom.xml ein und lagere die Versionsnummer in eine property aus.
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<properties>
<java.version>17</java.version>
<kotlin.version>1.9.25</kotlin.version>
<org.mapstruct.version>1.6.1</org.mapstruct.version>
</properties>
Im ‚build‘-Abschnitt der pom füge ich im kotlin-maven-plugin die executions hinzu, damit das Kotlin Annotation Processor Tool (kapt) die konkrete Implementierung des Interfaces generieren kann.
<executions>
<execution>
<id>kapt</id>
<goals>
<goal>kapt</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>src/main/kotlin</sourceDir>
</sourceDirs>
<annotationProcessorPaths>
<annotationProcessorPath>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</annotationProcessorPath>
</annotationProcessorPaths>
</configuration>
</execution>
<execution>
<id>compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
</sourceDirs>
</configuration>
</execution>
<execution>
<id>test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>test-compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
Damit das Plugin funktioniert muss ich abschließend noch die dependencies kotlin-maven-allopen, kotlin-maven-no-arg und den mapstruct-processor hinzufügen:
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-noarg</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
</dependencies>
Zum Abschluss der Konfiguration behebe ich noch ein Problem mit der Kompilierreihenfolge. Standardmäßig wird zuerst der Java-Compiler und anschließend der Kotlin-Compiler ausgeführt. Allerdings benötigt der Java-Compiler bereits die durch mapstruct generierten Klassen. Deswegen füge ich noch das maven-compiler-plugin hinzu. Durch den Ausschluss des default-compiler erreichen wir die geänderte Reihenfolge während der Kompilierung.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<executions>
<execution>
<id>default-compile</id>
<phase>none</phase>
</execution>
<execution>
<id>default-testCompile</id>
<phase>none</phase>
</execution>
<execution>
<id>java-compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>java-test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>testCompile</goal>
</goals>
</execution>
</executions>
</plugin>
Die Programmierung
Um die Funktionalität von mapstruct zu testen, lege ich zuerst drei Packages an:
- domain: enthält das zu mappende Domain-Objekt
- dto: enthält das Datentransferobjekt, in welches gemapped werden soll
- mapper: enthält das mapstruct-interface
Das Domain-Objekt
Im Verzeichnis domain erstelle ich die Klasse Person mit den Attributen firstName, lastName, phoneNumber und birthDate:
data class Person (var firstName:String?,
var lastName:String?,
var phoneNumber: String?,
var birthDate: LocalDate?)
Das DTO
Im Verzeichnis dto erstelle ich die Klasse PersonDto mit den Attributen firstName, lastName, phone und birthDate:
data class PersonDto(var firstName:String?,
var lastName:String?,
var phone: String?,
var birthDate: LocalDate?)
Der Mapper
Im Verzeichnis mapper lege ich das Interface PersonMapper an:
@Mapper
interface PersonMapper {
@Mapping(source = "phoneNumber", target = "phone")
fun convertToDto(person:Person) : PersonDto
fun convertToModel(personDto: PersonDto) : Person
}
Attribute mit identischem Namen erkennt und mapped mapstruct automatisch. Eine Besonderheit liegt beim Attribut phoneNumber vor. Dieses existiert in der Zielklasse nicht. Dort heißt das Attribut phone. Deswegen sage ich dem mapper durch Angabe der Annotation @Mapping, dass das phoneNumber in phone gemapped werden soll.
Die Vorarbeiten habe ich erledigt. Jetzt reicht ein maven clean Install aus, um die Implementation des Interfaces generieren.
Im Verzeichnis target/generated-sources/kapt/compile existiert ein Paket, welches unsere Klasse PersonMapperImpl enthält:
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2024-09-16T07:41:38+0200",
comments = "version: 1.6.0, compiler: javac, environment: Java 20.0.2 (Oracle Corporation)"
)
public class PersonMapperImpl implements PersonMapper {
@Override
public PersonDto convertToDto(Person person) {
if ( person == null ) {
return null;
}
String phone = null;
String firstName = null;
String lastName = null;
LocalDate birthDate = null;
phone = person.getPhoneNumber();
firstName = person.getFirstName();
lastName = person.getLastName();
birthDate = person.getBirthDate();
PersonDto personDto = new PersonDto( firstName, lastName, phone, birthDate );
return personDto;
}
@Override
public Person convertToModel(PersonDto personDto) {
if ( personDto == null ) {
return null;
}
String firstName = null;
String lastName = null;
LocalDate birthDate = null;
firstName = personDto.getFirstName();
lastName = personDto.getLastName();
birthDate = personDto.getBirthDate();
String phoneNumber = null;
Person person = new Person( firstName, lastName, phoneNumber, birthDate );
return person;
}
}
Das hat bereits gut funktioniert, die Methode convertToDto mapped Person vollständig in PersonDto und beinhaltet sogar eine null-Prüfung. Das mapping von phoneNumber nach phone hat ebenfalls geklappt.
Bei der Methode convertToModel sieht es leider nicht so gut aus. Das Mappen der Telefonnummer fehlt. Die liegt daran, dass ich vergessen habe eine Mapping-Annotation an die Methode zu packen. Mit der Annotation @InheritInverseConfiguration kann ich das Mapping aus der anderen Methode mitbenutzen und muss es nicht nochmals konfigurieren:
@Mapper
interface PersonMapper {
@Mapping(source = "phoneNumber", target = "phone")
fun convertToDto(person:Person) : PersonDto
@InheritInverseConfiguration
fun convertToModel(personDto: PersonDto) : Person
}
Nach einem weiteren maven clean Install sieht die vorher fehlerhafte Methode nun so aus:
@Override
public Person convertToModel(PersonDto personDto) {
if ( personDto == null ) {
return null;
}
String phoneNumber = null;
String firstName = null;
String lastName = null;
LocalDate birthDate = null;
phoneNumber = personDto.getPhone();
firstName = personDto.getFirstName();
lastName = personDto.getLastName();
birthDate = personDto.getBirthDate();
Person person = new Person( firstName, lastName, phoneNumber, birthDate );
return person;
}
Einsatz des Mappers
Als Letztes möchte ich noch zeigen, wie der Mapper in einem Projekt eingesetzt werden kann. Dafür lege ich eine die Testklasse MapperPersonTest mit der Testmethode testsPersonToDtoMapper an.
@SpringBootTest
class PersonMapperTest {
@Test
fun testsPersonToDtoMapper(){
//given
val personMapper = Mappers.getMapper(PersonMapper::class.java)
val person = Person("Uma", "Thurman", "+(55) 123 456", LocalDate.of(1970,4,29))
// when
val personDto = personMapper.convertToDto(person)
//then
assertThat(personDto.firstName).isEqualTo(person.firstName)
assertThat(personDto.lastName).isEqualTo(person.lastName)
assertThat(personDto.phone).isEqualTo(person.phoneNumber)
assertThat(personDto.birthDate).isEqualTo(person.birthDate)
}
}
Mit der Anweisung Mappers.getMapper() erzeuge ich eine Instanz des Mappers. Dann brauche ich nur noch ein Testobjekt zu erstellen, die Methode convertToDto aufzurufen und die Ergebnisse des Mapping-Vorgangs zu überprüfen. Der Testreport sieht folgendermaßen aus:
Fazit
MapStruct lässt sich mit ein wenig Anfangsaufwand (Einrichten der Plugins in der pom.xml) mit kotlin nutzen und erspart Programmierern lästige Arbeit auf einfache Art und Weise.