Szczegółowe informacje o Mapstruct @ Spring

(Miguel Duque) (14 grudnia 2020 r.)

Zdjęcie: Beau Swierstra na Unsplash

Będąc programistą Java, na pewno wiesz, że mapowanie POJO jest standardowym zadaniem podczas tworzenia aplikacji wielowarstwowych.
Ręczne pisanie tych mapowań jest nudne i nieprzyjemne zadanie dla programistów, jest również podatne na błędy.

MapStruct to otwarta biblioteka Java, która generuje implementacje klas mapowania podczas kompilacji w w bezpieczny i łatwy sposób.

W tym artykule będziemy podążać za przykładem, jak wykorzystać tę potężną bibliotekę, aby znacznie zmniejszyć ilość standardowego kodu, który byłby regularnie zapisywany ręcznie.

Indeks

  • Podstawowe mapowania
  • Odwzorowanie różnych nazw właściwości
  • Upewnienie się, że każda właściwość jest zamapowana
  • Mapowanie właściwości jednostki podrzędnej
  • Mapowanie całej jednostki podrzędnej – użycie innego mapowania
  • Mapowanie metodą niestandardową
  • @BeforeMapping @AfterMapping
  • Mapowanie z dodatkowym parametrem
  • Wstrzykiwanie zależności w metodach mapowania
  • Aktualizacje
  • Aktualizacje poprawek

Podstawowe mapowania

Zacznijmy naszą aplikację od podstawowego modelu, który zawiera klasę Doctor. Nasza usługa pobierze tę klasę z warstwy modelu, a następnie zwróci DoctorDto klasa.

Klasa modelu:

@Data
public class Doctor {
private int id;
private String name;
}

Klasa Dto:

@Data
public class DoctorDto {
private int id;
private String name;
}

Aby to zrobić, powinniśmy utworzyć interfejs Mappera:

@Mapper(componentModel = "spring")
public interface DoctorMapper {
DoctorDto toDto(Doctor doctor);
}

Ponieważ obie klasy mają takie same nazwy właściwości ( id i nazwa ), mapstruct uwzględni mapowanie obu pól w wygenerowanej klasie:

@Component
public class DoctorMapperImpl implements DoctorMapper {

@Override
public DoctorDto toDto(Doctor doctor) {
if ( doctor == null ) {
return null;
}

DoctorDto doctorDto = new DoctorDto();

doctorDto.setId( doctor.getId() );
doctorDto.setName( doctor.getName() );

return doctorDto;
}
}

Dodając componentModel = „Wiosna” , wygenerowany element mapujący będzie ziarnem wiosennym i można go pobrać z adnotacją @Autowired jak każdy inny element:

@Service
public class DoctorService {

private final DoctorMapper doctorMapper;
private final DoctorRepository doctorRepository;

@Autowired
public DoctorService(DoctorMapper doctorMapper) {
this.doctorMapper = doctorMapper;
this.doctorRepository = doctorRepository;
}

public DoctorDto getDoctor(Integer id) {
Doctor doctor = doctorRepository.findById(id);
return doctorMapper.toDto(doctor);
}
}

Mapowanie innej nazwy właściwości

Jeśli włączymy usługę telefon w Lekarz class:

@Data
public class Doctor {
private int id;
private String name;
private String phone;
}

Do zmapowania na kontakt w Doktorze Dto :

@Data
public class DoctorDto {
private int id;
private String name;
private String contact;
}

Nasz twórca map nie będzie w stanie zmapować go automatycznie. Aby to zrobić, musimy utworzyć regułę dla tego mapowania:

@Mapper(componentModel = "spring")
public interface DoctorMapper {

@Mapping(source = "phone", target = "contact")
DoctorDto toDto(Doctor doctor);
}

Upewnienie się, że każda właściwość jest zmapowana

Jeśli chcemy aby zagwarantować, że nie zapomnimy zmapować żadnej właściwości docelowej, możemy skonfigurować opcję unmappedTargetPolicy w naszym programie mapującym:

