Profundización en Mapstruct @ Spring

(Miguel Duque) (14 de diciembre de 2020)

Foto de Beau Swierstra en Unsplash

Como desarrollador de Java, definitivamente sabe que el mapeo de POJOs es una tarea estándar al desarrollar aplicaciones multicapa.
Escribir estos mapeos manualmente, además de ser aburrido y tarea desagradable para los desarrolladores, también es propensa a errores.

MapStruct es una biblioteca Java de código abierto que genera implementaciones de clases de mapeadores durante la compilación en de una manera segura y fácil.

Durante este artículo, seguiremos un ejemplo de cómo aprovechar esta poderosa biblioteca para reducir en gran medida la cantidad de código repetitivo que se escribiría regularmente a mano.

Índice

  • Mapeos básicos
  • Mapeo de diferentes nombres de propiedad
  • Asegurarse de que todas las propiedades estén asignadas
  • Asignación de la propiedad de la entidad secundaria
  • Asignación de la entidad secundaria completa: utilizando otro asignador
  • Asignación con un método personalizado
  • @BeforeMapping @AfterMapping
  • Mapeo con un parámetro adicional
  • Inyección de dependencia en métodos de mapeo
  • Actualizaciones
  • Actualizaciones de parches

Mapeos básicos

Comencemos nuestra aplicación con un modelo básico, que contiene la clase Doctor. Nuestro servicio recuperará esta clase de la capa del modelo y luego devolverá un DoctorDto clase.

Clase de modelo:

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

Dto clase:

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

Para hacer esto, debemos crear nuestra interfaz Mapper:

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

Como ambas clases tienen los mismos nombres de propiedad ( id y name ), mapstruct incluirá el mapeo de ambos campos en la clase generada:

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

Al agregar componentModel = “Spring” , el asignador generado será un bean Spring y se puede recuperar con la anotación @Autowired como cualquier otro 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);
}
}

Asignación de nombres de propiedad diferentes

Si incluimos una propiedad teléfono en el Doctor clase:

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

Para asignar a contacto en Doctor Dto :

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

Nuestro mapeador no podrá mapearlo automáticamente. Para hacer esto, necesitaremos crear una regla para este mapeo:

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

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

Asegurándonos de que todas las propiedades estén mapeadas

Si queremos Para garantizar que no olvidemos mapear ninguna propiedad de destino, podemos configurar la opción unmappedTargetPolicy en nuestro mapeador:

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

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

Con esta configuración, si eliminamos

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

nuestro código fallará durante la compilación con el error:

Propiedad de destino no asignada: «contacto». DoctorDto toDto (Doctor doctor);

Si por alguna razón, queremos ignorar un objetivo propiedad, podemos agregar:

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

De manera similar, también podemos garantizar que todas las fuentes propiedades se mapean configurando la opción unmappedSourcePolicy .

Asignación de propiedad de entidad secundaria

La mayoría de las veces, la clase que necesitamos para mapear contiene objetos secundarios . Por ejemplo:

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

Y en nuestro Dto, en lugar de la especialidad completa, solo necesitamos su nombre:

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

Esta situación también es sencilla con mapstruct :

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

Asignación de la entidad secundaria completa: utilizando otra mapper

Como antes, nuestra clase Doctor tiene un objeto secundario Dirección :

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

Pero en este caso, queremos mapearlo a un nuevo objeto en nuestra clase DoctorDto :

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

Para realizar el mapeo entre la dirección y la clase AddressDto, debemos crear una interfaz de mapeador diferente:

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

Luego, en nuestro DoctorMapper debemos asegurarnos de que este mapeador se utilice al mapear el Doctor a DoctorDto .Esto se puede hacer con la opción « uses» en la configuración del mapeador:

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

Veremos que nuestro DoctorMapperImpl conectará automáticamente y utilizará nuestro 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;
}

...
}

Mapeo con un método personalizado

Ahora agreguemos una lista de pacientes a nuestra Doctor clase:

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

Sin embargo, en nuestro DoctorDto solo queremos el número de pacientes:

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

Este mapeo requiere 2 cosas:

  • Un método personalizado con la anotación @Named
  • La configuración qualifiedByName en la anotación 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

El ejemplo anterior (mapeo de Lista de pacientes a int numPatients ) también se puede hacer con @BeforeMapping y @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());
}
}

Estos métodos serán llamados al principio y al final de nuestro método de mapeo generado:

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

Mapeo con un parámetro adicional

Veamos cómo manejar una situación en la que su mapeador necesita recibir un parámetro adicional, además de su Entidad.

En este caso, su clase de Doctor solo puede obtener el ID de la ciudad:

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

Pero debe asignarlo al nombre de la ciudad:

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

Su servicio buscará la lista de ciudades y las pasará al mapeador

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

En nuestro mapeador, necesitamos:

  • marcar el parámetro adicional (lista de ciudades) con @Context anotación
  • crear una método para manejar nuestro mapeo
  • solicitar el parámetro de contexto (lista de ciudades) en nuestro ma personalizado método 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);
}

Inyección de dependencias en métodos de mapeo

Probablemente se encontrará en situaciones donde sus métodos de mapeo personalizados requieran otro bean (otro mapeador, un repositorio, un servicio, etc.).

En estas situaciones, necesitará Autowire ese bean a su mapeador, así que veamos un ejemplo de cómo hacerlo.

En este ejemplo, nuestra clase Patient será una clase abstracta:

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

Que contiene dos implementaciones:

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

Como vimos anteriormente, este es el estado de nuestra Doctor entidad:

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

Sin embargo, nuestro PatientDto requiere una lista distinta para cada clase 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;
}

Y

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

Entonces, para mapear estas clases concretas, debemos comenzar por creatin g dos mapeadores:

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

Pero para mapear desde Lista de pacientes para Pacientes Para pacientes también necesitamos un mapeador diferente que utilice los mapeadores recién creados ( WomanMapper y ManMapper ), que acabará siendo utilizado por nuestro DoctorMapper.

Dado que nuestro PacientesMapper necesitamos utilizar WomanMapper y ManMapper , en lugar de crear una interfaz, necesitamos crear una clase abstracta:

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

Finalmente, para que nuestro DoctorMapper use el PacientesMapper , necesitamos agregar alguna configuración:

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

Como la variable pacientes tiene el mismo nombre en las clases Entity y Dto, no necesitamos especificar nada más.

Este será el resultado final de las clases generadas:

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

Actualizaciones

Mapstruct también proporciona Es una forma sencilla de gestionar las actualizaciones. Si queremos actualizar nuestra entidad Doctor con la información sobre DoctorDto , solo necesitamos crear:

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

Como podemos ver en la implementación generada, mapeará todos variables (nulas o no nulas):

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

Actualizaciones de parches

Como hemos visto en el ejemplo anterior, el método de actualización predeterminado mapee cada propiedad, incluso si es nula.Entonces, si se encuentra en una situación en la que solo desea realizar una actualización de parche (solo actualice los valores no nulos), debe usar la nullValuePropertyMappingStrategy :

@BeanMapping(nullValuePropertyMappingStrategy = 
NullValuePropertyMappingStrategy.IGNORE)

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

El método generado realizará comprobaciones nulas antes de actualizar los valores:

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

Conclusión

Este artículo describió cómo aprovechar la biblioteca de Mapstruct para reducir significativamente nuestro código repetitivo de una manera segura y elegante.

Como se ve en los ejemplos, Mapstruct ofrece un amplio conjunto de funcionalidades y configuraciones que nos permite crear desde mapeadores básicos a complejos de una manera fácil y rápida.