Mapping av java-objekter med MapStruct
For javautviklere, og spesielt for oss som jobber med integrasjoner, er det visse deler av kodingen som både er kjedelig, tidkrevende, og som ofte resulterer i feil. Mapping av objekter (typisk mellom DTOer og interne domeneobjekter) er et slikt eksempel. Slik trenger det derimot ikke å være.
MapStruct is a code generator that greatly simplifies the implementation of mappings between Java bean types based on a convention over configuration approach.
Når du skal velge et rammeverk for en programmeringsoppgave er det flere kriterier du må ta hensyn til:
- Trenger du i det hele tatt et rammeverk?
- Vil rammeverket gjøre oppgaven lettere — både mtp. selve utviklingen og senere vedlikehold? Herunder inngår kodens lesbarhet og testbarhet.
- Vil rammeverket gjøre at programvarens ytelse er like bra, eller marginalt dårligere, som uten dette rammeverket?
I økosystemet til Java finnes det et mylder av rammeverk, og det er (for) lett å inkludere nok en avhengighet. De følgende kodeeksemplene illustrerer derimot at MapStruct er et rammeverk som rettferdiggjør en plass på classpath, sett fra et rent kodeperspektiv, dersom formålet er å mappe javaobjekter. Det er lett å google oversikter for mapping-rammeverk hvis du ønsker å se på flere alternativer [1] [2].
MapStruct er relativt nytt, og den siste stable release er fra oktober 2017.
Oppsett i maven
I et maven-prosjekt må du ha følgende konfigurering. MapStruct kan selvfølgelig også benyttes i andre byggesystemer [3]. Merk at du må ha versjon 3.5 av maven-compiler-plugin eller høyere. I seksjonen for compilerArgs har du muligheten til å endre standard oppførsel på en rekke deler av MapStruct [4].
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-jdk8</artifactId>
<version>1.2.0.Final</version>
</dependency><plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.7.0</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.20</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.2.0.Final</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<compilerArg>
-Amapstruct.defaultComponentModel=spring
</compilerArg>
</compilerArgs>
</configuration>
</plugin>
Kodeeksempler
I de følgende kodeeksemplene jobber vi med et internt domeneobjekt (Car) som vi ønsker å mappe til et annet objekt (CarDto) før vi eksempelvis skal sende det i en request. For enkelhets skyld bruker vi Lombok for å slippe å kode gettere, settere og buildere samt Junit for enhetstester. Eksemplene er en videreutvikling av dokumentasjonen som MapStruct tilbyr.
@Data
@Builder
public class Car {
private CarBrand brand;
private String model;
private int numberOfSeats;
private LocalDateTime handoverDateTime;
private boolean hybrid;
@Getter
@AllArgsConstructor
public enum CarBrand {
LEXUS("Lexus"),
BMW("BMW"),
FORD("Ford");
private String brandName;
}
}@Data
public class CarDto {
private String brand;
private String model;
private int seatCount;
private String deliveredToOwnerDate;
private String hybrid;
}
Selve mappe-logikken ligger i et interface annotert med “@Mapper”. Eksemplene omhandler mapping fra- og til et felt, defaultverdier, type-konversjon, javauttrykk og dato/tid-format. Siden MapStruct genererer kode under kompileringen kan man både inspisere og debugge koden. Ulempen, spesielt i en tidlig utviklingsfase er at man må kompilere koden hver gang man endrer i mapper-interfacet.
@Mapper
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
@Mappings({
@Mapping(target = "brand", source = "brand.brandName"), @Mapping(target = "seatCount", source = "numberOfSeats"), @Mapping(target = "model", source = "model", defaultValue = "LC500h"), @Mapping(target = "hybrid",
expression = "java(com.example.demo.CarUtil.isHybrid(car.isHybrid()))"), @Mapping(target = "deliveredToOwnerDate", source = "handoverDateTime", dateFormat = "yyyy-MM-dd")
}) CarDto carToCarDto(Car car);
}
Både Car og CarDto har feltet model. Dersom vi ikke ønsker ytterligere håndtering av felter med samme navn, kan feltene utelates fra mapperen.
@Test
public void testThatSourceIsMappedToTargetWhenNamesAreEqual() {
Car car = Car.builder().model("RC300h").build();
CarDto carDto = CarMapper.INSTANCE.carToCarDto(car);
assertEquals("RC300h", carDto.getModel());
}
I mapperen har vi definert at feltet model skal ha en default-verdi dersom det ikke finnes.
@Test
public void testThatDefaultIsUsedWhenNoProperty() {
Car car = Car.builder().build();
CarDto carDto = CarMapper.INSTANCE.carToCarDto(car);
assertEquals("LC500h", carDto.getModel());
}
Antall seter er definert i feltene Car.numberofSeats og CarDto.seatCount.
@Test
public void testThatSourceIsMappedToTargetWhenDifferentPropertyName() {
Car car = Car.builder().numberOfSeats(4).build();
CarDto carDto = CarMapper.INSTANCE.carToCarDto(car);
assertEquals(4, carDto.getSeatCount());
}
Feltet Car.brand er definert som en enum i CarBrand. I mapperen har vi beskrevet at feltet Car.brand.brandName skal mappes til strengen CarDto.brand. Dvs. at “dot-notasjon” også fungerer.
@Test
public void testThatMapperUsesTypeConversion() {
Car car = Car.builder().brand(Car.CarBrand.LEXUS).build();
CarDto carDto = CarMapper.INSTANCE.carToCarDto(car);
assertEquals("Lexus", carDto.getBrand());
}
I mapperen har vi definert at feltet CarDto.hybrid skal populeres med resultatet fra et java-uttrykk — eksempelvis fra en metoden CarUtil.isHybrid.
public class CarUtil {
static String isHybrid(boolean hybrid) {
return hybrid ? "Yes" : "No";
}
}@Test
public void testThatJavaExpressionIsUsed() {
Car car = Car.builder().hybrid(true).build();
CarDto carDto = CarMapper.INSTANCE.carToCarDto(car);
assertEquals("Yes", carDto.getHybrid());
}
Dato- og tidsformat kan også enkelt konverteres, hvis det for eksempel holder å ekstrahere dato fra en timestamp.
@Test
public void testThatIntendedDateFormatIsUsed() {
Car car = Car.builder()
.handoverDateTime(LocalDateTime.of(2017, 9, 10, 0, 0, 0)).build();
CarDto carDto = CarMapper.INSTANCE.carToCarDto(car);
assertEquals("2017-09-10", carDto.getDeliveredToOwnerDate());
}
For IntelliJ og Eclipse finnes det en MapStruct-plugin som gjør det raskere å bevege seg mellom mapperen og de ulike klassene. Som tidligere beskrevet genereres altså koden for mapperen ved kompilering — noe som gjør det mye mer håndterbart å debugge oppførselen.
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2018-03-04T19:22:38+0100",
comments = "version: 1.2.0.Final, compiler: javac, environment: Java 1.8.0_121 (Oracle Corporation)"
)
public class CarMapperImpl implements CarMapper {
@Override
public CarDto carToCarDto(Car car) {
if ( car == null ) {
return null;
}
CarDto carDto = new CarDto();
if ( car.getModel() != null ) {
carDto.setModel( car.getModel() );
}
else {
carDto.setModel( "LC500h" );
}
if ( car.getHandoverDateTime() != null ) {
carDto.setDeliveredToOwnerDate( DateTimeFormatter.ofPattern( "yyyy-MM-dd" ).format( car.getHandoverDateTime() ) );
}
String brandName = carBrandBrandName( car );
if ( brandName != null ) {
carDto.setBrand( brandName );
}
carDto.setSeatCount( car.getNumberOfSeats() );
carDto.setHybrid( com.example.demo.CarUtil.isHybrid(car.isHybrid()) );
return carDto;
}
private String carBrandBrandName(Car car) {
if ( car == null ) {
return null;
}
CarBrand brand = car.getBrand();
if ( brand == null ) {
return null;
}
String brandName = brand.getBrandName();
if ( brandName == null ) {
return null;
}
return brandName;
}
}
Etter å ha jobbet noen måneder i et prosjekt hvor vi bruker MapStruct som rammeverk for mapping har jeg ingen store innvendinger. Det er raskt å konfigurere byggemiljøet og de vanligste utfordringene med mapping av objekter løses elegant. Det resulterer også i en renere kodebase, hvor utviklerne kan fokusere på å løse oppgaver og skrive enhetstester istedenfor å finne opp kruttet på nytt.