@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface DoctorMapper {

@Mapping(source = "phone", target = "contact")
DoctorDto toDto(Doctor doctor);
}

W tej konfiguracji, jeśli usuniemy

@Mapping (source = „phone”, target = „contact”)

nasz kod nie powiedzie się podczas kompilacji z błąd:

Niezamapowana właściwość docelowa: „contact”. DoctorDto toDto (Doctor doctor);

Jeśli z jakiegoś powodu chcemy zignorować cel możemy dodać:

@Mapping (target = „contact”, ignore = true)

Podobnie możemy zagwarantować, że wszystkie źródła właściwości są mapowane przez skonfigurowanie opcji unmappedSourcePolicy .

Mapping Child Entity property

W większości przypadków klasa, której potrzebujemy to map zawiera obiekty potomne . Na przykład:

@Data
public class Doctor {
private int id;
private String name;
private String phone;
private Speciality speciality;
}@Data
public class Speciality {
private int id;
private String name;
}

A w naszym Dto zamiast pełnej specjalności potrzebujemy tylko jego nazwy:

@Data
public class DoctorDto {
private int id;
private String name;
private String contact;
private String specialityName;
}

Ta sytuacja jest również prosta w przypadku mapstruct :

@Mapping(source = "phone", target = "contact")
@Mapping(source = "speciality.name", target = "specialityName")
DoctorDto toDto(Doctor doctor);

Mapowanie całej jednostki podrzędnej – użycie innego mapper

Tak jak poprzednio, nasza klasa Doctor ma obiekt podrzędny Adres :

@Data
public class Doctor {
private int id;
private String name;
private String phone;
private Speciality speciality;
private Address address;
}

Ale w tym przypadku chcemy zmapować go do nowego obiekt w naszej klasie DoctorDto :

@Data
public class DoctorDto {
private int id;
private String name;
private String contact;
private String specialityName;
private AddressDto address;
}

Do wykonać mapowanie między klasą Address i AddressDto, powinniśmy utworzyć inny interfejs mappera:

@Mapper(componentModel = "spring")
public interface AddressMapper {
AddressDto toDto(Address address);
}

Następnie w naszym DoctorMapper powinniśmy upewnić się, że ten mapujący jest używany podczas mapowania Doctor do DoctorDto .Można to zrobić za pomocą opcji „ używa” w konfiguracji programu mapującego:

@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR,
uses = {AddressMapper.class})
public interface DoctorMapper {

@Mapping(source = "phone", target = "contact")
@Mapping(source = "speciality.name", target = "specialityName")
DoctorDto toDto(Doctor doctor);
}

Zobaczymy, że nasz DoctorMapperImpl będzie Autowire i użyje naszego AddressMapper :

@Component
public class DoctorMapperImpl implements DoctorMapper {

@Autowired
private AddressMapper addressMapper;

@Override
public DoctorDto toDto(Doctor doctor) {
if ( doctor == null ) {
return null;
}

DoctorDto doctorDto = new DoctorDto();

doctorDto.setSpecialityName( doctorSpecialityName(doctor));
doctorDto.setContact( doctor.getPhone() );
doctorDto.setId( doctor.getId() );
doctorDto.setName( doctor.getName() );
doctorDto.setAddress(
addressMapper.toDto( doctor.getAddress() ) );


return doctorDto;
}

...
}

Mapowanie metodą niestandardową

Dodajmy teraz listę pacjentów do naszego Lekarz klasa:

@Data
public class Doctor {
private int id;
private String name;
private String phone;
private Speciality speciality;
private Address address;
private List patients;
}

Jednak w naszym DoctorDto chcemy tylko liczby pacjentów:

@Data
public class DoctorDto {
private int id;
private String name;
private String contact;
private String specialityName;
private AddressDto address;
private int numPatients;
}

To mapowanie wymaga 2 rzeczy:

  • Metoda niestandardowa z adnotacją @Named
  • Konfiguracja qualifiedByName w adnotacji Mapping
