Mapowanie obiektów z użyciem MapStruct

Mapowanie obiektów z użyciem MapStruct

MapStruct jest frameworkiem (procesorem adnotacji) który pozwala w łatwy i szybki sposób mapować obiekty w Javie. Ma to zastosowanie kiedy zewnętrzny model aplikacji tzw. kontrakt różni się od wewnętrznego modelu aplikacji. Dobrą praktyką jest to aby przed wysłaniem danych na front dokonać konwersji encji na obiekt transportowy DTO. Takie podejście pozwoli zwrócić tylko potrzebne dane co przełoży się w konsekwencji na szybkość działania całej aplikacji. Kolejną zaletą jest unikanie tzw. BoilerPlate kodu. Alternatywą do MapStruct jest narzędzie Dozer. Zanim przejdziemy do bardziej zaawansowanych mapowań zobaczmy szybki przykład:

Plik pom.xml:

W poniższym pliku skonfigurowano również maven-compiler-plugin w taki sposób aby można było wykorzystać bibliotekę Lombok w projekcie. Jawnie wskazano, żeby procesor adnotacji Lomboka był przez mavena używany.

<dependencies>
	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<version>${version.lombok}</version>
		<optional>true</optional>
	</dependency>
	<dependency>
		<groupId>org.mapstruct</groupId>
		<artifactId>mapstruct</artifactId>
		<version>${version.mapstruct}</version>
	</dependency>
	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok-mapstruct-binding</artifactId>
		<version>${version.mapstruct-lombok}</version>
	</dependency>
	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<version>1.18.20</version>
		<scope>provided</scope>
	</dependency>
	<dependency>
		<groupId>junit</groupId>
		<artifactId>junit</artifactId>
		<version>4.12</version>
		<scope>test</scope>
	</dependency>
</dependencies>
 
<build>
	<plugins>
		<plugin>
			<groupId>org.apache.maven.plugins</groupId>
			<artifactId>maven-compiler-plugin</artifactId>
			<version>3.8.1</version>
			<configuration>
				<source>8</source>
				<target>8</target>
				<annotationProcessorPaths>
					<path>
						<groupId>org.mapstruct</groupId>
						<artifactId>mapstruct-processor</artifactId>
						<version>${version.mapstruct}</version>
					</path>
					<path>
						<groupId>org.projectlombok</groupId>
						<artifactId>lombok</artifactId>
						<version>${version.lombok}</version>
					</path>
					<path>
						<groupId>org.projectlombok</groupId>
						<artifactId>lombok-mapstruct-binding</artifactId>
						<version>${version.mapstruct-lombok}</version>
					</path>
				</annotationProcessorPaths>
			</configuration>
		</plugin>
	</plugins>
</build>

Przykładowy model:

@AllArgsConstructor
@Getter
@Setter
@NoArgsConstructor
public class Person {
    String name;
    String surname;
    Sex sex
public enum Sex {
    M,
    W
}

Obiekt transportowy DTO:

@AllArgsConstructor
@Getter
@Setter
@NoArgsConstructor
public class PersonDto {
    String username;
    String surname;
    String sex;
}

Przykładowy mapper – pola które mają taką samą nazwę zostaną automatycznie przemapowane, pola do których zaś nie dostarczymy definicji mapowania a mają różne nazwy zostaną zignorowane.

@Mapper
public interface PersonMapper {
    PersonMapper INSTANCE = Mappers.getMapper(PersonMapper.class);
 
    @Mapping(source = "name", target = "username")
    PersonDto personToPersonDto(Person person);
}

Definiując:

@Mapper(componentModel = "spring")

Mapper będzie dostępny jako komponent springa, co pozwoli na jego użycie jako spring beana.

Test poprawności mapowania:

public class shouldMapPersonToPersonDto{
 
