Djupdykning i Mapstruct @ Spring

(Miguel Duque) (14 dec 2020)

Foto av Beau Swierstra på Unsplash

Att vara Java-utvecklare vet du definitivt att kartläggning av POJOs är en standarduppgift när du utvecklar flerskiktade applikationer.
Skriva dessa mappningar manuellt, förutom att vara tråkiga och obehaglig uppgift för utvecklare, är också felbenägen.

MapStruct är ett Java-bibliotek med öppen källkod som genererar mappklassimplementeringar under kompilering i ett säkert och enkelt sätt.

Under den här artikeln följer vi ett exempel på hur man kan dra nytta av detta kraftfulla bibliotek för att kraftigt minska mängden pannkodskod som regelbundet skulle skrivas för hand.

Index

  • Grundläggande mappningar
  • Kartläggning av olika egenskaper Namn
  • Se till att varje egendom är mappad
  • Kartläggning av underordnad egendom
  • Kartläggning av hela underordnad enhet – Använd en annan mappare
  • Kartläggning med en anpassad metod
  • @BeforeMapping @AfterMapping
  • Kartläggning med en ytterligare parameter
  • Beroende på kartläggningsmetoder
  • Uppdateringar
  • Uppdateringar för korrigering

Grundläggande kartläggningar

Låt oss börja vår ansökan med en basmodell som innehåller klassen Läkare. Vår tjänst hämtar den här klassen från modellskiktet och returnerar sedan en DoctorDto klass.

Modellklass:

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

Dto-klass:

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

För att göra detta bör vi skapa vårt mappargränssnitt:

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

Eftersom båda klasserna har samma egendomsnamn ( id och namn ) kommer kartstrukturen att inkludera kartläggning av båda fälten i den genererade klassen:

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

Genom att lägga till componentModel = “Vår” , den genererade mapparen kommer att vara en fjäderböna och kan hämtas med @Autowired -anteckningen som alla andra bönor:

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

Kartläggning av olika egendomsnamn

Om vi ​​inkluderar en egenskap telefon i Läkare klass:

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

Att mappas till kontakt i Läkare Dto :

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

Vår mappare kommer inte att kunna mappa den automatiskt. För att göra detta måste vi skapa en regel för den här kartläggningen:

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

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

Se till att varje egendom är kartlagd

Om vi ​​vill för att garantera att vi inte glömmer att mappa någon målegenskap kan vi konfigurera alternativet unmappedTargetPolicy på vår mapper:

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

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

Om vi ​​tar bort den här konfigurationen

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

kommer vår kod att misslyckas under kompilering med felet:

Omappad målegenskap: “kontakt”. DoctorDto toDto (Läkare);

Om vi ​​av någon anledning vill ignorera ett mål egenskap kan vi lägga till:

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

På samma sätt kan vi också garantera att alla källor egenskaper mappas genom att konfigurera alternativet unmappedSourcePolicy .

Kartläggning av underordnad egenskap

För det mesta, den klass som vi behöver till kartan innehåller underordnade objekt . Till exempel:

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

Och i vårt Dto, i stället för hela specialiteten, behöver vi bara dess namn:

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

Denna situation är också enkel med mapstruct :

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

Kartläggning av hela barnenheten – Använd en annan mapper

Som tidigare har vår Läkare klass ett underobjekt Adress :

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

Men i det här fallet vill vi mappa den till en ny objekt i vårt DoctorDto klass:

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

Till utföra kartläggningen mellan Adress och AddressDto-klassen, vi bör skapa ett annat mappargränssnitt:

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

Sedan, i vår DoctorMapper vi bör se till att den här mapparen används vid kartläggning av Läkare till DoctorDto .Detta kan göras med alternativet ” använder” i mapparkonfigurationen:

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

Vi ser att vårt DoctorMapperImpl kommer att autowire och använda vår 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;
}

...
}

Kartläggning med en anpassad metod

Låt oss nu lägga till en lista över patienter till vår Läkare klass:

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

På vår DoctorDto vi vill bara ha antalet patienter:

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

Denna kartläggning kräver 2 saker:

  • En anpassad metod med @ Namnet -anteckningen
  • Konfigurationen kvalificeradByNamn på Mappningsannotationen
@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

Föregående exempel (mappning från Lista patienter till int numPatients ) kan också göras med @BeforeMapping och @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());
}
}

Dessa metoder kommer att anropas i början och slutet av vår genererade kartläggningsmetod:

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

Kartläggning med en ytterligare parameter

Låt oss kontrollera hur du hanterar en situation där mapparen behöver ta emot en ytterligare parameter, förutom din enhet.

I det här fallet kan din läkarklass bara få stads-id:

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

Men måste mappa den till stadsnamnet:

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

Din tjänst hämtar listan över städer och skickar dem till mapparen

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

I vår mapper måste vi:

  • markera den ytterligare parametern (lista över städer) med @Context kommentar
  • skapa en anpassad metod för att hantera vår kartläggning
  • begär kontextparametern (lista över städer) på vår anpassade ma pping-metod
@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);
}

Beroende av beroende på kartläggningsmetoder

Du kommer troligen att befinna dig i situationer där dina anpassade kartläggningsmetoder kräver en annan böna (en annan mapper, ett förråd, en tjänst osv.).

I dessa situationer måste du autowire den bönan till din mapper, så låt oss se ett exempel på hur du gör det.

I det här exemplet blir vår Patient -klassen en abstrakt klass:

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

Som innehåller två implementeringar:

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

Som vi tidigare såg är detta läget för vår Läkare enhet:

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

Vår PatientDto kräver en distinkt lista för varje konkret klass:

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

Och

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

Så för att kartlägga dessa konkreta klasser bör vi börja med creatin g två kartläggare:

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

Men att kartlägga från Lista patienter till Patienter till patienter vi behöver också en annan mappare som använder de nyligen skapade mapparna ( WomanMapper och ManMapper ), som slutligen kommer att användas av vår DoctorMapper .

Eftersom vår PatientsMapper kommer att måste använda WomanMapper och ManMapper istället för att skapa ett gränssnitt måste vi skapa en abstraktklass:

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

Slutligen, för att få vår DoctorMapper att använda PatientsMapper måste vi lägga till lite konfiguration:

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

Eftersom variabeln patienter har samma namn på klasserna Enhet och Dto behöver vi inte ange något annat.

Detta blir slutresultatet av de genererade klasserna:

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

Uppdateringar

Mapstruct ger också är ett enkelt sätt att hantera uppdateringar. Om vi ​​vill uppdatera vår Läkare enhet med informationen på DoctorDto , vi behöver bara skapa:

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

Som vi kan se på den genererade implementeringen kommer den att kartlägga alla variabler (null eller inte 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

Som vi har sett i föregående exempel kommer standarduppdateringsmetoden att mappa varje fastighet, även om den är noll.Så om du stöter på dig själv i en situation där du bara vill utföra en korrigeringsuppdatering (endast uppdatera värdena inte null), måste du använda nullValuePropertyMappingStrategy :

@BeanMapping(nullValuePropertyMappingStrategy = 
NullValuePropertyMappingStrategy.IGNORE)

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

Den genererade metoden utför nollkontroller innan värdena uppdateras:

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

Slutsats

Denna artikel beskrev hur man utnyttjar Mapstruct-biblioteket för att avsevärt minska vår pannkodskod på ett säkert och elegant sätt.

Som framgår av exemplen erbjuder Mapstruct en stor uppsättning funktioner och konfigurationer som gör att vi kan skapa från grundläggande till komplexa kartläggare på ett enkelt och snabbt sätt.