Duik diep in Mapstruct @ Spring

(Miguel Duque) (14 december 2020)

Foto door Beau Swierstra op Unsplash

Als Java-ontwikkelaar weet u zeker dat het in kaart brengen van POJOs een standaardtaak is bij het ontwikkelen van meerlagige applicaties.
Het handmatig schrijven van deze mappings is niet alleen saai en onaangename taak voor ontwikkelaars, is ook foutgevoelig.

MapStruct is een open-source Java-bibliotheek die mapper class-implementaties genereert tijdens compilatie in een veilige en gemakkelijke manier.

In dit artikel zullen we een voorbeeld volgen van hoe we gebruik kunnen maken van deze krachtige bibliotheek om de hoeveelheid standaardcode die regelmatig met de hand zou worden geschreven aanzienlijk te verminderen.

Index

  • Basistoewijzingen
  • Mapping van andere eigenschapnaam
  • Ervoor zorgen dat elke eigenschap in kaart is gebracht
  • Eigenschap van onderliggende entiteit in kaart brengen
  • De volledige onderliggende entiteit in kaart brengen – een andere mapper gebruiken
  • Mapping met een aangepaste methode
  • @BeforeMapping @AfterMapping
  • Mapping met een extra parameter
  • Injectie van afhankelijkheid op mappingmethoden
  • Updates
  • Patch-updates

Basistoewijzingen

Laten we onze applicatie starten met een basismodel, dat de klasse Doctor bevat. Onze service haalt deze klasse op uit de modellaag en retourneert vervolgens een DoctorDto class.

Model class:

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

Dto class:

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

Om dit te doen, moeten we onze Mapper-interface maken:

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

Aangezien beide klassen dezelfde eigenschapsnamen hebben ( id en naam ), zal mapstruct de toewijzing van beide velden in de gegenereerde klasse bevatten:

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

Door componentModel toe te voegen = “Spring” , de gegenereerde mapper is een Spring bean en kan net als elke andere bean worden opgehaald met de annotatie @Autowired :

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

Andere eigenschapnaam in kaart brengen

Als we een eigenschap phone opnemen in de Doctor class:

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

Moet worden toegewezen aan contact in Dokter Dto :

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

Onze mapper kan het niet automatisch in kaart brengen. Om dit te doen, moeten we een regel voor deze mapping maken:

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

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

Ervoor zorgen dat elke eigenschap in kaart is gebracht

Als we willen om te garanderen dat we niet vergeten een doelproperty in kaart te brengen, kunnen we de optie unmappedTargetPolicy configureren op onze mapper:

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

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

Als we met deze configuratie

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

verwijderen, zal onze code mislukken tijdens het compileren met de fout:

Unmapped target property: “contact”. DoctorDto toDto (Doctor doctor);

Als we om wat voor reden dan ook een doel willen negeren eigenschap, kunnen we toevoegen:

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

Evenzo kunnen we ook garanderen dat alle bron eigenschappen worden in kaart gebracht door de unmappedSourcePolicy optie te configureren.

Eigenschap onderliggende entiteit in kaart brengen

Meestal is de klasse die we nodig hebben to map bevat onderliggende objecten . Bijvoorbeeld:

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

En in onze Dto, in plaats van de volledige specialiteit, hebben we alleen de naam nodig:

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

Deze situatie is ook eenvoudig met mapstruct :

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

De volledige onderliggende entiteit in kaart brengen – een andere mapper

Zoals eerder heeft onze Doctor klasse een onderliggend object Adres :

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

Maar in dit geval willen we het toewijzen aan een nieuwe object in onze DoctorDto klasse:

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

Aan voer de toewijzing uit tussen Address en AddressDto-klasse, we zouden een andere mapper-interface moeten maken:

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

Vervolgens, in onze DoctorMapper we moeten ervoor zorgen dat deze mapper wordt gebruikt bij het toewijzen van de Doctor naar DoctorDto .Dit kan gedaan worden met de “ uses” optie op de mapper configuratie:

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

We zullen zien dat onze DoctorMapperImpl zal Autowire gebruiken en onze 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;
}

...
}

Mapping met een aangepaste methode

