Wzorce projektowe powstały po to, aby dać sprawdzone w boju narzędzie do rozwiązania problemów. Jednym z takich wzorców jest dekorator, który pozwala na dodawanie nowych obowiązków danej klasie przez opakowanie jej. Właśnie z tego rozwiązania skorzystali twórcy MapStruct tworząc adnotację @DecoratedWith. Dzięki jej zastosowaniu możemy rozszerzyć działanie zwykłego mappera np. takiego jak ten z jednego z poprzednich wpisów. Przejdźmy zatem do zweryfikowania jak ten mechanizm działa w praktyce.

Udekorujmy sobie mapper

Jak zawsze zacznijmy od zdefiniowania problemu. Dostępne mamy dwie klasy: Employee oraz EmployeeDto, które chcielibyśmy konwertować pomiędzy sobą. Natomiast jest pomiędzy nimi różnica. Pierwszy przechowuje wypłatę jako BigDecimal, a drugi jako String wraz z jednostką monetarną. Przy okazji do ich implementacji wykorzystamy bibliotekę Lombok (dokładniej adnotację @Builder), która później ułatwi nam pracę.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package pl.devcezz.mapstruct.decorator;

import lombok.Builder;

import java.math.BigDecimal;

@Builder(toBuilder = true)
public class Employee {

  private String firstname;
  private String surname;
  private BigDecimal salary;

  public Employee(String firstname, String surname, BigDecimal salary) {
    this.firstname = firstname;
    this.surname = surname;
    this.salary = salary;
  }

  public String getFirstname() {
    return firstname;
  }

  public String getSurname() {
    return surname;
  }

