Deep dive into Mapstruct @ Spring

(Miguel Duque) (14 dicembre 2020)

Foto di Beau Swierstra su Unsplash

Essendo uno sviluppatore Java, sai sicuramente che la mappatura dei POJO è unattività standard quando si sviluppano applicazioni multistrato.
Scrivere queste mappature manualmente, oltre a essere noiosa e compito spiacevole per gli sviluppatori, è anche soggetto a errori.

MapStruct è una libreria Java open source che genera implementazioni di classi mapper durante la compilazione in un modo semplice e sicuro.

Durante questo articolo, seguiremo un esempio di come sfruttare questa potente libreria per ridurre notevolmente la quantità di codice boilerplate che verrebbe regolarmente scritto a mano.

Indice

  • Mappature di base
  • Mappatura di nomi di proprietà diversi
  • Assicurarsi che ogni proprietà sia mappata
  • Mappatura della proprietà dellentità figlia
  • Mappatura dellintera entità figlia – Utilizzo di un altro mappatore
  • Mappatura con un metodo personalizzato
  • @BeforeMapping @AfterMapping
  • Mappatura con un parametro aggiuntivo
  • Inserimento di dipendenze sui metodi di mappatura
  • Aggiornamenti
  • Aggiornamenti patch

Mappature di base

Iniziamo la nostra applicazione con un modello base, che contiene la classe Doctor. Il nostro servizio recupererà questa classe dal livello del modello e poi restituirà un DoctorDto classe.

Classe modello:

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

Classe Dto:

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

Per fare ciò, dovremmo creare la nostra interfaccia Mapper:

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

Poiché entrambe le classi hanno gli stessi nomi di proprietà ( id e nome ), mapstruct includerà la mappatura di entrambi i campi nella classe generata:

@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;
}
}

Aggiungendo componentModel = “Spring” , il mappatore generato sarà un bean Spring e può essere recuperato con lannotazione @Autowired come qualsiasi altro bean:

@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);
}
}

Mappatura di nomi di proprietà diversi

Se includiamo una proprietà telefono nel Doctor class:

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

da mappare a contatto in Dottore Dto :

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

Il nostro mappatore non sarà in grado di mapparlo automaticamente. Per fare ciò, dovremo creare una regola per questa mappatura:

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

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

Assicurandoci che ogni proprietà sia mappata

Se vogliamo per garantire che non ci dimentichiamo di mappare alcuna proprietà di destinazione, possiamo configurare lopzione unmappedTargetPolicy sul nostro mappatore:

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

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

Con questa configurazione, se rimuoviamo

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

il nostro codice fallirà durante la compilazione con lerrore:

Proprietà target non mappata: “contact”. DoctorDto toDto (Doctor doctor);

Se per qualche motivo vogliamo ignorare un target , possiamo aggiungere:

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

Allo stesso modo, possiamo anche garantire che tutte le sorgenti le proprietà sono mappate configurando lopzione unmappedSourcePolicy .

Mappatura della proprietà dellentità figlio

La maggior parte delle volte, la classe di cui abbiamo bisogno da mappare contiene oggetti figlio . Ad esempio:

@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;
}

E nel nostro Dto, invece della specialità completa, abbiamo solo bisogno del suo nome:

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

Anche questa situazione è semplice con mapstruct :

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

Mappatura dellintera entità figlia – Usarne unaltra mapper

Come prima, la nostra classe Doctor ha un oggetto figlio Indirizzo :

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

Ma in questo caso, vogliamo mapparlo a un nuovo oggetto nella nostra classe DoctorDto :

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

A eseguire la mappatura tra la classe Address e AddressDto, dovremmo creare uninterfaccia mapper diversa:

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

Quindi, nel nostro DoctorMapper dobbiamo assicurarci che questo mappatore venga utilizzato durante la mappatura del Doctor a DoctorDto .Questo può essere fatto con lopzione “ uses” nella configurazione del mapper:

@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);
}

Vedremo che il nostro DoctorMapperImpl eseguirà Autowire e utilizzerà il nostro 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;
}

...
}

Mappatura con un metodo personalizzato