    @Test
    public void shouldMapCarToDto() {
 
        //given
        Person person = new Person("Java", "Leader", Sex.M);
 
        //when
        PersonDto personDto = PersonMapper.INSTANCE.personToPersonDto(person);
 
        //then
        assertThat(personDto, is(IsNull.notNullValue()));
        assertThat( personDto.getUsername(), is("Java"));
        assertThat( personDto.getSurname(), is("Leader"));
        assertThat( personDto.getSex(), is("M"));
 
    }
}

Skupmy się teraz na adnotacji Mapping. Adnotacja @Mapping posiada trzy atrybuty:

  • Source – bazowa nazwa pola (z obiektu przekazanego do metody mapującej),
  • Target – nazwa pola do którego mapowanie ma być zastosowane (z obiektu zwracanego przez metodę mapującą),
  • Ignore – nazwa docelowa pola do którego mapowanie ma być zignorowane. Ma zastosowanie jedynie z podaniem powyższego parametru Target.

W przypadku mapowania pól prostych oraz pól z biblioteki Java Core nie ma problemu. W przypadku natomiast pól złożonych które zostały zdefiniowane przez nas to my jako developerzy musimy zadbać o prawidłowe mapowanie tych pól. Dla przykładu rozszerzmy model danych o dodatkowe pole:

@AllArgsConstructor
@Getter
@Setter
@NoArgsConstructor
public class Person {
    String name;
    String surname;
    Sex sex;
    Address address;
}

oraz:

@AllArgsConstructor
@Getter
@Setter
@NoArgsConstructor
public class PersonDto {
    String username;
    String surname;
    String sex;
    AddressDto addressDto;
}

Mapper wzbogacony o nową domyślną metodę mapującą:

@Mapper
public interface PersonMapper {
    PersonMapper INSTANCE = Mappers.getMapper(PersonMapper.class);
 
    @Mapping(source = "name", target = "username")
    @Mapping(source = "address", target = "addressDto")
    PersonDto personToPersonDto(Person person);
 
    default AddressDto addressToAddressDto(Address address) {
        if (address == null) {
            return null;
        }
        AddressDto addressDto = new AddressDto();
        addressDto.setStreet(address.getStreet());
        addressDto.setCity(address.getCity());
        addressDto.setZipCode(address.getZipCode());
        addressDto.setState(address.getState());
        return addressDto;
    }
}

Zwróćmy uwagę, że w powyższej metodzie mapującej nie ma rozbieżności co do nazewnictwa pól więc po dodaniu samej deklaracji metody domyślnej pola zostałyby prawidłowo zmapowane. Jeśli jednak zależy nam na konkretnym mapowaniu pól według swoich preferencji to jest własnie to miejsce gdzie należy takie mapowanie wykonać.

dodajemy testy jednostkowe:

assertThat(personDto.getAddressDto(), is(IsNull.notNullValue()));
assertThat(personDto.getAddressDto().getStreet(),  is("streetName"));
assertThat(personDto.getAddressDto().getCity(),    is("cityName"));
assertThat(personDto.getAddressDto().getState(),   is("stateName"));
assertThat(personDto.getAddressDto().getZipCode(), is("zipCodeName"));

Jeśli zależy nam na utworzeniu metody mapującej która korzysta już z konfiguracji mapowań zdefiniowanych innej metodzie to należy skorzystać z adnotacji:

@InheritConfiguration

poniżej zdefiniowana metoda korzysta z mapowań metody personToPersonDto, ale z pominięciem mapowania dla pola address:

@InheritConfiguration(name = "personToPersonDto")
@Mapping(ignore = true, target = "addressDto")
PersonDto personToPersonDtoWithoutAddress(Person person);

kilka testów jednostkowych:

//when
PersonDto personWithoutAddressDto = PersonMapper.INSTANCE.personToPersonDtoWithoutAddress(person);
 
//then
assertThat(personWithoutAddressDto, is(IsNull.notNullValue()));
assertThat(personWithoutAddressDto.getUsername(), is("Java"));
assertThat(personWithoutAddressDto.getSurname(), is("Leader"));
assertThat(personWithoutAddressDto.getSex(), is("M"));
 
assertThat(personWithoutAddressDto.getAddressDto(), is(IsNull.nullValue()));

Adnotacja @InheritConfiguration ma swoją druga wersję @InheritInverseConfiguration która pozwala na dziedziczenie mapowania w drugą stronę:

@InheritInverseConfiguration(name = "personToPersonDto")
@Mapping(ignore = true, target = "address")
Person personDtoToPersonWithoutAddress(PersonDto personDto);

kilka testów jednostkowych:

//when
Person personDtoWithoutAddress = PersonMapper.INSTANCE.personDtoToPersonWithoutAddress(personDto);
 
//then
assertThat(personDtoWithoutAddress, is(IsNull.notNullValue()));
assertThat(personDtoWithoutAddress.getName(), is("Java"));
assertThat(personDtoWithoutAddress.getSurname(), is("Leader"));
assertThat(personDtoWithoutAddress.getSex(), is(Sex.M));
 
assertThat(personDtoWithoutAddress.getAddress(), is(IsNull.nullValue()));

Definiowanie metod domyślnych nie jest dobrym pomysłem. Wynika to z konieczności dodawania tej samej metody w kilku innym mapperach. Rozwiązaniem tego jest dodanie kolejnego mappera oraz użycie parametru uses w adnotacji @Mapper.

@Mapper
public interface AddressMapper {
    AddressDto addressToAddressDto(Address address);
}
@Mapper(uses = AddressMapper.class)
public interface PersonMapper {
   ...
}

Przyjrzyjmy się teraz mapowaniu wyliczeń – enums. Utwórzmy przykładowe wyliczenia:

public enum DaysWeekEnum {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}
public enum DausWeekEnumDto {
    MON, TUE, WEN, THU, FRI, SAT, SUN
}

Musimy zmapować wszystkie wartości z enuma źródłowego. W przeciwnym razie otrzymamy błąd kompilacji. Tworzymy mapper:

@Mapper
public interface DaysMapper {
 