  public BigDecimal getSalary() {
    return salary;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package pl.devcezz.mapstruct.decorator;

import lombok.Builder;

@Builder(toBuilder = true)
public class EmployeeDto {

  private String firstname;
  private String surname;
  private String salaryWithEuro;

  public EmployeeDto(String firstname, String surname, String salaryWithEuro) {
    this.firstname = firstname;
    this.surname = surname;
    this.salaryWithEuro = salaryWithEuro;
  }

  public String getFirstname() {
    return firstname;
  }

  public String getSurname() {
    return surname;
  }

  public String getSalaryWithEuro() {
    return salaryWithEuro;
  }
}

W tym przypadku użycie adnotacji @Mappings oraz @Mapping na niewiele się zda. Co prawda MapStruct będzie z tym walczył poprzez konwersję BigDecimal na String i odwrotnie, ale nie w taki sposób jak my byśmy tego chcieli.

Zwykły mapper na niewiele się zda przy bardziej złożonych konwersjach
Zwykły mapper na niewiele się zda przy bardziej złożonych konwersjach

W tym przypadku nie pozostaje nam nic innego jak wykorzystanie wzorca dekorator dostępnego w ramach biblioteki MapStruct. Tak jak wspomniałem dzięki niemu możemy poprawić mapowanie wygenerowanych metod mapujących tak, aby działały w sposób jaki my tego chcemy. Należy jednak mieć na uwadze, aby wskazać odpowiedni model komponentu, dla którego chcemy stworzyć dekorator. Ma to znacznie ze względu na jego zastosowanie w kodzie. Twórcy MapStruct postanowili wyróżnić 3 modele komponentów (dokładniej 4, ale CDI nie ma wsparcia dla adnotacji @DecoratedWith), dla których wygenerowany mapper powinien działać prawidłowo:

  • default
  • spring
  • jsr330

Przyjrzymy się za chwilę każdemu z nich. Warto nadmienić, że informację o wybrany modelu umieszczamy w adnotacji @Mapper, a dokładniej we właściwości componentModel. Domyślną wartością, o ile nie podamy żadnej, jest oczywiście default.

Domyślny model komponenty

Skoro default jest domyślną wartością to zajmiemy się nią w pierwszej kolejności. Na samym początku zdefiniujmy dekorator w kodzie, a później przyjrzymy się dokładniej jego składowym oraz działaniu.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package pl.devcezz.mapstruct.decorator;

import org.mapstruct.DecoratedWith;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;

@Mapper(componentModel = "default")
@DecoratedWith(DefaultEmployeeDecoratorMapper.class)
public interface EmployeeMapper {

  EmployeeMapper INSTANCE = Mappers.getMapper(EmployeeMapper.class);

  EmployeeDto map(Employee employee);

  Employee map(EmployeeDto dto);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package pl.devcezz.mapstruct.decorator;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public abstract class DefaultEmployeeDecoratorMapper implements EmployeeMapper {

  private static final String SALARY_FORMAT = "%1$.2f€";
  private static final String SALARY_REGEX = "\\d+\\.\\d{2}";

  private final EmployeeMapper delegate;

  public DefaultEmployeeDecoratorMapper(EmployeeMapper delegate) {
    this.delegate = delegate;
  }

  @Override
  public EmployeeDto map(Employee employee) {
    EmployeeDto dto = delegate.map(employee);
    return dto.toBuilder()
        .salaryWithEuro(formatSalary(employee.getSalary()))
        .build();
  }

  @Override
  public Employee map(EmployeeDto dto) {
    Employee employee = delegate.map(dto);
    return employee.toBuilder()
        .salary(extractSalary(dto.getSalaryWithEuro()))
        .build();
  }

  private String formatSalary(BigDecimal salary) {
    return String.format(Locale.US, SALARY_FORMAT, salary);
  }

  private BigDecimal extractSalary(String salary) {
    Pattern pattern = Pattern.compile(SALARY_REGEX);
    Matcher matcher = pattern.matcher(salary);
    BigDecimal result = matcher.find() ? new BigDecimal(matcher.group(0)) : BigDecimal.ZERO;
    return result.setScale(2, RoundingMode.HALF_UP);
  }
}

W interfejsie EmployeeMapper usunęliśmy zbędne adnotacje do wskazania jakie pola mają się mapować na ich odpowiedniki. W adnotacji @Mapper wskazaliśmy natomiast model komponentu na default co w sumie nie jest konieczne, bo jest to domyślne zachowanie. Dodatkowo “udekorowaliśmy” interfejs adnotacją @DecoratedWith i podaliśmy w niej nazwę klasy, w której zajmiemy się dokładnym mapowaniem.

Stworzony przez nas dekorator w postaci DefaultEmployeeDecoratorMapper jest klasą abstrakcyjną implementującą interfejs mapujący. Możliwe jest w nim zadeklarowanie pola do delegacji niektórych mapowań i przypisanie go w konstruktorze - dla naszego przypadku jest to po prostu EmployeeMapper. Następnie wybieramy metody do nadpisania, w których na początku delegujemy mapowanie, a później nadpisujemy wybrane pole dzięki wzorcowi budowniczego. I to wszystko! Resztę za nas wykonuje MapStruct. Teraz do pola INSTANCE przez instrukcję Mappers.getMapper(EmployeeMapper.class) zostanie wstrzyknięta odpowiednia implementacja z użytym dekoratorem. Uruchamiamy testy i wszystkie przechodzą.

Wykorzystując dekorator dla mappera wszystkie pola zostały poprawnie zmapowane
Wykorzystując dekorator dla mappera wszystkie pola zostały poprawnie zmapowane

Spójrzmy jeszcze do kodu, który wygenerował dla nas MapStruct. Klasa EmployeeMapperImpl_ to po prostu instancja stworzona na podstawie interfejsu. Natomiast EmployeeMapperImpl odpowiada za podpięcie dekoratora przez jego rozszerzenie. W konstruktorze podawana jest instancja EmployeeMapperImpl_ jako delegacja, którą sobie zażyczyliśmy.

Model komponentu dla Springa

Skoro już wiemy z czym się je to dekorowanie to przejdźmy do wsparcia MapStruct dla Springa. Zasada pozostaje praktycznie ta sama, tworzymy nowy dekorator plus w interfejsie EmployeeMapper zmieniamy model komponentu na spring i wskazujemy na nowoutworzoną klasę.

1
2
3
4
5
6
7
8
9
10
11
12
13
package pl.devcezz.mapstruct.decorator;

import org.mapstruct.DecoratedWith;
import org.mapstruct.Mapper;

@Mapper(componentModel = "spring")
@DecoratedWith(SpringEmployeeDecoratorMapper.class)
public interface EmployeeMapper {

  EmployeeDto map(Employee employee);

  Employee map(EmployeeDto dto);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package pl.devcezz.mapstruct.decorator;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public abstract class SpringEmployeeDecoratorMapper implements EmployeeMapper {

  private static final String SALARY_FORMAT = "%1$.2f€";
  private static final String SALARY_REGEX = "\\d+\\.\\d{2}";

  @Autowired
  @Qualifier("delegate")
  private EmployeeMapper delegate;

  @Override
  public EmployeeDto map(Employee employee) {
    EmployeeDto dto = delegate.map(employee);
    return dto.toBuilder()
        .salaryWithEuro(formatSalary(employee.getSalary()))
        .build();
  }

  @Override
  public Employee map(EmployeeDto dto) {
    Employee employee = delegate.map(dto);
    return employee.toBuilder()
        .salary(extractSalary(dto.getSalaryWithEuro()))
        .build();
  }

  private String formatSalary(BigDecimal salary) {
    return String.format(Locale.US, SALARY_FORMAT, salary);
  }

  private BigDecimal extractSalary(String salary) {
    Pattern pattern = Pattern.compile(SALARY_REGEX);
    Matcher matcher = pattern.matcher(salary);
    BigDecimal result = matcher.find() ? new BigDecimal(matcher.group(0)) : BigDecimal.ZERO;
    return result.setScale(2, RoundingMode.HALF_UP);
  }
}

Na co warto zwrócić uwagę to fakt, że usunęliśmy pole INSTANCE. W ten sposób interfejs znacznie się uprościł, ponieważ posiada tylko i wyłącznie dwie metody oraz dwie adnotacje. Klasa SpringEmployeeDecoratorMapper też wygląda praktycznie identycznie do swojego poprzednika. Musieliśmy wyrzucić konstruktor i dodać wstrzykiwanie na polu (inaczej MapStruct nie chciałby nam wygenerować implementacji, bo nie dałby rady stworzyć konstruktora z argumentami). Na co trzeba zwrócić uwagę, to gdy wymagane jest dodanie delegacji należy oznaczyć wybrane pole przez @Qualifier("delegate"). MapStruct tworzy dwie implementacje, które pasowałyby jako bean dla tego pola - klasę podstawową dla interfejsu oraz klasę dla dekoratora (obydwie implementujące EmployeeMapper). Dlatego pierwsza z nich ma właśnie dopisek w postaci adnotacji @Qualifier("delegate"), a druga @Primary. To rozwiązanie powstrzymuje konflikty w wyborze danego beana do podstawienia.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package pl.devcezz.mapstruct.decorator;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.math.BigDecimal;
import java.math.RoundingMode;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
public class EmployeeMapperIntTest {

  @Autowired
  EmployeeMapper employeeMapper;

  @Test
  void should_map_employee_to_employee_dto() {
    Employee employee = new Employee(
        "Jan",
        "Kowalski",
        BigDecimal.valueOf(2800.00)
    );

    EmployeeDto result = employeeMapper.map(employee);

    assertThat(result.getFirstname()).isEqualTo("Jan");
    assertThat(result.getSurname()).isEqualTo("Kowalski");
    assertThat(result.getSalaryWithEuro()).isEqualTo("2800.00€");
  }

  @Test
  void should_map_employee_dto_to_employee() {
    EmployeeDto dto = new EmployeeDto(
        "Albert",
        "Poniatowski",
        "9020.00€"
    );

    Employee result = employeeMapper.map(dto);

    assertThat(result.getFirstname()).isEqualTo("Albert");
    assertThat(result.getSurname()).isEqualTo("Poniatowski");
    assertThat(result.getSalary())
        .isEqualTo(BigDecimal.valueOf(9020.00).setScale(2, RoundingMode.HALF_UP));
  }
}

Z powodu tego, że nie możemy wstrzykiwać mappera przez konstruktor musieliśmy napisać test integracyjny. Trzeba zastanowić się tylko nad sensem czy przy tak prostej logice ma sens w ogóle pisanie jakikolwiek testów. Jednak ze względu na artykułu zamieściłem takie sprawdzenie.

Możemy również sprawdzić co by się stało w sytuacji, gdy nad EmployeeMapper w teście dodalibyśmy adnotację @Qualifier z wartością delegate. Wtedy mielibyśmy ten sam problem jaki miał miejsce na samym początku - użylibyśmy zwykłego mappera bez dekoratora.

Problematyczne wstrzykiwanie przez pole

Nie za bardzo podoba mi się to, że musimy robić wstrzykiwanie przez pole. Wolałbym mieć możliwość wyboru strategii przekazywania beanów. Niby można użyć właściwość injectionStrategy = InjectionStrategy.CONSTRUCTOR w adnotacji @Mapper, ale w moim przypadku to nie zadziałało. Na GitHub istnieje nawet sugestia stworzenia takiego rozwiązania, ale wydaje mi się to skomplikowane, ponieważ przed uruchomieniem testów musielibyśmy wygenerować implementacje mapperów, które chcielibyśmy użyć w testach jednostkowych. Myślisz, że dałoby się rozwiązać ten problem w rozsądny sposób? Jedyne co przychodzi mi na myśl to wstrzykiwanie przez setter. Co prawda działa ono w aktualnej wersji MapStruct, ale nie jestem zwolennikiem tego sposobu.

1
2
3
4
@Autowired
public void setDelegate(@Qualifier("delegate") EmployeeMapper delegate) {
  this.delegate = delegate;
}

Model komponentu według specyfikacji JSR330

Na samym początku należy wspomnieć notatkę z dokumentacji. Mówi ona, że model komponentu jsr330 jest uważany jako eksperymentalny i może on ulec zmianie w przyszłych wydaniach biblioteki.

NOTE: The decorator feature when used with component model jsr330 is considered experimental and it may change in future releases.

Natomiast samo podejście jest bardzo podobne do tego co widzieliśmy w przypadku Springa. Zamiast @Autowired wykorzystujemy @Inject, a w przypadku @Qualifier mamy do dyspozycji @Named. Możemy te dwie adnotacje wykorzystać w aplikacji Springa, ponieważ wspiera on również standard JSR330.

Marks a constructor, field, setter method, or config method as to be autowired by Spring’s dependency injection facilities. This is an alternative to the JSR-330 {@link javax.inject.Inject} annotation, adding required-vs-optional semantics.

1
2
3
4
5
6
7
8
9
10
11
12
13
package pl.devcezz.mapstruct.decorator;

import org.mapstruct.DecoratedWith;
import org.mapstruct.Mapper;

@Mapper(componentModel = "jsr330")
@DecoratedWith(Jsr330EmployeeDecoratorMapper.class)
public interface EmployeeMapper {

  EmployeeDto map(Employee employee);

  Employee map(EmployeeDto dto);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package pl.devcezz.mapstruct.decorator;

import javax.inject.Inject;
import javax.inject.Named;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public abstract class Jsr330EmployeeDecoratorMapper implements EmployeeMapper {

  private static final String SALARY_FORMAT = "%1$.2f€";
  private static final String SALARY_REGEX = "\\d+\\.\\d{2}";

  @Inject
  @Named("pl.devcezz.mapstruct.decorator.EmployeeMapperImpl_")
  private EmployeeMapper delegate;

  @Override
  public EmployeeDto map(Employee employee) {
    EmployeeDto dto = delegate.map(employee);
    return dto.toBuilder()
        .salaryWithEuro(formatSalary(employee.getSalary()))
        .build();
  }

  @Override
  public Employee map(EmployeeDto dto) {
    Employee employee = delegate.map(dto);
    return employee.toBuilder()
        .salary(extractSalary(dto.getSalaryWithEuro()))
        .build();
  }

  private String formatSalary(BigDecimal salary) {
    return String.format(Locale.US, SALARY_FORMAT, salary);
  }

  private BigDecimal extractSalary(String salary) {
    Pattern pattern = Pattern.compile(SALARY_REGEX);
    Matcher matcher = pattern.matcher(salary);
    BigDecimal result = matcher.find() ? new BigDecimal(matcher.group(0)) : BigDecimal.ZERO;
    return result.setScale(2, RoundingMode.HALF_UP);
  }
}

Jak widać różnice są minimalne w porównaniu do podejścia spring. Praktycznie różnią się tylko tym co opisałem wyżej. Warto zwrócić uwagę, że zmieniła się nazwa kwalifikatora. Teraz do podstawowej implementacji mappera musimy użyć znacznika @Named("pl.devcezz.mapstruct.decorator.EmployeeMapperImpl_"). Natomiast w teście jeśli chcemy skorzystać z dekoratora to podajemy tylko @Named. Nie ma tutaj domyślnego beana, zawsze trzeba wskazać implementację, którą chcemy akurat użyć.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package pl.devcezz.mapstruct.decorator;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.inject.Inject;
import javax.inject.Named;
import java.math.BigDecimal;
import java.math.RoundingMode;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
public class EmployeeMapperIntTest {

  @Inject
  @Named
  EmployeeMapper employeeMapper;

  //... tests
}

Poniżej widzimy wygenerowane klasy przez MapStruct dla pojdeścia jsr330 z wyspecjalizowanymi adnotacjami @Named. Sprawdziłoby się ono w przypadku Quarkusa.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package pl.devcezz.mapstruct.decorator;

import javax.inject.Named;
import javax.inject.Singleton;

@Singleton
@Named
public class EmployeeMapperImpl extends Jsr330EmployeeDecoratorMapper implements EmployeeMapper {
  public EmployeeMapperImpl() {
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package pl.devcezz.mapstruct.decorator;

import javax.inject.Named;
import javax.inject.Singleton;
import pl.devcezz.mapstruct.decorator.Employee.EmployeeBuilder;
import pl.devcezz.mapstruct.decorator.EmployeeDto.EmployeeDtoBuilder;

@Singleton
@Named("pl.devcezz.mapstruct.decorator.EmployeeMapperImpl_")
public class EmployeeMapperImpl_ implements EmployeeMapper {
  public EmployeeMapperImpl_() {
  }

  public EmployeeDto map(Employee employee) {
    if (employee == null) {
      return null;
    } else {
      EmployeeDtoBuilder employeeDto = EmployeeDto.builder();
      employeeDto.firstname(employee.getFirstname());
      employeeDto.surname(employee.getSurname());
      return employeeDto.build();
    }
  }

  public Employee map(EmployeeDto dto) {
    if (dto == null) {
      return null;
    } else {
      EmployeeBuilder employee = Employee.builder();
      employee.firstname(dto.getFirstname());
      employee.surname(dto.getSurname());
      return employee.build();
    }
  }
}

Podsumowanie

Jak widzisz twórcy MapStruct dali nam dużą swobodę do dodawania nowych funkcjonalności mapperom. Oczywiście w przypadku podejścia spring możemy do danego dekoratora wstrzyknąć inne komponenty, aby jeszcze bardziej rozszerzyć jego działalność. Mam nadzieję, że ten wpis przedstawił Ci kolejne możliwości jakie daje MapStruct.

Cały kod znajdziesz tutaj: https://github.com/cezarysanecki/code-from-blog