Guía de MapStruct en Java - Biblioteca de mapeo avanzado

En este artículo, veremos muchos ejemplos del uso de MapStruct para mapeo avanzado de Java, manejo de excepciones y anotaciones de MapStruct.

Introducción

A medida que los microservicios y las aplicaciones distribuidas se apoderan rápidamente del mundo del desarrollo, la integridad y la seguridad de los datos son más importantes que nunca. Un canal de comunicación seguro y una transferencia de datos limitada entre estos sistemas débilmente acoplados son primordiales. La mayoría de las veces, el usuario final o el servicio no necesitan acceder a la totalidad de los datos de un modelo, sino solo a algunas partes específicas.

Objetos de transferencia de datos (DTO) se aplican regularmente en estas aplicaciones. Los DTO son solo objetos que contienen la información solicitada de otro objeto. Por lo general, la información tiene un alcance limitado. Dado que los DTO son un reflejo de los objetos originales, los mapeadores entre estas clases juegan un papel clave en el proceso de conversión.

En este artículo, nos sumergiremos en MapStruct, un mapeador extenso para Java Beans.

Estructura del mapa

MapStruct es un generador de código basado en Java de código abierto que crea código para implementaciones de mapeo.

Utiliza el procesamiento de anotaciones para generar implementaciones de clase de mapeador durante la compilación y reduce en gran medida la cantidad de código repetitivo que normalmente se escribiría a mano.

Dependencias de MapStruct

Si está utilizando Maven, instale MapStruct agregando la dependencia:

1
2
3
4
5
6
7
<dependencies>
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>${org.mapstruct.version}</version>
    </dependency>
</dependencies>

Esta dependencia importará las anotaciones principales de MapStruct. Dado que MapStruct funciona en tiempo de compilación y está adjunto a constructores como Maven y Gradle, también tendremos que agregar un complemento a <build>:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.5.1</version>
            <configuration>
                <source>1.8</source>
                <target>1.8</target>
                <annotationProcessorPaths>
                    <path>
                        <groupId>org.mapstruct</groupId>
                        <artifactId>mapstruct-processor</artifactId>
                        <version>${org.mapstruct.version}</version>
                    </path>
                </annotationProcessorPaths>
            </configuration>
        </plugin>
    </plugins>
</build>

Si está usando Gradle, instalar MapStruct es tan simple como:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
plugins {
    id 'net.ltgt.apt' version '0.20'
}

apply plugin: 'net.ltgt.apt-idea'
apply plugin: 'net.ltgt.apt-eclipse'

dependencies {
    compile "org.mapstruct:mapstruct:${mapstructVersion}"
    annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"
}

El complemento net.ltgt.apt es responsable del procesamiento de anotaciones. Puede aplicar los complementos apt-idea y apt-eclipse dependiendo de su IDE.

Puedes consultar la última versión en Centro de expertos.

Mapeos básicos

Comencemos con algunos mapas básicos. Tendremos un modelo Doctor y DoctorDto. Sus campos tendrán los mismos nombres para nuestra conveniencia:

1
2
3
4
public class Doctor {
    private int id;
    private String name;
}

Y:

1
2
3
4
public class DoctorDto {
    private int id;
    private String name;
}

Ahora, para hacer un mapeador entre estos dos, crearemos una interfaz DoctorMapper. Al anotarlo con @Mapper, MapStruct sabe que este es un mapeador entre nuestras dos clases:

1
2
3
4
5
@Mapper
public interface DoctorMapper {
    DoctorMapper INSTANCE = Mappers.getMapper(DoctorMapper.class);
    DoctorDto toDto(Doctor doctor);
}

Tenemos una INSTANCIA de tipo DoctorMapper. Este será nuestro "punto de entrada" a la instancia una vez que generemos la implementación.

Hemos definido un método toDto() en la interfaz, que acepta una instancia Doctor y devuelve una instancia DoctorDto. Esto es suficiente para que MapStruct sepa que nos gustaría asignar una instancia de Doctor a una instancia de DoctorDto.

Cuando creamos/compilamos la aplicación, el complemento del procesador de anotaciones de MapStruct seleccionará la interfaz DoctorMapper y generará una implementación para ella:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class DoctorMapperImpl implements DoctorMapper {
    @Override
    public DoctorDto toDto(Doctor doctor) {
        if ( doctor == null ) {
            return null;
        }
        DoctorDtoBuilder doctorDto = DoctorDto.builder();

        doctorDto.id(doctor.getId());
        doctorDto.name(doctor.getName());

        return doctorDto.build();
    }
}

La clase DoctorMapperImpl ahora contiene un método toDto() que asigna nuestros campos Doctor a los campos DoctorDto.

Ahora, para asignar una instancia de Doctor a una instancia de DoctorDto, haríamos:

1
DoctorDto doctorDto = DoctorMapper.INSTANCE.toDto(doctor);

