Plongez-vous dans Mapstruct @ Spring

(Miguel Duque) (14 décembre 2020)

Photo de Beau Swierstra sur Unsplash

En tant que développeur Java, vous savez certainement que le mappage des POJO est une tâche standard lors du développement dapplications multicouches.
Écrire ces mappages manuellement, en plus dêtre ennuyeux et tâche désagréable pour les développeurs, est également sujette aux erreurs.

MapStruct est une bibliothèque Java open-source qui génère des implémentations de classe de mappeur lors de la compilation dans un moyen sûr et facile.

Au cours de cet article, nous allons suivre un exemple de la façon de tirer parti de cette puissante bibliothèque pour réduire considérablement la quantité de code standard qui serait régulièrement écrit à la main.

Index

  • Mappages de base
  • Mappage dun nom de propriété différent
  • Sassurer que chaque propriété est mappée
  • Mappage de la propriété de lentité enfant
  • Mappage de lentité enfant complète – Utilisation dun autre mappeur
  • Mappage avec une méthode personnalisée
  • @BeforeMapping @AfterMapping
  • Mappage avec un paramètre supplémentaire
  • Injection de dépendances sur les méthodes de mappage
  • Mises à jour
  • Mises à jour des correctifs

Mappages de base

Commençons notre application avec un modèle de base, qui contient la classe Doctor. Notre service récupérera cette classe à partir de la couche modèle, puis renverra un DoctorDto class.

Classe de modèle:

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

Classe Dto:

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

Pour ce faire, nous devons créer notre interface Mapper:

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

Comme les deux classes ont les mêmes noms de propriété ( id et nom ), mapstruct inclura le mappage des deux champs dans la classe générée:

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

En ajoutant componentModel = « Spring » , le mappeur généré sera un bean Spring et peut être récupéré avec lannotation @Autowired comme nimporte quel autre 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);
}
}

Mappage dun nom de propriété différent

Si nous incluons une propriété phone dans le Doctor classe:

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

À mapper avec contact dans Docteur Dto :

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

Notre mappeur « ne pourra pas le mapper automatiquement. Pour ce faire, nous devrons créer une règle pour ce mappage:

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

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

Sassurer que chaque propriété est mappée

Si nous le voulons pour garantir que nous noublions pas de mapper une propriété cible, nous pouvons configurer loption unmappedTargetPolicy sur notre mappeur:

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

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

Avec cette configuration, si nous supprimons

@Mapping (source = « phone », target = « contact »)

notre code échouera lors de la compilation avec lerreur:

Propriété cible non mappée: « contact ». DoctorDto toDto (Doctor doctor);

Si pour une raison quelconque, nous voulons ignorer une cible propriété, nous pouvons ajouter:

@Mapping (target = « contact », ignore = true)

De même, nous pouvons également garantir que toutes les sources les propriétés sont mappées en configurant loption unmappedSourcePolicy .

Mappage de la propriété de lentité enfant

La plupart du temps, la classe dont nous avons besoin mapper contient des objets enfants . Par exemple:

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

Et dans notre Dto, au lieu de la spécialité complète, nous avons juste besoin de son nom:

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

Cette situation est également simple avec mapstruct :

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

Mappage de lentité enfant complète – Utilisation dun autre mapper

Comme précédemment, notre classe Doctor a un objet enfant Adresse :

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

Mais dans ce cas, nous voulons le mapper à un nouveau objet de notre classe DoctorDto :

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

À effectuer le mappage entre la classe Address et AddressDto, nous devons créer une interface de mappage différente:

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

Ensuite, dans notre DoctorMapper nous devons nous assurer que ce mappeur est utilisé lors du mappage du Doctor à DoctorDto .Cela peut être fait avec loption «  uses » sur la configuration du mappeur:

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

Nous verrons que notre DoctorMapperImpl utilisera automatiquement notre 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;
}

...
}

Cartographie avec une méthode personnalisée