    DaysMapper INSTANCE = Mappers.getMapper(DaysMapper.class);
 
    @ValueMappings({
        @ValueMapping(source = "MONDAY",   target = "MON"),
        @ValueMapping(source = "TUESDAY",  target = "TUE"),
        @ValueMapping(source = "WEDNESDAY",target = "WED"),
        @ValueMapping(source = "THURSDAY", target = "THU"),
        @ValueMapping(source = "FRIDAY",   target = "FRI"),
        @ValueMapping(source = "SATURDAY", target = "SAT"),
        @ValueMapping(source = "SUNDAY",   target = "SUN")
    })
    DaysWeekEnumDto mapDaysWeekToDaysWeekDtoEnum(DaysWeekEnum daysWeekEnum);
}

Kilka testów jednostkowych:

@Test
public void shouldMapEnumToEnumDto() {
 
    DaysWeekEnum monday   = DaysWeekEnum.MONDAY;
    DaysWeekEnum tuesday  = DaysWeekEnum.TUESDAY;
    DaysWeekEnum wednsday = DaysWeekEnum.WEDNESDAY;
    DaysWeekEnum thursday = DaysWeekEnum.THURSDAY;
    DaysWeekEnum friday   = DaysWeekEnum.FRIDAY;
    DaysWeekEnum saturday = DaysWeekEnum.SATURDAY;
    DaysWeekEnum sunday   = DaysWeekEnum.SUNDAY;
 
    DaysWeekEnumDto mondayToDto = DaysMapper.INSTANCE.mapDaysWeekToDaysWeekDtoEnum(monday);
    assertThat(mondayToDto, is(DaysWeekEnumDto.MON));
 
    DaysWeekEnumDto tuesdayToDto = DaysMapper.INSTANCE.mapDaysWeekToDaysWeekDtoEnum(tuesday);
    assertThat(tuesdayToDto, is(DaysWeekEnumDto.TUE));
 
    DaysWeekEnumDto wednsdayToDto = DaysMapper.INSTANCE.mapDaysWeekToDaysWeekDtoEnum(wednsday);
    assertThat(wednsdayToDto, is(DaysWeekEnumDto.WED));
 
    DaysWeekEnumDto thursdayToDto = DaysMapper.INSTANCE.mapDaysWeekToDaysWeekDtoEnum(thursday);
    assertThat(thursdayToDto, is(DaysWeekEnumDto.THU));
 
    DaysWeekEnumDto fridayToDto = DaysMapper.INSTANCE.mapDaysWeekToDaysWeekDtoEnum(friday);
    assertThat(fridayToDto, is(DaysWeekEnumDto.FRI));
 
    DaysWeekEnumDto saturdayToDto = DaysMapper.INSTANCE.mapDaysWeekToDaysWeekDtoEnum(saturday);
    assertThat(saturdayToDto, is(DaysWeekEnumDto.SAT));
 
    DaysWeekEnumDto sundayToDto = DaysMapper.INSTANCE.mapDaysWeekToDaysWeekDtoEnum(sunday);
    assertThat(sundayToDto, is(DaysWeekEnumDto.SUN));
}

ANY_REMAINING & ANY_UNMAPPED

Przyjrzyjmy się teraz parametrom:

MappingConstants.ANY_REMAINING

oraz:

MappingConstants.ANY_UNMAPPED

zdefiniujmy dla przykładu enumy:

public enum StatusEnum {
    VERY_LOW,
    LOW,
    MEDIUM,
    MAX
}

oraz:

public enum StatusEnumDto {
    LOW,
    MEDIUM,
    HIGH,
    OTHER
}

zdefniujmy mapper:

@Mapper
public interface StatusMapper {
    @ValueMapping(source = "LOW",target = "LOW")
    @ValueMapping(source = "MEDIUM",target = "MEDIUM")
    @ValueMapping(source = "MAX",target = "HIGH")
    @ValueMapping(source =  MappingConstants.ANY_REMAINING, target = "OTHER")
    StatusEnumDto mapStatusToStatusDto(StatusEnum statusEnum);
}

ANY_REMAINING mówi nam o tym, że wartości z enuma źródłowego zostaną zmapowane na wartości z enuma docelowego jeśli posiadają takie same nazwy lub mapowanie zostało ręcznie zdefiniowane za pomocą adnotacji @ValueMapping. Pozostałe jednak wartości które nie mają takich samych nazw zostaną przemapowane na wartość target = „OTHER”.

Kilka testów jednostkowych:

@Test
public void shouldMapStatusToStatusDto() {
 
    StatusEnum MAX    = StatusEnum.MAX;
    StatusEnumDto max = StatusMapper.INSTANCE.mapStatusToStatusDto(MAX);
    assertThat(max, is(StatusEnumDto.HIGH));
 
    StatusEnum MEDIUM    = StatusEnum.MEDIUM;
    StatusEnumDto medium = StatusMapper.INSTANCE.mapStatusToStatusDto(MEDIUM);
    assertThat(medium, is(StatusEnumDto.MEDIUM));
 
    StatusEnum LOW    = StatusEnum.LOW;
    StatusEnumDto low = StatusMapper.INSTANCE.mapStatusToStatusDto(LOW);
    assertThat(low, is(StatusEnumDto.LOW));
 
    StatusEnum VERY_LOW    = StatusEnum.VERY_LOW;
    StatusEnumDto very_low = StatusMapper.INSTANCE.mapStatusToStatusDto(VERY_LOW);
    assertThat(very_low, is(StatusEnumDto.OTHER));
}

ANY_UNMAPPED mówi nam o tym, że wszystkie wartości z enuma źródłowego zostaną przemapowane na wartość target=”OTHER” nawet jeśli posiadają takie same nazwy, ale jawnie nie zdefiniowanao dla nich mapowania za pomoca adnotacji @ValueMapping.

@Mapper
public interface StatusMapper {
    @ValueMapping(source =  MappingConstants.ANY_REMAINING, target = "OTHER")
    StatusEnumDto mapStatusToStatusDto(StatusEnum statusEnum);
}

Kilka testów jednostkowych:

@Test
public void shouldMapStatusToStatusDto() {
 
    StatusEnum MAX    = StatusEnum.MAX;
    StatusEnumDto max = StatusMapper.INSTANCE.mapStatusToStatusDto(MAX);
    assertThat(max, is(StatusEnumDto.OTHER));
 
    StatusEnum MEDIUM    = StatusEnum.MEDIUM;
    StatusEnumDto medium = StatusMapper.INSTANCE.mapStatusToStatusDto(MEDIUM);
    assertThat(medium, is(StatusEnumDto.OTHER));
 
    StatusEnum LOW    = StatusEnum.LOW;
    StatusEnumDto low = StatusMapper.INSTANCE.mapStatusToStatusDto(LOW);
    assertThat(low, is(StatusEnumDto.OTHER));
 
    StatusEnum VERY_LOW    = StatusEnum.VERY_LOW;
    StatusEnumDto very_low = StatusMapper.INSTANCE.mapStatusToStatusDto(VERY_LOW);
    assertThat(very_low, is(StatusEnumDto.OTHER));
 
}

wszystkie wartości zostały zmapowane na OTHER mimo, że posiadają takie same nazwy. Ważną uwagą jest to, że nie można używać obydwu tych parametrów jednocześnie czyli albo ANY_REMAINING albo ANY_UNMAPPED.

@Named & @IterableMapping

Co w przypadku kiedy dany mapper zawiera dwie metody mapujące dla tych samych obiektów? Otrzymamy błąd kompilacji, przykładowy mapper:

@Mapper
public interface AddressMapper {
 