Nota: Es posible que haya notado un DoctorDtoBuilder en la implementación anterior. Hemos omitido la implementación por brevedad, ya que los constructores tienden a ser largos. MapStruct intentará usar su constructor si está presente en la clase. Si no, simplemente lo instanciará a través de la palabra clave nuevo.

If you'd like to read more about the Patrón de diseño de constructor en Java, we've got you covered!

Asignaciones de diferentes campos de origen y de destino {#mappings differentsourceandtargetfields}

A menudo, un modelo y un DTO no tendrán los mismos nombres de campo. Puede haber ligeras variaciones debido a que los miembros del equipo asignan sus propias representaciones y cómo le gustaría empaquetar la información para el servicio que solicitó el DTO.

MapStruct brinda soporte para manejar estas situaciones a través de la anotación @Mapping.

Nombres de propiedad diferentes

Actualicemos la clase Doctor para incluir una especialidad:

1
2
3
4
5
public class Doctor {
    private int id;
    private String name;
    private String specialty;
}

Y para DoctorDto, agreguemos un campo de especialización:

1
2
3
4
5
public class DoctorDto {
    private int id;
    private String name;
    private String specialization;
}

Ahora, tendremos que avisar a nuestro DoctorMapper de esta discrepancia. Lo haremos configurando los indicadores origen y objetivo de la anotación @Mapping con ambas variantes:

1
2
3
4
5
6
7
@Mapper
public interface DoctorMapper {
    DoctorMapper INSTANCE = Mappers.getMapper(DoctorMapper.class);

    @Mapping(source = "doctor.specialty", target = "specialization")
    DoctorDto toDto(Doctor doctor);
}

El campo especialidad de la clase Doctor corresponde al campo especialización de la clase DoctorDto.

Después de compilar el código, el procesador de anotaciones ha generado esta implementación:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class DoctorMapperImpl implements DoctorMapper {
@Override
    public DoctorDto toDto(Doctor doctor) {
        if (doctor == null) {
            return null;
        }

        DoctorDtoBuilder doctorDto = DoctorDto.builder();

        doctorDto.specialization(doctor.getSpecialty());
        doctorDto.id(doctor.getId());
        doctorDto.name(doctor.getName());

        return doctorDto.build();
    }
}

Clases de múltiples fuentes

A veces, una sola clase no es suficiente para construir un DTO. A veces, queremos agregar valores de varias clases en un solo DTO para el usuario final. Esto también se hace configurando las banderas apropiadas en la anotación @Mapping:

Vamos a crear otro modelo Educación:

1
2
3
4
5
public class Education {
    private String degreeName;
    private String institute;
    private Integer yearOfPassing;
}

Y agregue un nuevo campo en DoctorDto:

1
2
3
4
5
6
public class DoctorDto {
    private int id;
    private String name;
    private String degree;
    private String specialization;
}

Ahora, actualicemos la interfaz DoctorMapper:

1
2
3
4
5
6
7
8
@Mapper
public interface DoctorMapper {
    DoctorMapper INSTANCE = Mappers.getMapper(DoctorMapper.class);

    @Mapping(source = "doctor.specialty", target = "specialization")
    @Mapping(source = "education.degreeName", target = "degree")
    DoctorDto toDto(Doctor doctor, Education education);
}

Hemos añadido otra anotación @Mapping en la que hemos establecido el origen como el nombre del título de la clase Educación y el objetivo como el campo grado de la clase DoctorDto.

Si las clases Educación y Doctor contienen campos con el mismo nombre, tendremos que dejar que el mapeador sepa cuál usar o lanzará una excepción. Si ambos modelos contienen un id, tendremos que elegir qué id se asignará a la propiedad DTO.

Asignación de entidades secundarias

En la mayoría de los casos, los POJO no contienen solo tipos de datos primitivos. En la mayoría de los casos, contendrán otras clases. Por ejemplo, un Doctor tendrá 1..n pacientes:

1
2
3
4
public class Patient {
    private int id;
    private String name;
}

Y hagamos una ‘Lista’ de ellos para el ‘Doctor’:

1
2
3
4
5
6
public class Doctor {
    private int id;
    private String name;
    private String specialty;
    private List<Patient> patientList;
}

Dado que se transferirán los datos del Paciente, también crearemos un DTO para ellos:

1
2
3
4
public class PatientDto {
    private int id;
    private String name;
}

Y finalmente, actualicemos el DoctorDto con una Lista del PatientDto recién creado:

1
2
3
4
5
6
7
public class DoctorDto {
    private int id;
    private String name;
    private String degree;
    private String specialization;
    private List<PatientDto> patientDtoList;
}

Antes de cambiar algo en DoctorMapper, tendremos que hacer un mapeador que convierta entre las clases Patient y PatientDto:

1
2
3
4
5
@Mapper
public interface PatientMapper {
    PatientMapper INSTANCE = Mappers.getMapper(PatientMapper.class);
    PatientDto toDto(Patient patient);
}

Es un mapeador básico que solo mapea un par de tipos de datos primitivos.

Ahora, actualicemos nuestro DoctorMapper para incluir a los pacientes del médico:

1
2
3
4
5
6
7
8
9
@Mapper(uses = {PatientMapper.class})
public interface DoctorMapper {

    DoctorMapper INSTANCE = Mappers.getMapper(DoctorMapper.class);

    @Mapping(source = "doctor.patientList", target = "patientDtoList")
    @Mapping(source = "doctor.specialty", target = "specialization")
    DoctorDto toDto(Doctor doctor);
}

Dado que estamos trabajando con otra clase que requiere mapeo, hemos establecido el indicador uses de la anotación @Mapper. Este @Mapper usa otro @Mapper. Puedes poner tantas clases/mapeadores aquí como quieras, solo tenemos uno.

Debido a que agregamos este indicador, al generar la implementación del mapeador para la interfaz DoctorMapper, MapStruct también convertirá el modelo Patient en un PatientDto, ya que registramos PatientMapper para esta tarea.

Ahora, compilar la aplicación dará como resultado una nueva implementación:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class DoctorMapperImpl implements DoctorMapper {
    private final PatientMapper patientMapper = Mappers.getMapper( PatientMapper.class );

    @Override
    public DoctorDto toDto(Doctor doctor) {
        if ( doctor == null ) {
            return null;
        }

        DoctorDtoBuilder doctorDto = DoctorDto.builder();

        doctorDto.patientDtoList( patientListToPatientDtoList(doctor.getPatientList()));
        doctorDto.specialization( doctor.getSpecialty() );
        doctorDto.id( doctor.getId() );
        doctorDto.name( doctor.getName() );

        return doctorDto.build();
    }
    
    protected List<PatientDto> patientListToPatientDtoList(List<Patient> list) {
        if ( list == null ) {
            return null;
        }

        List<PatientDto> list1 = new ArrayList<PatientDto>( list.size() );
        for ( Patient patient : list ) {
            list1.add( patientMapper.toDto( patient ) );
        }

        return list1;
    }
}

Evidentemente, se ha agregado un nuevo mapeador - patientListToPatientDtoList(), además del mapeador toDto(). Esto se hace sin una definición explícita, simplemente porque hemos agregado PatientMapper a DoctorMapper.

El método itera sobre una lista de modelos Patient, los convierte en PatientDtos y los agrega a una lista contenida dentro de un objeto DoctorDto.

Actualización de instancias existentes {#actualización de instancias existentes}

A veces, nos gustaría actualizar un modelo con los valores más recientes de un DTO. Usando la anotación @MappingTarget en el objeto de destino (Doctor en nuestro caso), podemos actualizar las instancias existentes.

Agreguemos un nuevo @Mapping a nuestro DoctorMapper que acepta las instancias Doctor y DoctorDto. La instancia DoctorDto será la fuente de datos, mientras que Doctor será el destino:

1
2
3
4
5
6
7
8
9
@Mapper(uses = {PatientMapper.class})
public interface DoctorMapper {

    DoctorMapper INSTANCE = Mappers.getMapper(DoctorMapper.class);

    @Mapping(source = "doctorDto.patientDtoList", target = "patientList")
    @Mapping(source = "doctorDto.specialization", target = "specialty")
    void updateModel(DoctorDto doctorDto, @MappingTarget Doctor doctor);
}

Ahora, después de generar la implementación nuevamente, tenemos el método updateModel():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class DoctorMapperImpl implements DoctorMapper {

    @Override
    public void updateModel(DoctorDto doctorDto, Doctor doctor) {
        if (doctorDto == null) {
            return;
        }

        if (doctor.getPatientList() != null) {
            List<Patient> list = patientDtoListToPatientList(doctorDto.getPatientDtoList());
            if (list != null) {
                doctor.getPatientList().clear();
                doctor.getPatientList().addAll(list);
            }
            else {
                doctor.setPatientList(null);
            }
        }
        else {
            List<Patient> list = patientDtoListToPatientList(doctorDto.getPatientDtoList());
            if (list != null) {
                doctor.setPatientList(list);
            }
        }
        doctor.setSpecialty(doctorDto.getSpecialization());
        doctor.setId(doctorDto.getId());
        doctor.setName(doctorDto.getName());
    }
}

Lo que vale la pena señalar es que la lista de pacientes también se está actualizando, ya que es una entidad secundaria del módulo.

Inyección de dependencia

Hasta ahora, hemos accedido a los mapeadores generados a través del método getMapper():

1
DoctorMapper INSTANCE = Mappers.getMapper(DoctorMapper.class);

Sin embargo, si está utilizando [Primavera] (https://spring.io/projects/spring-framework), puede actualizar la configuración de su mapeador e inyectarlo como una dependencia normal.

Actualicemos nuestro DoctorMapper para que funcione con Spring:

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

Agregar (componentModel = "spring") en la anotación @Mapper le dice a MapStruct que al generar la clase de implementación del mapeador, nos gustaría que se creara con el soporte de inyección de dependencia a través de Spring. Ahora, no hay necesidad de agregar el campo INSTANCIA a la interfaz.

El DoctorMapperImpl generado ahora tendrá la anotación @Component:

1
2
@Component
public class DoctorMapperImpl implements DoctorMapper {}

Una vez marcado como @Component, Spring puede tomarlo como un bean y usted es libre de @Autowire en otra clase, como un controlador:

1
2
3
4
5
@Controller
public class DoctorController() {
    @Autowired
    private DoctorMapper doctorMapper;
}

Si no estás usando Spring, MapStruct también tiene soporte para CDI de Java:

1
2
@Mapper(componentModel = "cdi")
public interface DoctorMapper {}

Asignación de enumeraciones

La asignación de enumeraciones funciona de la misma manera que la asignación de campos. MapStruct mapeará los que tengan los mismos nombres sin ningún problema. Sin embargo, para Enums con nombres diferentes, usaremos la anotación @ValueMapping. Nuevamente, esto es similar a la anotación @Mapping con tipos regulares.

Vamos a crear dos Enums, siendo el primero PaymentType:

1
2
3
4
5
6
7
public enum PaymentType {
    CASH,
    CHEQUE,
    CARD_VISA,
    CARD_MASTER,
    CARD_CREDIT
}

Estas son, por ejemplo, las opciones disponibles para el pago en una aplicación. Y ahora, tengamos una visión más general y limitada de esas opciones:

1
2
3
4
5
public enum PaymentTypeView {
    CASH,
    CHEQUE,
    CARD
}

Ahora, hagamos una interfaz de mapeo entre estos dos enums:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Mapper
public interface PaymentTypeMapper {

    PaymentTypeMapper INSTANCE = Mappers.getMapper(PaymentTypeMapper.class);

    @ValueMappings({
            @ValueMapping(source = "CARD_VISA", target = "CARD"),
            @ValueMapping(source = "CARD_MASTER", target = "CARD"),
            @ValueMapping(source = "CARD_CREDIT", target = "CARD")
    })
    PaymentTypeView paymentTypeToPaymentTypeView(PaymentType paymentType);
}

Aquí, tenemos un valor general de CARD y valores más específicos de CARD_VISA, CARD_MASTER y CARD_CREDIT. Hay una discrepancia con la cantidad de valores: PaymentType tiene 6 valores, mientras que PaymentTypeView solo tiene 3.

Para unirlos, podemos usar la anotación @ValueMappings, que acepta múltiples anotaciones @ValueMapping. Aquí, podemos configurar el origen para que sea cualquiera de los tres casos específicos, y el destino como el valor “TARJETA”.

MapStruct manejará estos casos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class PaymentTypeMapperImpl implements PaymentTypeMapper {

    @Override
    public PaymentTypeView paymentTypeToPaymentTypeView(PaymentType paymentType) {
        if (paymentType == null) {
            return null;
        }

        PaymentTypeView paymentTypeView;

        switch (paymentType) {
            case CARD_VISA: paymentTypeView = PaymentTypeView.CARD;
            break;
            case CARD_MASTER: paymentTypeView = PaymentTypeView.CARD;
            break;
            case CARD_CREDIT: paymentTypeView = PaymentTypeView.CARD;
            break;
            case CASH: paymentTypeView = PaymentTypeView.CASH;
            break;
            case CHEQUE: paymentTypeView = PaymentTypeView.CHEQUE;
            break;
            default: throw new IllegalArgumentException( "Unexpected enum constant: " + paymentType );
        }
        return paymentTypeView;
    }
}

CASH y CHEQUE tienen sus valores correspondientes por defecto, mientras que el valor específico de CARD se maneja a través de un bucle switch.

Sin embargo, este enfoque puede volverse poco práctico cuando tiene muchos valores que le gustaría asignar a uno más general. En lugar de asignar cada uno manualmente, podemos simplemente dejar que MapStruct revise todos los valores restantes disponibles y asignarlos a otro.

Esto se hace a través de MappingConstants:

1
2
@ValueMapping(source = MappingConstants.ANY_REMAINING, target = "CARD")
PaymentTypeView paymentTypeToPaymentTypeView(PaymentType paymentType);

Aquí, después de realizar las asignaciones predeterminadas, todos los valores restantes (que no coincidan) se asignarán a CARD.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Override
public PaymentTypeView paymentTypeToPaymentTypeView(PaymentType paymentType) {
    if ( paymentType == null ) {
        return null;
    }

    PaymentTypeView paymentTypeView;

    switch ( paymentType ) {
        case CASH: paymentTypeView = PaymentTypeView.CASH;
        break;
        case CHEQUE: paymentTypeView = PaymentTypeView.CHEQUE;
        break;
        default: paymentTypeView = PaymentTypeView.CARD;
    }
    return paymentTypeView;
}

Otra opción sería usar ANY_UNMAPPED:

1
2
@ValueMapping(source = MappingConstants.ANY_UNMAPPED, target = "CARD")
PaymentTypeView paymentTypeToPaymentTypeView(PaymentType paymentType);

En este caso, en lugar de asignar primero los valores predeterminados y luego asignar los restantes a un solo destino, MapStruct simplemente asignará todos los valores no asignados al destino.

Asignación de tipos de datos

MapStruct admite la conversión de tipos de datos entre las propiedades fuente y destino. También proporciona conversión automática de tipos entre tipos primitivos y sus contenedores correspondientes.

La conversión automática de tipo se aplica a:

  • Conversión entre tipos primitivos y sus respectivos tipos de envoltura. Por ejemplo, la conversión entre int y Integer, float y Float, long y Long, boolean y Boolean, etc.
  • Conversión entre cualquier tipo primitivo y cualquier tipo contenedor. Por ejemplo, entre int y long, byte e Integer, etc.
  • Conversión entre todos los tipos primitivos y contenedores y String. Por ejemplo, la conversión entre boolean y String, Integer y String, float y String, etc.

Por lo tanto, durante la generación del código del mapeador, si la conversión de tipo entre el campo de origen y el de destino se encuentra en alguno de los escenarios anteriores, MapStrcut se encargará de la conversión de tipo.

Actualicemos nuestro PatientDto para incluir un campo para almacenar la dateofBirth:

1
2
3
4
5
public class PatientDto {
    private int id;
    private String name;
    private LocalDate dateOfBirth;
}

Por otro lado, digamos que nuestro objeto Patient tiene una dateOfBirth de tipo String:

1
2
3
4
5
public class Patient {
    private int id;
    private String name;
    private String dateOfBirth;
}

Ahora, avancemos y hagamos un mapeador entre estos dos:

1
2
3
4
5
6
@Mapper
public interface PatientMapper {

    @Mapping(source = "dateOfBirth", target = "dateOfBirth", dateFormat = "dd/MMM/yyyy")
    Patient toModel(PatientDto patientDto);
}

Al convertir entre fechas, también podemos usar el indicador dateFormat para establecer el especificador de formato. La implementación generada se verá así:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class PatientMapperImpl implements PatientMapper {

    @Override
    public Patient toModel(PatientDto patientDto) {
        if (patientDto == null) {
            return null;
        }

        PatientBuilder patient = Patient.builder();

        if (patientDto.getDateOfBirth() != null) {
            patient.dateOfBirth(DateTimeFormatter.ofPattern("dd/MMM/yyyy")
                                .format(patientDto.getDateOfBirth()));
        }
        patient.id(patientDto.getId());
        patient.name(patientDto.getName());

        return patient.build();
    }
}

Tenga en cuenta que MapStruct ha utilizado el patrón proporcionado por el indicador dateFormat. Si no especificamos el formato, se habría establecido en el formato predeterminado de LocalDate:

1
2
3
4
if (patientDto.getDateOfBirth() != null) {
    patient.dateOfBirth(DateTimeFormatter.ISO_LOCAL_DATE
                        .format(patientDto.getDateOfBirth()));
}

Adición de métodos personalizados

Hasta ahora, hemos estado agregando un método de marcador de posición que queremos que MapStruct implemente para nosotros. Lo que también podemos hacer es agregar un método “predeterminado” personalizado a la interfaz. Al agregar un método predeterminado, también podemos agregar la implementación directamente. Podremos acceder a él a través de la instancia sin problema.

Para esto, hagamos un DoctorPatientSummary, que contiene un resumen entre un Doctor y una lista de sus Patients:

1
2
3
4
5
6
7
8
public class DoctorPatientSummary {
    private int doctorId;
    private int patientCount;
    private String doctorName;
    private String specialization;
    private String institute;
    private List<Integer> patientIds;
}

Ahora, en nuestro DoctorMapper, agregaremos un método predeterminado que, en lugar de asignar un Doctor a un DoctorDto, convierte los objetos Doctor y Education en DoctorPatientSummary:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@Mapper
public interface DoctorMapper {

    default DoctorPatientSummary toDoctorPatientSummary(Doctor doctor, Education education) {

        return DoctorPatientSummary.builder()
                .doctorId(doctor.getId())
                .doctorName(doctor.getName())
                .patientCount(doctor.getPatientList().size())
                .patientIds(doctor.getPatientList()
                        .stream()
                        .map(Patient::getId)
                        .collect(Collectors.toList()))
                .institute(education.getInstitute())
                .specialization(education.getDegreeName())
                .build();
    }
}

Este objeto se construye a partir de los objetos Doctor y Education utilizando el patrón de diseño de constructor.

Esta implementación estará disponible para su uso después de que MapStruct genere la clase de implementación del mapeador. Puede acceder a él de la misma manera que accedería a cualquier otro método de asignación:

1
DoctorPatientSummary summary = doctorMapper.toDoctorPatientSummary(dotor, education);

Creación de mapeadores personalizados

Hasta ahora, hemos estado usando interfaces para crear planos para mapeadores. También podemos hacer blueprints con clases abstractas, anotadas con la anotación @Mapper. MapStruct creará una implementación para esta clase, similar a la creación de una implementación de interfaz.

Reescribamos el ejemplo anterior, aunque esta vez lo convertiremos en una clase abstracta:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Mapper
public abstract class DoctorCustomMapper {
    public DoctorPatientSummary toDoctorPatientSummary(Doctor doctor, Education education) {

        return DoctorPatientSummary.builder()
                .doctorId(doctor.getId())
                .doctorName(doctor.getName())
                .patientCount(doctor.getPatientList().size())
                .patientIds(doctor.getPatientList()
                        .stream()
                        .map(Patient::getId)
                        .collect(Collectors.toList()))
                .institute(education.getInstitute())
                .specialization(education.getDegreeName())
                .build();
    }
}

Puede usar esta implementación de la misma manera que usaría una implementación de interfaz. El uso de clases abstractas nos brinda más control y opciones al crear implementaciones personalizadas debido a menos limitaciones. Otro beneficio es la capacidad de agregar los métodos @BeforeMapping y @AfterMapping.

@BeforeMapping y @AfterMapping

Para control y personalización adicionales, podemos definir los métodos @BeforeMapping y @AfterMapping. Obviamente, estos se ejecutan antes y después de cada mapeo. Es decir, estos métodos se agregarán y ejecutarán antes y después del mapeo real entre dos objetos dentro de la implementación.

Agreguemos estos métodos a nuestro DoctorCustomMapper:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Mapper(uses = {PatientMapper.class}, componentModel = "spring")
public abstract class DoctorCustomMapper {

    @BeforeMapping
    protected void validate(Doctor doctor) {
        if(doctor.getPatientList() == null){
            doctor.setPatientList(new ArrayList<>());
        }
    }

    @AfterMapping
    protected void updateResult(@MappingTarget DoctorDto doctorDto) {
        doctorDto.setName(doctorDto.getName().toUpperCase());
        doctorDto.setDegree(doctorDto.getDegree().toUpperCase());
        doctorDto.setSpecialization(doctorDto.getSpecialization().toUpperCase());
    }

    @Mapping(source = "doctor.patientList", target = "patientDtoList")
    @Mapping(source = "doctor.specialty", target = "specialization")
    public abstract DoctorDto toDoctorDto(Doctor doctor);
}

Ahora, generemos un mapeador basado en esta clase:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Component
public class DoctorCustomMapperImpl extends DoctorCustomMapper {
    
    @Autowired
    private PatientMapper patientMapper;
    
    @Override
    public DoctorDto toDoctorDto(Doctor doctor) {
        validate(doctor);

        if (doctor == null) {
            return null;
        }

        DoctorDto doctorDto = new DoctorDto();

        doctorDto.setPatientDtoList(patientListToPatientDtoList(doctor
            .getPatientList()));
        doctorDto.setSpecialization(doctor.getSpecialty());
        doctorDto.setId(doctor.getId());
        doctorDto.setName(doctor.getName());

        updateResult(doctorDto);

        return doctorDto;
    }
}

El método validate() se ejecuta antes de que se cree una instancia del objeto DoctorDto, y el método updateResult() se ejecuta una vez finalizada la asignación.

Adición de valores predeterminados

Un par de indicadores útiles que puede usar con la anotación @Mapping son las constantes y los valores predeterminados. Siempre se utilizará un valor constante, independientemente del valor fuente. Se utilizará un valor predeterminado si el valor fuente es nulo.

Actualicemos nuestro DoctorMapper con una constante y predeterminada:

1
2
3
4
5
6
7
@Mapper(uses = {PatientMapper.class}, componentModel = "spring")
public interface DoctorMapper {
    @Mapping(target = "id", constant = "-1")
    @Mapping(source = "doctor.patientList", target = "patientDtoList")
    @Mapping(source = "doctor.specialty", target = "specialization", defaultValue = "Information Not Available")
    DoctorDto toDto(Doctor doctor);
}

Si la especialidad no está disponible, asignaremos la cadena Información no disponible en su lugar. Además, codificamos el id para que sea -1.

Vamos a generar el mapeador:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Component
public class DoctorMapperImpl implements DoctorMapper {

    @Autowired
    private PatientMapper patientMapper;
    
    @Override
    public DoctorDto toDto(Doctor doctor) {
        if (doctor == null) {
            return null;
        }

        DoctorDto doctorDto = new DoctorDto();

        if (doctor.getSpecialty() != null) {
            doctorDto.setSpecialization(doctor.getSpecialty());
        }
        else {
            doctorDto.setSpecialization("Information Not Available");
        }
        doctorDto.setPatientDtoList(patientListToPatientDtoList(doctor.getPatientList()));
        doctorDto.setName(doctor.getName());

        doctorDto.setId(-1);

        return doctorDto;
    }
}

Si doctor.getSpecialty() devuelve null, establecemos la especialización en nuestro mensaje predeterminado. El id se establece independientemente, ya que es una constante.

Adición de expresiones Java

MapStruct va tan lejos como para permitirle ingresar completamente expresiones Java como indicadores en la anotación @Mapping. Puede establecer una defaultExpression (si el valor source es null) o una expression que es constante.

Agreguemos un externalId que será una String y una cita que será del tipo LocalDateTime a nuestro Doctor y DoctorDto.

Nuestro modelo Doctor se verá así:

1
2
3
4
5
6
7
8
9
public class Doctor {

    private int id;
    private String name;
    private String externalId;
    private String specialty;
    private LocalDateTime availability;
    private List<Patient> patientList;
}

Y DoctorDto se verá así:

1
2
3
4
5
6
7
8
9
public class DoctorDto {

    private int id;
    private String name;
    private String externalId;
    private String specialization;
    private LocalDateTime availability;
    private List<PatientDto> patientDtoList;
}

Y ahora, actualicemos nuestro DoctorMapper:

1
2
3
4
5
6
7
8
9
@Mapper(uses = {PatientMapper.class}, componentModel = "spring", imports = {LocalDateTime.class, UUID.class})
public interface DoctorMapper {

    @Mapping(target = "externalId", expression = "java(UUID.randomUUID().toString())")
    @Mapping(source = "doctor.availability", target = "availability", defaultExpression = "java(LocalDateTime.now())")
    @Mapping(source = "doctor.patientList", target = "patientDtoList")
    @Mapping(source = "doctor.specialty", target = "specialization")
    DoctorDto toDtoWithExpression(Doctor doctor);
}

Aquí, hemos asignado el valor de java(UUID.randomUUID().toString()) al externalId, mientras que hemos establecido condicionalmente la disponibilidad en un nuevo LocalDateTime, si la disponibilidad no está presente.

Dado que las expresiones son solo Strings, tenemos que especificar las clases utilizadas en las expresiones. Este no es un código que se está evaluando, es un valor de texto literal. Por lo tanto, hemos agregado imports = {LocalDateTime.class, UUID.class} a la anotación @Mapper.

El mapeador generado se verá así:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Component
public class DoctorMapperImpl implements DoctorMapper {

    @Autowired
    private PatientMapper patientMapper;
    
    @Override
    public DoctorDto toDtoWithExpression(Doctor doctor) {
        if (doctor == null) {
            return null;
        }

        DoctorDto doctorDto = new DoctorDto();

        doctorDto.setSpecialization(doctor.getSpecialty());
        if (doctor.getAvailability() != null) {
            doctorDto.setAvailability(doctor.getAvailability());
        }
        else {
            doctorDto.setAvailability(LocalDateTime.now());
        }
        doctorDto.setPatientDtoList(patientListToPatientDtoList(doctor
            .getPatientList()));
        doctorDto.setId(doctor.getId());
        doctorDto.setName(doctor.getName());

        doctorDto.setExternalId(UUID.randomUUID().toString());

        return doctorDto;
    }
}

El externalId se establece en:

1
doctorDto.setExternalId(UUID.randomUUID().toString());

Mientras que, si la disponibilidad es nula, se establece en:

1
doctorDto.setAvailability(LocalDateTime.now());

Manejo de excepciones durante el mapeo

El manejo de excepciones es inevitable. Las aplicaciones incurren en estados excepcionales todo el tiempo. MapStruct brinda soporte para incluir el manejo de excepciones sin problemas, lo que hace que su trabajo como desarrollador sea mucho más simple.

Consideremos un escenario en el que queremos validar nuestro modelo Doctor mientras lo asignamos a DoctorDto. Hagamos una clase Validator separada para esto:

1
2
3
4
5
6
7
8
public class Validator {
    public int validateId(int id) throws ValidationException {
        if(id == -1){
            throw new ValidationException("Invalid value in ID");
        }
        return id;
    }
}

Ahora, querremos actualizar nuestro DoctorMapper para usar la clase Validator, sin que tengamos que especificar la implementación. Como de costumbre, agregaremos las clases a la lista de clases usadas por @Mapper, y todo lo que tenemos que hacer es decirle a MapStruct que nuestro método toDto() arroja ValidationException:

1
2
3
4
5
6
7
@Mapper(uses = {PatientMapper.class, Validator.class}, componentModel = "spring")
public interface DoctorMapper {

    @Mapping(source = "doctor.patientList", target = "patientDtoList")
    @Mapping(source = "doctor.specialty", target = "specialization")
    DoctorDto toDto(Doctor doctor) throws ValidationException;
}

Ahora, generemos una implementación para este mapeador:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Component
public class DoctorMapperImpl implements DoctorMapper {

    @Autowired
    private PatientMapper patientMapper;
    @Autowired
    private Validator validator;

    @Override
    public DoctorDto toDto(Doctor doctor) throws ValidationException {
        if (doctor == null) {
            return null;
        }

        DoctorDto doctorDto = new DoctorDto();

        doctorDto.setPatientDtoList(patientListToPatientDtoList(doctor
            .getPatientList()));
        doctorDto.setSpecialization(doctor.getSpecialty());
        doctorDto.setId(validator.validateId(doctor.getId()));
        doctorDto.setName(doctor.getName());
        doctorDto.setExternalId(doctor.getExternalId());
        doctorDto.setAvailability(doctor.getAvailability());

        return doctorDto;
    }
}

MapStruct ha establecido automáticamente la identificación de doctorDto con el resultado de la instancia Validator. También agregó una cláusula throws para el método.

Configuraciones de mapeo

MapStruct proporciona una configuración muy útil para escribir métodos de asignación. La mayoría de las veces, las configuraciones de asignación que especificamos para un método de asignación se replican al agregar otro método de asignación para tipos similares.

En lugar de configurarlos manualmente, podemos configurar tipos similares para que tengan los mismos métodos de mapeo o similares.

Heredar configuración

Repasemos el escenario en Actualización de instancias existentes, donde creamos un mapeador para actualizar los valores de un modelo Doctor existente desde un objeto DoctorDto:

1
2
3
4
5
6
7
8
9
@Mapper(uses = {PatientMapper.class})
public interface DoctorMapper {

    DoctorMapper INSTANCE = Mappers.getMapper(DoctorMapper.class);

    @Mapping(source = "doctorDto.patientDtoList", target = "patientList")
    @Mapping(source = "doctorDto.specialization", target = "specialty")
    void updateModel(DoctorDto doctorDto, @MappingTarget Doctor doctor);
}

Digamos que tenemos otro mapeador que genera un ‘Doctor’ a partir de un ‘DoctorDto’:

1
2
3
4
5
6
7
@Mapper(uses = {PatientMapper.class, Validator.class})
public interface DoctorMapper {

    @Mapping(source = "doctorDto.patientDtoList", target = "patientList")
    @Mapping(source = "doctorDto.specialization", target = "specialty")
    Doctor toModel(DoctorDto doctorDto);
}

Ambos métodos de asignación utilizan la misma configuración. Los origens y destinos son los mismos. En lugar de repetir las configuraciones para ambos métodos de mapeo, podemos usar la anotación @InheritConfiguration.

Al anotar un método con la anotación @InheritConfiguration, MapStruct buscará otro método ya configurado cuya configuración también se pueda aplicar a este. Por lo general, esta anotación se usa para métodos de actualización después de un método de mapeo, tal como lo estamos usando:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Mapper(uses = {PatientMapper.class, Validator.class}, componentModel = "spring")
public interface DoctorMapper {

    @Mapping(source = "doctorDto.specialization", target = "specialty")
    @Mapping(source = "doctorDto.patientDtoList", target = "patientList")
    Doctor toModel(DoctorDto doctorDto);

    @InheritConfiguration
    void updateModel(DoctorDto doctorDto, @MappingTarget Doctor doctor);
}

Heredar configuración inversa

Otro escenario similar es escribir funciones de asignación para asignar Modelo a DTO y DTO a Modelo, como en el código a continuación donde tenemos que especifique la misma asignación de origen y destino en ambas funciones:

Tus configuraciones no siempre serán las mismas. Por ejemplo, pueden ser inversas. Asignación de un modelo a un DTO y un DTO a un modelo: utiliza los mismos campos, pero a la inversa. Así es como se ve normalmente:

1
2
3
4
5
6
7
8
9
@Mapper(componentModel = "spring")
public interface PatientMapper {

    @Mapping(source = "dateOfBirth", target = "dateOfBirth", dateFormat = "dd/MMM/yyyy")
    Patient toModel(PatientDto patientDto);

    @Mapping(source = "dateOfBirth", target = "dateOfBirth", dateFormat = "dd/MMM/yyyy")
    PatientDto toDto(Patient patient);
}

En lugar de escribir esto dos veces, podemos usar la anotación @InheritInverseConfiguration en el segundo método:

1
2
3
4
5
6
7
8
9
@Mapper(componentModel = "spring")
public interface PatientMapper {

    @Mapping(source = "dateOfBirth", target = "dateOfBirth", dateFormat = "dd/MMM/yyyy")
    Patient toModel(PatientDto patientDto);

    @InheritInverseConfiguration
    PatientDto toDto(Patient patient);
}

El código generado de ambas implementaciones del mapeador será el mismo.

Conclusión

En este artículo, exploramos MapStruct, una biblioteca para crear clases de mapeador, desde mapeos de nivel básico hasta métodos personalizados y mapeadores personalizados. También analizamos las diferentes opciones proporcionadas por MapStruct, incluida la inserción de dependencias, las asignaciones de tipos de datos, las asignaciones de enumeración y el uso de expresiones.

MapStruct proporciona un poderoso complemento de integración para reducir la cantidad de código que un usuario tiene que escribir y hace que el proceso de creación de mapeadores sea fácil y rápido.

El código fuente del código de muestra se puede encontrar aquí.