Ajoutons maintenant une liste de patients à notre Docteur classe:

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

Cependant, sur notre DoctorDto nous voulons uniquement le nombre de patients:

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

Cette cartographie nécessite 2 choses:

  • Une méthode personnalisée avec lannotation @Named
  • La configuration qualifiéByName sur lannotation 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

Lexemple précédent (mappage depuis Lister patients à int numPatients ) peut également être fait avec @BeforeMapping et @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());
}
}

Ces méthodes seront appelées au début et à la fin de notre méthode de cartographie générée:

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

Mappage avec un paramètre supplémentaire

Voyons comment gérer une situation où votre mappeur a besoin de recevoir un paramètre supplémentaire, en plus de votre entité.

Dans ce cas, votre classe Doctor ne peut obtenir que lidentifiant de la ville:

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

Mais doit le mapper au nom de la ville:

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

Votre service récupérera la liste des villes et les transmettra au mappeur

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

Dans notre mappeur, nous devons:

  • marquer le paramètre supplémentaire (liste des villes) avec une annotation @Context
  • créer un méthode pour gérer notre cartographie
  • demander le paramètre de contexte (liste des villes) sur notre ma personnalisé méthode 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);
}

Injection de dépendances sur les méthodes de mappage

Vous vous retrouverez probablement dans des situations où vos méthodes de mappage personnalisées nécessitent un autre bean (un autre mappeur, un référentiel, un service, etc.).

Dans ces situations, vous aurez besoin de transférer automatiquement ce bean vers votre mappeur, alors voyons un exemple de la façon de le faire.

Dans cet exemple, notre classe Patient sera une classe abstraite:

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

Qui contient deux implémentations:

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

Comme nous lavons vu précédemment, cest létat de notre Docteur entité:

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

Cependant, notre PatientDto nécessite une liste distincte pour chaque classe concrète:

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

Et

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

Donc, pour mapper ces classes concrètes, nous devrions commencer par creatin g deux mappeurs:

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

Mais pour mapper à partir de Lister patients à PatientsDto patients nous avons également besoin dun autre mappeur qui utilise les mappeurs nouvellement créés ( WomanMapper et ManMapper ), qui finira par être utilisé par notre DoctorMapper.

Puisque notre PatientsMapper besoin dutiliser le WomanMapper et le ManMapper , au lieu de créer une interface, nous devons créer une classe abstraite:

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

Enfin, pour que notre DoctorMapper utilise le PatientsMapper , nous devons ajouter une configuration:

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

La variable patients ayant le même nom dans les classes Entity et Dto, nous navons pas besoin de spécifier autre chose.

Ce sera le résultat final des classes générées:

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

Mises à jour

Mapstruct fournit également est un moyen simple de gérer les mises à jour. Si nous voulons mettre à jour notre entité Doctor avec les informations sur DoctorDto , il suffit de créer:

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

Comme nous pouvons le voir sur limplémentation générée, cela mappera tout variables (nulles ou non nulles):

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

Mises à jour des correctifs

Comme nous lavons vu dans lexemple précédent, la méthode de mise à jour par défaut mappez chaque propriété, même si elle est nulle.Donc, si vous vous trouvez dans une situation où vous souhaitez simplement effectuer une mise à jour du correctif (ne mettre à jour que les valeurs non nulles), vous devez utiliser la nullValuePropertyMappingStrategy :

@BeanMapping(nullValuePropertyMappingStrategy = 
NullValuePropertyMappingStrategy.IGNORE)

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

La méthode générée effectuera des vérifications nulles avant de mettre à jour les valeurs:

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

Conclusion

Cet article décrit comment tirer parti de la bibliothèque Mapstruct pour réduire considérablement notre code standard de manière sûre et élégante.

Comme le montrent les exemples, Mapstruct offre un vaste ensemble de fonctionnalités et de configurations qui nous permettent de créer à partir de mappeurs de base à complexes dune manière simple et rapide.