Scufundare profundă în Mapstruct @ Spring

(Miguel Duque) (14 dec. 2020)

Fotografie de Beau Swierstra pe Unsplash

Fiind un dezvoltator Java, știți cu siguranță că maparea POJO-urilor este o sarcină standard atunci când dezvoltați aplicații multistrat.
Scrierea manuală a acestor mapări, pe lângă faptul că este o plictisitoare și sarcină neplăcută pentru dezvoltatori este, de asemenea, predispusă la erori.

MapStruct este o bibliotecă Java cu sursă deschisă care generează implementări de clasă mapper în timpul compilării o modalitate sigură și ușoară.

În acest articol, vom urmări un exemplu despre cum să profitați de această bibliotecă puternică pentru a reduce considerabil cantitatea de cod a cazanului care ar fi scris în mod regulat manual.

Index

  • Mappings de bază
  • Maparea diferitelor nume de proprietăți
  • Asigurarea faptului că fiecare proprietate este mapată
  • Cartarea proprietății entității copil
  • Cartarea întregii entități copil – Utilizarea unui alt mapator
  • Cartografierea cu o metodă personalizată
  • @BeforeMapping @AfterMapping
  • Cartografiere cu un parametru suplimentar
  • Injecție de dependență pentru metodele de cartografiere
  • Actualizări
  • Actualizări de patch-uri

Cartografieri de bază

Să începem aplicația cu un model de bază, care conține clasa Doctor. Serviciul nostru va prelua această clasă din stratul model și apoi va returna un DoctorDto clasă.

Clasa model:

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

Clasa Dto:

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

Pentru a face acest lucru, ar trebui să creăm interfața Mapper:

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

Deoarece ambele clase au aceleași nume de proprietăți ( id și nume ), mapstruct va include maparea ambelor câmpuri din clasa generată:

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

Prin adăugarea componentModel = „Primăvară” , cartograful generat va fi un bob de primăvară și poate fi recuperat cu adnotarea @Autowired ca orice alt bob:

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

Cartografierea diferitelor nume de proprietăți

Dacă includem o proprietate telefon în Doctor class:

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

Pentru a fi mapat la contact în Doctore Dto :

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

Cartograful nostru nu va putea să îl mapeze automat. Pentru a face acest lucru, va trebui să creăm o regulă pentru această mapare:

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

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

Asigurându-ne că fiecare proprietate este mapată

Dacă vrem pentru a garanta că nu uităm să mapăm orice proprietate țintă, putem configura opțiunea unmappedTargetPolicy de pe cartograf:

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

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

Cu această configurație, dacă eliminăm

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

codul nostru va eșua în timpul compilării cu eroarea:

Proprietate țintă nemapată: „contact”. DoctorDto toDto (Doctor doctor);

Dacă, din anumite motive, dorim să ignorăm o țintă proprietate, putem adăuga:

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

În mod similar, putem garanta că toate sursele proprietățile sunt mapate configurând opțiunea unmappedSourcePolicy .

Cartarea proprietății entității copil

De cele mai multe ori, clasa de care avem nevoie harta conține obiecte copil . De exemplu:

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

Și în Dto-ul nostru, în loc de specialitatea completă, avem nevoie doar de numele său:

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

Această situație este simplă și cu mapstruct :

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

Cartografierea întregii entități copil – Utilizarea altei mapper

La fel ca înainte, clasa noastră Doctor are un obiect copil Adresă :

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

Dar, în acest caz, dorim să o mapăm la o nouă obiect din clasa noastră DoctorDto :

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

To efectuați maparea între clasa Address și AddressDto, ar trebui să creăm o interfață de mapare diferită:

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

Apoi, în DoctorMapper ar trebui să ne asigurăm că acest mapator este utilizat la maparea Doctor la DoctorDto .Acest lucru se poate face cu opțiunea „ utilizează” din configurația maperului:

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

Vom vedea că DoctorMapperImpl va conecta automat și va folosi 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;
}

...
}