Aggiungiamo ora un elenco di pazienti al nostro Doctor class:

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

Tuttavia, sul nostro DoctorDto vogliamo solo il numero di pazienti:

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

Questa mappatura richiede 2 cose:

  • Un metodo personalizzato con lannotazione @Named
  • La configurazione tifiedByName sullannotazione 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

Lesempio precedente (mappatura da Elenco pazienti a int numPatients ) può essere eseguito anche con @BeforeMapping e @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());
}
}

Questi metodi verranno chiamati allinizio e alla fine del nostro metodo di mappatura generato:

@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;
}

Mappatura con un parametro aggiuntivo

Controlliamo come gestire una situazione in cui il tuo mappatore ha bisogno di ricevere un parametro aggiuntivo, oltre alla tua Entità.

In questo caso, la classe del medico può ottenere solo lID della città:

@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;
}

ma deve mapparlo al nome della città:

@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;
}

Il tuo servizio recupererà lelenco delle città e le passerà al mappatore

@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);
}
}

Nel nostro mappatore, dobbiamo:

  • contrassegnare il parametro aggiuntivo (elenco di città) con @Context annotazione
  • creare un metodo per gestire la nostra mappatura
  • richiedere il parametro di contesto (elenco di città) sulla nostra mappatura personalizzata metodo 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);
}

Inserimento di dipendenze sui metodi di mappatura

Probabilmente ti troverai in situazioni in cui i tuoi metodi di mappatura personalizzati richiedono un altro bean (un altro mappatore, un repository, un servizio, ecc.).

In queste situazioni, dovrai collegare automaticamente quel bean al tuo mappatore, quindi vediamo un esempio di come farlo.

In questo esempio, la nostra classe Patient sarà una classe astratta:

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

Che contiene due implementazioni:

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

Come abbiamo visto in precedenza, questo è lo stato del nostro Doctor entity:

@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;
}

Tuttavia, il nostro PatientDto richiede un elenco distinto per ogni classe concreta:

@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;
}

E

@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);
}
}

Quindi, per mappare queste classi concrete, dovremmo iniziare creando g due mappatori:

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

Ma per mappare da Elenco pazienti a PatientsD a pazienti abbiamo bisogno anche di un mapping diverso che utilizzi i mapping appena creati ( WomanMapper e ManMapper ), che finiranno per essere utilizzati dal nostro DoctorMapper.

poiché il nostro PatientsMapper lo farà è necessario utilizzare WomanMapper e ManMapper , invece di creare uninterfaccia, dobbiamo creare una classe astratta:

@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;
}
}

Infine, per fare in modo che il nostro DoctorMapper utilizzi PatientsMapper , dobbiamo aggiungere alcune configurazioni:

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

Poiché la variabile pazienti ha lo stesso nome nelle classi Entity e Dto, non è necessario specificare nientaltro.

Questo sarà il risultato finale delle classi generate:

@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;
}
}

Aggiornamenti

Mapstruct fornisce anche è un modo semplice per gestire gli aggiornamenti. Se vogliamo aggiornare la nostra entità Doctor con le informazioni su DoctorDto , dobbiamo solo creare:

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

Come possiamo vedere sullimplementazione generata, mapperà tutti variabili (null o non 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 );
}
}

Aggiornamenti patch

Come abbiamo visto nellesempio precedente, il metodo di aggiornamento predefinito mappare ogni proprietà, anche se è nulla.Quindi, se ti trovi in una situazione in cui desideri solo eseguire un aggiornamento della patch (aggiorna solo i valori non nulli), devi utilizzare nullValuePropertyMappingStrategy :

@BeanMapping(nullValuePropertyMappingStrategy = 
NullValuePropertyMappingStrategy.IGNORE)

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

Il metodo generato eseguirà controlli nulli prima di aggiornare i valori:

@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() );
}

Conclusione

Questo articolo ha descritto come sfruttare la libreria Mapstruct per ridurre in modo significativo il nostro codice boilerplate in modo sicuro ed elegante.

Come visto negli esempi, Mapstruct offre una vasta serie di funzionalità e configurazioni che ci permettono di creare da mappatori da base a complessi in modo facile e veloce.