@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR,
uses = {AddressMapper.class})
public interface DoctorMapper {

@Mapping(source = "phone", target = "contact")
@Mapping(source = "speciality.name", target = "specialityName")
@Mapping(
source = "patients",
target = "numPatients",
qualifiedByName = "countPatients")

DoctorDto toDto(Doctor doctor);

@Named("countPatients")
default int getNumPatients(List patients) {
if(patients == null) {
return 0;
}
return patients.size();
}
}

@BeforeMapping @AfterMapping

Poprzedni przykład (mapowanie z Lista pacjentów do int numPatients ) można również wykonać za pomocą @BeforeMapping i @AfterMapping

@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR,
uses = {AddressMapper.class})
public interface DoctorMapper {

@BeforeMapping
default void setList(Doctor doctor) {
if (doctor != null && doctor.getPatients() == null) {
doctor.setPatients(new ArrayList());
}
}

@Mapping(source = "phone", target = "contact")
@Mapping(source = "speciality.name", target = "specialityName")
@Mapping(target = "numPatients", ignore = true)
DoctorDto toDto(Doctor doctor);

@AfterMapping
default void setNumPatients(Doctor doctor,
@MappingTarget DoctorDto doctorDto) {
doctorDto.setNumPatients(doctor.getPatients().size());
}
}

Te metody będą wywoływane na początku i na końcu naszej wygenerowanej metody mapowania:

@Override
public DoctorDto toDto(Doctor doctor) {
setList( doctor );

if ( doctor == null ) {
return null;
}

DoctorDto doctorDto = new DoctorDto();

doctorDto.setSpecialityName( doctorSpecialityName( doctor ) );
doctorDto.setContact( doctor.getPhone() );
doctorDto.setId( doctor.getId() );
doctorDto.setName( doctor.getName() );
doctorDto.setAddress( addressMapper.toDto(doctor.getAddress()));

setNumPatients( doctor, doctorDto );

return doctorDto;
}

Mapowanie z dodatkowym parametrem

Sprawdźmy, jak poradzić sobie w sytuacji, w której twórca mapowania potrzebuje dodatkowego parametru oprócz jednostki.

W tym przypadku klasa lekarza może uzyskać tylko identyfikator miasta:

@Data
public class Doctor {
private int id;
private String name;
private String phone;
private Speciality speciality;
private Address address;
private List patients;
private int cityId;
}

Ale musi zmapować go na nazwę miasta:

@Data
public class DoctorDto {
private int id;
private String name;
private String contact;
private String specialityName;
private AddressDto address;
private int numPatients;
private String cityName;
}

Twoja usługa pobierze listę miast i przekaże ją twórcy map

@Service
public class DoctorService {

private final DoctorMapper doctorMapper;
private final DoctorRepository doctorRepository;

@Autowired
public DoctorService(DoctorMapper doctorMapper) {
this.doctorMapper = doctorMapper;
this.doctorRepository = doctorRepository;
}

public DoctorDto getDoctor(Integer id) {
Doctor doctor = doctorRepository.findById(id);
List cities = getCities();
return doctorMapper.toDto(doctor, cities);
}
}

W naszym programie mapującym musimy:

  • oznaczyć dodatkowy parametr (listę miast) adnotacją @Context
  • utworzyć niestandardowy metoda obsługi naszego mapowania
  • zażądaj parametru kontekstu (listy miast) na naszym niestandardowym ma pping
@Mapping(source = "phone", target = "contact")
@Mapping(source = "speciality.name", target = "specialityName")
@Mapping(target = "numPatients", ignore = true)
@Mapping(source = "cityId",
target = "cityName",
qualifiedByName = "cityName")

DoctorDto toDto(Doctor doctor, @Context List cities);

@Named("cityName")
default String getCityName(int cityId, @Context List cities) {
return cities.stream()
.filter(city -> city.getId() == cityId)
.findAny()
.map(City::getName)
.orElse(null);
}

Wstrzykiwanie zależności w metodach mapowania