Cartografierea cu o metodă personalizată

Să adăugăm acum o listă de pacienți la Doctor clasa:

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

Cu toate acestea, pe DoctorDto dorim doar numărul de pacienți:

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

Această mapare necesită 2 lucruri:

  • O metodă personalizată cu adnotarea @Named
  • Configurația qualificationByName din adnotarea 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

Exemplul anterior (mapare din Lista pacienți la int numPatients ) se poate face și cu @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());
}
}

Aceste metode vor fi apelate la începutul și la sfârșitul metodei noastre de mapare generate:

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

Cartografierea cu un parametru suplimentar

Să verificăm cum să gestionăm o situație în care cartograful dvs. trebuie să primească un parametru suplimentar, pe lângă Entitatea dvs.

În acest caz, clasa de doctor poate obține doar ID-ul orașului:

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

Dar trebuie să o mapeze la numele orașului:

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

Serviciul dvs. va prelua lista orașelor și le va transmite cartografului

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

În cartograf, trebuie:

  • să marcăm parametrul suplimentar (lista orașelor) cu adnotare @Context
  • să creăm un parametru personalizat metoda de gestionare a cartografierii noastre
  • solicitați parametrul contextului (lista orașelor) pe ma personalizat metoda 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);
}

Injectarea dependenței de metodele de mapare

Probabil că vă veți găsi în situațiile în care metodele dvs. de mapare personalizate necesită un alt bean (un alt maper, un depozit, un serviciu etc.).

În aceste situații, va trebui să conectați automat acel bean la maperul dvs., așa că să vedem un exemplu despre cum să o faceți.

În acest exemplu, clasa noastră Pacient va fi o clasă abstractă:

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

Care conține două implementări:

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

După cum am văzut anterior, aceasta este starea Doctor entitate:

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

Cu toate acestea, PatientDto necesită o listă distinctă pentru fiecare clasă concretă:

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

Și

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

Deci, pentru a mapa aceste clase concrete, ar trebui să începem cu creatin g două mapere:

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

Dar să mapezi de la Lista pacienți la PacientsDto patients avem nevoie, de asemenea, de un alt cartograf care utilizează cartografii nou creați ( WomanMapper și ManMapper ), care vor ajunge să fie utilizate de DoctorMapper.

Deoarece PatientsMapper trebuie să folosim WomanMapper și ManMapper , în loc să creăm o interfață, trebuie să creăm o clasă Abstract:

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

În cele din urmă, pentru ca DoctorMapper să utilizeze PatientsMapper , trebuie să adăugăm o configurație:

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

Deoarece variabila pacienți are același nume în clasele Entity și Dto, nu trebuie să specificăm altceva.

Acesta va fi rezultatul final din clasele 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;
}
}

Actualizările

Mapstruct oferă, de asemenea, este o modalitate ușoară de a gestiona actualizările. Dacă dorim să ne actualizăm entitatea Doctor cu informațiile despre DoctorDto , trebuie doar să creăm:

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

După cum putem vedea în implementarea generată, va mapa toate variabile (nul sau nu nul):

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

Actualizări patch-uri

După cum am văzut în exemplul anterior, metoda implicită de actualizare va fi mapează fiecare proprietate, chiar dacă este nulă.Deci, dacă vă întâlniți într-o situație în care doriți doar să efectuați o actualizare a patch-ului (actualizați doar valorile care nu sunt nule), trebuie să utilizați nullValuePropertyMappingStrategy :

@BeanMapping(nullValuePropertyMappingStrategy = 
NullValuePropertyMappingStrategy.IGNORE)

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

Metoda generată va efectua verificări nule înainte de actualizarea valorilor:

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

Concluzie

Acest articol a descris cum să profităm de biblioteca Mapstruct pentru a reduce semnificativ codul nostru de cazan într-un mod sigur și elegant.

După cum se vede în exemple, Mapstruct oferă un set vast de funcționalități și configurații care ne permite să creăm din de bază pentru cartografii complexi într-un mod ușor și rapid.