    AddressDto addressToAddressDto(Address address);
 
    @Mapping(ignore = true, target = "street")
    AddressDto addressToAddressDtoWithoutStreet(Address address);
}
@Mapping(source = "name", target = "username")
@Mapping(source = "address", target = "addressDto")
PersonDto personToPersonDtoWithAddressWithoutStreet(Person person);

W takim przypadku należy wskazać z której metody mapującej chcemy szkorzystać, inaczej pojawi się błąd:

Ambiguous mapping methods found for mapping property "Address address" to AddressDto

Rozwiązaniem problemu jest użycie adnotacji @Named:

@Mapping(ignore = true, target = "street")
@Named("addressToAddressDtoWithoutStreet")
AddressDto addressToAddressDtoWithoutStreet(Address address);

oraz jawne wskazanie z której metody mapującej chcemy skorzystać z użyciem parametru qualifiedByName:

@Mapping(source = "name", target = "username")
@Mapping(source = "address", target = "addressDto", qualifiedByName = "addressToAddressDtoWithoutStreet")
PersonDto personToPersonDtoWithAddressWithoutStreet(Person person);

W przypadku mapowania kolekcji aby wskazać konkretnie z której metody mappowanie ma być zastosowane nalezy skorzystać z adnotacji – @IterableMapping wraz z przedstawioną wyżej adnotacją @Named.

@Mapping(source = "name", target = "username")
@Mapping(source = "address", target = "addressDto")
PersonDto personToPersonDto(Person person);
 
@InheritConfiguration(name = "personToPersonDto")
@Mapping(ignore = true, target = "addressDto")
@Named("personToPersonDtoWithoutAddress")
PersonDto personToPersonDtoWithoutAddress(Person person);
@IterableMapping(qualifiedByName = "personToPersonDtoWithoutAddress")
List<PersonDto> personListToPersonListDto(List<Person> personList);

@AfterMapping & @BeforeMapping

@AfterMapping oraz @BeforeMapping to adnotacje które wykonują się odpowiednio przed oraz po zdefiniowanym mapowaniu. Dla przykładu utwórzmy klasy:

@AllArgsConstructor
@Getter
@Setter
@NoArgsConstructor
public class Employee {
    int id;
    String name;
    String surame;
}
@AllArgsConstructor
@Getter
@Setter
@NoArgsConstructor
public class EmployeeDto {
    int id;
    String name;
    String surname;
}
@AllArgsConstructor
@Getter
@Setter
@NoArgsConstructor
public class Company {
    Employee employee;
}
@AllArgsConstructor
@Getter
@Setter
@NoArgsConstructor
public class CompanyDto {
    int employeeId;
}

Definiujemy mapper:

@Mapper
public abstract class CompanyMapper {
 