Laten we nu een lijst met patiënten toevoegen aan onze Doctor class:

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

Op onze DoctorDto we willen alleen het aantal patiënten:

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

Deze mapping vereist 2 dingen:

  • Een aangepaste methode met de @Named annotatie
  • De gekwalificeerdeByName configuratie op de Mapping annotatie
@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

Het vorige voorbeeld (mapping van Lijst patiënten tot int numPatients ) kan ook worden gedaan met @BeforeMapping en @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());
}
}

Deze methoden worden aangeroepen aan het begin en einde van onze gegenereerde mapping-methode:

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

Mapping met een extra parameter

Laten we eens kijken hoe we om moeten gaan met een situatie waarin uw mapper een extra parameter nodig heeft, naast uw Entiteit.

In dit geval kan uw arts-klas alleen de plaats-id krijgen:

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

Maar moet deze worden toegewezen aan de plaatsnaam:

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

Uw service haalt de lijst met steden op en geeft deze door aan de mapper

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

In onze mapper moeten we:

  • de aanvullende parameter (lijst met steden) markeren met @Context annotatie
  • een aangepaste methode om onze mapping af te handelen
  • vraag de contextparameter (lijst met steden) op onze aangepaste ma pping-methode
@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);
}

Injectie van afhankelijkheid op toewijzingsmethoden

U zult waarschijnlijk in situaties terechtkomen waarin uw aangepaste toewijzingsmethoden vereisen een andere bean (een andere mapper, een repository, een service, enz.).

In deze situaties moet je die bean naar je mapper Autowire, dus laten we eens kijken hoe je het moet doen.

In dit voorbeeld zal onze Patient klasse een abstracte klasse zijn:

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

Die twee implementaties bevat:

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

Zoals we eerder zagen, is dit de toestand van onze Doctor entiteit:

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

Onze PatientDto vereist een aparte lijst voor elke concrete klasse:

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

En

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

Dus om deze concrete klassen in kaart te brengen, moeten we beginnen met creatin g twee mappers:

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

Maar om toe te wijzen vanaf Lijst patiënten naar PatientsDnaar patiënten we hebben ook een andere mapper nodig die de nieuw gemaakte mappers gebruikt ( WomanMapper en ManMapper ), die uiteindelijk gebruikt zullen worden door onze DoctorMapper.

Aangezien onze PatientsMapper moeten de WomanMapper en ManMapper gebruiken, in plaats van een interface te maken, moeten we een abstracte klasse maken:

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

Ten slotte, om onze DoctorMapper de PatientsMapper te laten gebruiken, moeten we een configuratie toevoegen:

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

Aangezien de variabele patienten dezelfde naam heeft in de Entity- en Dto-klassen, hoeven we verder niets te specificeren.

Dit zal het eindresultaat zijn van de gegenereerde klassen:

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

Updates

Mapstruct biedt ook is een gemakkelijke manier om updates af te handelen. Als we onze Doctor entiteit willen updaten met de informatie over DoctorDto , we hoeven alleen maar te creëren:

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

Zoals we kunnen zien bij de gegenereerde implementatie, zal het alle variabelen (null of niet 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 );
}
}

Patch-updates

Zoals we in het vorige voorbeeld hebben gezien, zal de standaard updatemethode breng elke eigenschap in kaart, zelfs als deze null is.Dus als je jezelf in een situatie tegenkomt waarin je gewoon een patch-update wilt uitvoeren (alleen de niet-null-waarden bijwerken), moet je de nullValuePropertyMappingStrategy gebruiken:

@BeanMapping(nullValuePropertyMappingStrategy = 
NullValuePropertyMappingStrategy.IGNORE)

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

De gegenereerde methode zal null-controles uitvoeren voordat de waarden worden bijgewerkt:

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

Conclusie

Dit artikel beschreven hoe u gebruik kunt maken van de Mapstruct-bibliotheek om onze standaardcode op een veilige en elegante manier aanzienlijk te verminderen.

Zoals te zien is in de voorbeelden, biedt Mapstruct een uitgebreide reeks functionaliteiten en configuraties waarmee we kunnen creëren vanuit eenvoudige tot complexe mappers op een gemakkelijke en snelle manier.