Prawdopodobnie znajdziesz się w sytuacjach, w których twoje niestandardowe metody mapowania wymagają inny bean (inny program do mapowania, repozytorium, usługa itp.).

W takich sytuacjach będziesz musiał automatycznie przekierować ten bean do swojego programu mapper, więc zobaczmy przykład, jak to zrobić.

W tym przykładzie nasza klasa Pacjent będzie klasą abstrakcyjną:

@Data
public abstract class Patient {
private int id;
private String name;
private int age;
}

Który zawiera dwie implementacje:

public class Man extends Patient {
}public class Woman extends Patient {
}

Jak już widzieliśmy, jest to stan naszego Lekarz jednostka:

@Data
public class Doctor {
private int id;
private String name;
private String phone;
private Speciality speciality;
private Address address;
private List patients;
private int cityId;
}

Jednak nasz PatientDto wymaga odrębnej listy dla każdej konkretnej klasy:

@Data
public class DoctorDto {
private int id;
private String name;
private String contact;
private String specialityName;
private AddressDto address;
private int numPatients;
private String cityName;
private PatientsDto patients;
}

@Data
public class PatientsDto {
private List men = new ArrayList();
private List women = new ArrayList();

public void addMan(ManDto manDto) {
men.add(manDto);
}

public void addWoman(WomanDto womanDto) {
women.add(womanDto);
}
}

Aby zmapować te konkretne klasy, powinniśmy zacząć od kreatyny g dwóch twórców map:

@Mapper(componentModel = "spring")
public interface WomanMapper {
WomanDto toDto(Woman woman);
}@Mapper(componentModel = "spring")
public interface ManMapper {
ManDto toDto(Man man);
}

Ale aby zmapować z Lista pacjentów do PacjenciDto pacjentów potrzebujemy też innego mapowania, który używa nowo utworzonych maperów ( WomanMapper i ManMapper ), które ostatecznie zostaną użyte przez naszego DoctorMapper.

Ponieważ nasz PatientsMapper będzie musimy użyć WomanMapper i ManMapper , zamiast tworzyć interfejs, musimy stworzyć klasę abstrakcyjną:

@Mapper(componentModel = "spring")
public abstract class PatientsMapper {

@Autowired
private ManMapper manMapper;

@Autowired
private WomanMapper womanMapper;

public PatientsDto toDto(List patients) {
PatientsDto patientsDto = new PatientsDto();
for (Patient patient : patients) {
if (patient instanceof Man) {
patientsDto.addMan(
manMapper.toDto((Man) patient));
} else if (patient instanceof Woman) {
patientsDto.addWoman(
womanMapper.toDto((Woman) patient));
}
}
return patientsDto;
}
}

Wreszcie, aby nasz DoctorMapper używał PatientsMapper , musimy dodać pewną konfigurację:

@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR,
uses = {AddressMapper.class, PatientsMapper.class})
public interface DoctorMapper {
...
}

Ponieważ zmienna patient ma tę samą nazwę w klasach Entity i Dto, nie musimy określać niczego innego.

To będzie wynik końcowy wygenerowanych klas:

@Component
public class DoctorMapperImpl implements DoctorMapper {

@Autowired
private AddressMapper addressMapper;
@Autowired
private PatientsMapper patientsMapper;


@Override
public DoctorDto toDto(Doctor doctor, List cities) {
if (doctor == null) {
return null;
}

DoctorDto doctorDto = new DoctorDto();

doctorDto.setNumPatients(
getNumPatients(doctor.getPatients()));
doctorDto.setCityName(
getCityName(doctor.getCityId(), cities));
doctorDto.setSpecialityName(doctorSpecialityName(doctor));
doctorDto.setContact(doctor.getPhone());
doctorDto.setId(doctor.getId());
doctorDto.setName(doctor.getName());
doctorDto.setAddress(
addressMapper.toDto(doctor.getAddress()));
doctorDto.setPatients(
patientsMapper.toDto(doctor.getPatients()));


return doctorDto;
}
...
}@Component
public class ManMapperImpl implements ManMapper {

@Override
public ManDto toDto(Man man) {
if ( man == null ) {
return null;
}

ManDto manDto = new ManDto();

manDto.setId( man.getId() );
manDto.setName( man.getName() );
manDto.setAge( man.getAge() );

return manDto;
}
}@Component
public class WomanMapperImpl implements WomanMapper {

@Override
public WomanDto toDto(Woman woman) {
if ( woman == null ) {
return null;
}

WomanDto womanDto = new WomanDto();

womanDto.setId( woman.getId() );
womanDto.setName( woman.getName() );
womanDto.setAge( woman.getAge() );

return womanDto;
}
}