    public abstract CompanyDto companyToCompanyDto(Company company);
 
    public static CompanyMapper INSTANCE = Mappers.getMapper(CompanyMapper.class);
 
    @AfterMapping
    public void afterMappingCompanyToCompanyDto(Company company, @MappingTarget CompanyDto companyDto) {
        System.out.println("[invoke] afterMappingCompanyToCompanyDto");
        companyDto.setEmployeeId(company.getEmployee().getId());
    }
}

Test jednostkowy:

@Test
public void shouldMappingCompanyId() {
 
    Employee  employee = new Employee(1, "Java", "Leader");
    Company company = new Company(employee);
 
    CompanyDto companyDto = CompanyMapper.INSTANCE.companyToCompanyDto(company);
    assertThat(companyDto.getEmployeeId(),is(1));
}

Powyższy przykład pokazuje, że po wykonaniu mapowania Company > CompanyDto identyfikator obiektu Employee z Company został prawidlowo przemapowany do pola id w CompanyDto. Analogicznie działa adnotacja @BeforeMapping:

@BeforeMapping
public void beforeMappingCompanyToCompanyDto(Company company, @MappingTarget CompanyDto companyDto) {
    System.out.println("operations before mapping company => companyDto");
}

Parametr numberFormat & defaultExpression

Jeśli chcielibyśmy dokonac konwersji double => string z zachowaniem zdefiniowanego formatu z pomoca przychodzi parametr numberFormat. Przyjrzymy się przykładowi:

@AllArgsConstructor
@Getter
@Setter
@NoArgsConstructor
public class Employee {
    int id;
    String name;
    String surname;
    Double salary;
 
