Mapstruct – Eine Einführung

Eine regelmäßig wiederkehrende Aufgabe beim Programmieren ist das Übertragen der Daten in einem Objekt in ein anderes Objekt, das fast genauso aufgebaut ist, aber eben nur fast.
In diesem Artikel beschreibe ich die Library Mapstruct, die es ermöglicht, zwischen verschiedenen Objekten auf einfache und übersichtliche Weise ein Mapping zu definieren.

Ziel des Beitrags ist es, ein paar grundlegende Konzepte von Mapstruct zu beschreiben und anhand von Beispielen den Einsatz in einem Java Projekt in Eclipse zu ermöglichen.

Problemstellung

Einen Service sauber aufzubauen ist eigentlich ganz einfach: Man trennt sauber die einzelnen Bereiche  in verschiedene Services und Schichten, je nach konkretem Anwendungsbereich. Jeder Bereich hat seine eigene Domain und die Kommunikation zwischen diesen Bereichen ist sauber per Schnittstellen definiert. Dieses Konzept leuchtet den meisten Entwicklern schnell ein – und wird dann sehr schnell untergraben, weil man die Grenzen wieder verschwimmen lässt.

Warum ist das so? Wenn jede Schicht ihre eigene Domain hat, dann gibt es auf eine fachliche Sache immer verschiedenste Sichtweisen: Den Kunden, den Kunden wie er in der Schnittstelle übergeben wird, der Kunde, wie er in der Datenbank abgespeichert wird, ein Update zu einem Kunden, ein Kunde, der das erste Mal angelegt wird… Und für jede dieser Sichtweisen gibt es eine eigene Klasse als Implementation.

Das bedeutet aber auch, dass zwischen all diesen Varianten eine Übersetzung stattfinden muss. Das Übersetzen von einem Objekt in ein anderes ist zwar keine schwere Aufgabe, aber sie ist kleinteilig, umfangreich und ein Tippfehler oder ein vergessener Check auf ein leeres Feld kann einem wirklich den Tag ruinieren.

Das Konzept Mapstruct

Glücklicherweise sind aufwändige Aufgaben, die aber im Prinzip einfach sind, auch leicht zu automatisieren. Mapstruct ist eine Library, die genau diese Aufgabe umsetzt. Mapstruct geht im Standardfall bei der Übersetzung davon aus, dass die betrachteten Klassen die Bean Eigenschaften erfüllen: Ein argumentloser Konstruktor sowie Getter und Setter für jedes Feld. (Es geht natürlich auch komplexer, aber das soll noch nicht Teil dieses Beitrags sein.)

Zwei Klassen, die zueinander schon relativ ähnlich sind werden durch Mapstruct automatisch,unkompliziert und inklusive aller notwendigen Prüfungen und Ausfallsicherungen ineinander übersetzt. Mit all diesen Maßnahmen können typische Flüchtigkeitsfehler gut verhindert werden.

Sagen wir mal, wir haben eine Klasse `Kunde` in unserem Projekt

class Kunde {
	Anrede anrede;
	String name;
	String vorname;	
	LocalDate geburtsdatum;
	KundenAdresse adresse;
}
 
class KundenAdresse {
	String ort;
	String postleitzahl;
	String strasse;
	Integer hausnummer;
}

Dabei ist die Anrede ein Enum und die Adresse ein Unterobjekt. Der Einfachheit halber wurden die Getter und Setter weggelassen. In der Datenbank wurden aber leicht anderslautende Felder verwendet und auch nur eine Tabelle – wir nehmen mal an, es gab gute Gründe dafür:

class KundeDBEntity {
	String anrede;
	String nachname;
	String vorname;
	
	String geburtsdatum;
	String wohnort;
	String plz;
	String strasse;
	String hausnummer;
}

Diese an sich recht einfache Situation beinhaltet schon mehrere kleine Tücken, die berücksichtigt werden müssen: Die `anrede` muss in ein Enum umgewandelt, das `geburtsdatum` als LocalDate geparst und die Felder der Adresse in das Unterobjekt initialisiert werden – sofern die Felder überhaupt belegt sind. Außerdem ist der `name` in der Datenbank noch ein `nachname` .

Mapper aufsetzen

import org.mapstruct.Mapper;
 
@Mapper
public interface KundeMapper {
	Kunde fromKundeDbEntity(KundeDbEntity in);
}