Aktualizacje

Mapstruct zapewnia również to łatwy sposób obsługi aktualizacji. Jeśli chcemy zaktualizować nasz Doktor podmiot o informacje o DoctorDto , musimy tylko utworzyć:

@Mapping(source = "contact", target = "phone")
@Mapping(source = "specialityName", target = "speciality.name")
void updateEntity(DoctorDto doctorDto,
@MappingTarget Doctor doctor);

Jak widać na wygenerowanej implementacji, zmapuje wszystko zmienne (null lub not null):

@Override
public void updateEntity(DoctorDto doctorDto, Doctor doctor) {
if ( doctorDto == null ) {
return;
}

if ( doctor.getSpeciality() == null ) {
doctor.setSpeciality( new Speciality() );
}
doctorDtoToSpeciality( doctorDto, doctor.getSpeciality() );
doctor.setPhone( doctorDto.getContact() );
doctor.setId( doctorDto.getId() );
doctor.setName( doctorDto.getName() );
if ( doctorDto.getAddress() != null ) {
if ( doctor.getAddress() == null ) {
doctor.setAddress( new Address() );
}
addressDtoToAddress( doctorDto.getAddress(), doctor.getAddress() );
}
else {
doctor.setAddress( null );
}
}

Aktualizacje poprawek

Jak widzieliśmy w poprzednim przykładzie, domyślna metoda aktualizacji będzie mapuj każdą właściwość, nawet jeśli jest pusta.Więc jeśli napotkasz się w sytuacji, w której chcesz tylko zaktualizować łatkę (zaktualizować tylko wartości inne niż null), musisz użyć nullValuePropertyMappingStrategy :

@BeanMapping(nullValuePropertyMappingStrategy = 
NullValuePropertyMappingStrategy.IGNORE)

@Mapping(source = "contact", target = "phone")
@Mapping(source = "specialityName", target = "speciality.name")
void updatePatchEntity(DoctorDto doctorDto,
@MappingTarget Doctor doctor);

Wygenerowana metoda przeprowadzi sprawdzenie wartości null przed zaktualizowaniem wartości:

@Override
public void updatePatchEntity(DoctorDto doctorDto, Doctor doctor) {
if ( doctorDto == null ) {
return;
}

if ( doctorDto.getContact() != null ) {
doctor.setPhone( doctorDto.getContact() );
}
doctor.setId( doctorDto.getId() );
if ( doctorDto.getName() != null ) {
doctor.setName( doctorDto.getName() );
}
if ( doctorDto.getAddress() != null ) {
if ( doctor.getAddress() == null ) {
doctor.setAddress( new Address() );
}
addressDtoToAddress(
doctorDto.getAddress(),
doctor.getAddress() );
}
if ( doctor.getSpeciality() == null ) {
doctor.setSpeciality( new Speciality() );
}
doctorDtoToSpeciality1( doctorDto, doctor.getSpeciality() );
}

Wniosek

Ten artykuł opisaliśmy, jak wykorzystać bibliotekę Mapstruct do znacznego zredukowania naszego standardowego kodu w bezpieczny i elegancki sposób.

Jak widać na przykładach, Mapstruct oferuje szeroki zestaw funkcji i konfiguracji, które pozwalają nam tworzyć z od podstawowych do złożonych maperów w łatwy i szybki sposób.