    public Employee(int id, String name, String surname) {
        this.id = id;
        this.name = name;
        this.surname = surname;
    }
}
@AllArgsConstructor
@Getter
@Setter
@NoArgsConstructor
public class EmployeeDto {
    int id;
    String name;
    String surname;
    String salary;
}
@Mapper
public interface EmployeeMapper {
 
    EmployeeMapper INSTANCE = Mappers.getMapper(EmployeeMapper.class);
 
    @Mapping(source = "salary",target = "salary", numberFormat = "$#.00", defaultExpression = "java(new String(\"$1500\"))")
    EmployeeDto mapEmployeeToEmployeeDto(Employee employee);
}

Test jednostkowy który zweryfikuje czy pole salary zostało prawidłowo przemapowane z zachowaniem odpowiedniego formatu. W przypadku wartości null zostanie wykonane wyrażenie zdefiniowane w defaultExpression które ustawi domyślną wartość dla tego pola:

@Test
public void shouldMappingCompanyId() {
 
    Employee  employee = new Employee(1, "Java", "Leader",  new Double(1200));
    Company company = new Company(employee);
 
    CompanyDto companyDto = CompanyMapper.INSTANCE.companyToCompanyDto(company);
    assertThat(companyDto.getEmployeeId(),is(1));
}

Zobacz kod na GitHubie i zapisz się na bezpłatny newsletter!

.

2 Comments

  1. Cześć!

    Zastanawiam się kiedy powinno się korzystać z takich narzędzi. Czy wtedy, gdy mamy jakiś zewnętrzny serwis, który ma swój model danych i chcemy go zmapować na nasz wewnętrzny? Masz może jakieś przykłady?

    Pozdrawiam!

    • marcin warycha - javaleader.pl 13 października 2021 at 16:21

      Cześć Cezary,

      Zwróć uwagę, że nie chcesz prezentować zawartości całej Twojej encji na frontendzie. Powinieneś przygotować odpowiednie obiekty transportowe zawierające tylko niezbędne informacje. W tym celu ręczne mapowanie zawartości encji
      na DTO (data transfer object) byłoby bardzo pracochłonne i tutaj z pomocą przychodzi właśnie MapStruct!

Leave a comment

Your email address will not be published.


*