Mit dieser einfachen Definition haben wir bereits einen Mapper zwischen den beiden Klassen definiert. Viele Dinge funktionieren hier schon automatisch: Zahlen werden in Texte umgewandelt und umgekehrt, ein Datum wird geparst und Strings werden zu Enums umgewandelt. Auch das Mapping ganzer Listen geht sehr einfach:

import org.mapstruct.Mapper;
 
@Mapper
public interface KundeMapper {
 
	Kunde fromKundeDbEntity(KundeDbEntity in);
 
	List<Kunde> fromKundeDbEntity(List<KundeDbEntity> in);
}

Die Annotation `@Mapper` registriert den Mapper zentral für Mapstruct. Möchte man ihn nun im Code verwenden, so geht das direkt mit `Mappers` aus dem Package `org.mapstruct.factory`, das die von Mapstruct generierte Implementation des Interfaces zur Verfügung stellt.

Kunde kunde = Mappers.getMapper(KundeMapper.class).fromKundeDbEntity(kundeDbEntity);

Gezieltes Mapping

Überall dort, wo eine offensichtliche Übersetzung nicht möglich ist, wird Mapstruct eine Compiler-Warnung ausgeben, sodass man als Entwickler einfach selber nachsteuern kann. Mit einfachen Annotationen können so zum Beispiel unterschiedlich benannte Felder miteinander verknüpft werden.

import org.mapstruct.Mapper;
 
@Mapper
public interface KundeMapper {
	@Mapping(target = “name”, source = “nachname”)
	Kunde fromKundeDbEntity(KundeDbEntity in);
}

Das Mapping ist dabei auch möglich, wenn zusammengehörende Felder auf völlig verschiedenen Ebenen der Objektstruktur zu finden sind. Normalerweise wären an dieser Stelle viele Checks auf NULL notwendig, damit wir problemlos auf die notwendige Unterobjekte zugreifen können. Um all diese Nullpointer Checks kümmert sich Mapstruct glücklicherweise selber.

import org.mapstruct.Mapper;
 
@Mapper
public interface KundeMapper {
	@Mapping(target = “adresse.ort”, source = “wohnort”)
	@Mapping(target = “adresse.postleitzahl”, source = “plz”)
	@Mapping(target = “adresse.strasse”, source = “strasse”)
	@Mapping(target = “adresse.hausnummer”, source = “hausnummer”)
	Kunde fromKundeDbEntity(KundeDbEntity in);
}

Enums und Value Mapping

Ein häufiges Problem beim Übersetzen von Strings zu Enum sind die Texte, für die es keine Entsprechung gibt. Wird nichts weiter spezifiziert, dann wird auch Mapstruct ein simples Enum.valueOf(in) verwendeen, was bei unbekannten Werten zu einer Exception führen kann. 

Mit der ValueMapping Annotation kann man hier jedoch sehr elegant Abhilfe schaffen:

import org.mapstruct.Mapper;
 
@Mapper
public interface KundeMapper {
	
    Kunde fromKundeDbEntity(KundeDbEntity in);
 
    @ValueMapping(target = "HERR", source = "Herr")
    @ValueMapping(target = "FRAU", source = "Frau")
    @ValueMapping(target = "DIVERS", source = "divers")
    @ValueMapping(target = “UNBEKANNT”, source = MappingConstants.ANY_REMAINING)
    Anrede fromString(String in);
}

Hierbei können Werte direkt der gewünschten Konstante zugewiesen werden. Mit MappingConstants.ANY_REMAINING werden alle Werte angesprochen, die nicht durch explizites Mapping abgedeckt sind oder für die nicht schon allein durch den Namen eine Entsprechung gefunden wurde..

Wenn explizit eine Methode von String zu einem Enum im Mapping Interface vorliegt, dann wird Mapstruct diese automatisch für jedes Mapping von Strings zu diesem Enum verwenden. Mit speziellen Bezeichnern kann man das bei Bedarf gezielter steuern – das beschreiben wir in einem kommenden Artikel.

Gut zu wissen: Clone ganz einfach

Ein Objekt zu klonen ist bei sauberer Architektur unvermeidlich. Hier greifen die meisten zu Reflection oder das Cloneable Interface, insbesondere bei größeren Objekten – was zu viel Freude bei komplexen Objekten mit Unterobjekten und Listen etc. führen kann. Mapstruct dagegen hat auch hierfür eine praktische Annotation:

import org.mapstruct.Mapper;
 
@Mapper
public interface KundeMapper {
 
	// Erzeugt nur eine Oberflächliche Kopie
	Kunde clone(Kunde in);
	
	// Erzeugt DeepClone für alle Unterobjekte direkt mit
	@DeepClone
	Kunde deepClone(Kunde in);
}

Einrichten in Eclipse

Während des Build Prozesses in unseren Demo- und Produktivumgebungen werden mit der korrekten Maven Build Ergänzung unter anderem die Annotation-Prozessoren von Mapstruct angestoßen.

Für die lokale Entwicklung in Eclipse muss dafür jedoch das passende Plugin m2e-apt installiert und das Projekt konfiguriert werden, damit die Annotationen verarbeitet werden. Glücklicherweise reicht hier eine einfache Einstellung, 

Project > Properties > Maven > Annotation Processing > Automatically configure JDT APT

sowie eine Ergänzung in der pom.xml.

<project>
	…
	<properties>
		…
		<m2e.apt.activation>jdt_apt</m2e.apt.activation>
		…
	</properties>
</project>

Mit diesen Einstellungen werden die durch Mapstruct Annotationen definierten Klassen im Projekt direkt gebaut und können verwendet werden.

Und sonst so?

Welche Vorteile bringt ein Mapping mithilfe von Mapstruct noch?

Generell ist das Konzept des statischen Mappings anderen Tools wie zum Beispiel der Reflection überlegen, da wir schon zur Compilierzeit wissen, wie die Übersetzung zwischen zwei Objekten durchgeführt werden soll. Inkompatible Typen oder fehlende Mapping Anweisungen können so schon vor der Laufzeit festgestellt werden.

Mit der Umsetzung durch Annotationen in einem Interface ist es auch leichter durchzusetzen, dass die MappingLogik sauber aus den Domain Objekten ausgelagert wird. 

Bisher wurden nur einige grundlegende Funktionalitäten von Mapstruct vorgestellt, um den Artikel kurzzuhalten. Es gibt aber noch einige weitere spannende Funktionalitäten, durch die Mapstruct sehr vielseitig werden kann.

Dazu gehören das gezielte Verwenden von eigenen Mappingmethoden für einzelne Felder, das Zusammenlegen von zwei oder mehr Objekten oder schlicht ein einfacher DeepClone von Objekten. Auch durch diverse Eigenschaften in den Annotationen kann man das Verhalten von Mapstruct sehr genau für den einzelnen Use Case steuern.

Fazit

Generell kann mit einem annotationsbasiertem Codegenerator wie Mapstruct viel Boilerplate beim Entwickeln gespart werden. Damit ist die Gefahr von vermeidbaren Flüchtigkeitsfehlern einfacher zu fassen, der Entwicklungsprozess beschleunigt sich und wir können uns konzentriert um die spannenderen Fragen kümmern.

Mapstruct ist hier ein gutes Tool, um es einem Entwicklungsteam zu erleichtern Architekturprinzipien wie das Adapter Pattern oder ein sauberes Schichtenmodell (oder Hexagonalmodell) umzusetzen. Dabei muss man nicht furchtbar darauf achten, dass alle Feldnamen in allen Schichten identisch bleiben – was allein schon durch verschiedene Namenskonventionen z.b. für Java, REST APIs und SQL Datenbanken gerne mal ein Problem ist.

Offen ist allerdings, in welchem Umfang Unit Tests die Funktionalität des Mappers prüfen sollten – es wird wohl kaum vermeidbar sein, die Funktionalität des Mappens insbesondere bei den wichtigen Feldern zu prüfen. Hierfür ist eine Methodik notwendig, die vollständige Tests liefert, aber nicht wieder ganz viel Code wiederholt – sonst wurde das Boilerplate Problem schlicht nur verschoben.

Bisher wurde nur an der Oberfläche der vielen Möglichkeiten von Mapstruct gekratzt und in einem kommenden Beitrag kann noch auf weitere Funktionalitäten eingegangen werden, ebenso wie die Wechselwirkungen mit anderen Libraries, wie zum Beispiel Lombok bei der Vermeidung von POJO-Boilerplate und Hamcrest im Bereich des Testens.

Bild: Canva

Weiterführende Links:

[1] Mapstruct Referenz
[2] Kurzes Tutorial auf Baeldung

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.