Mergulhe no Mapstruct @ Spring

(Miguel Duque) (14 de dezembro de 2020)

Foto de Beau Swierstra no Unsplash

Sendo um desenvolvedor Java, você definitivamente sabe que mapear POJOs é uma tarefa padrão no desenvolvimento de aplicativos multicamadas.
Escrever esses mapeamentos manualmente, além de ser uma tarefa chata e tarefa desagradável para desenvolvedores, também está sujeita a erros.

MapStruct é uma biblioteca Java de código aberto que gera implementações de classes mapeadoras durante a compilação em uma maneira segura e fácil.

Durante este artigo, seguiremos um exemplo de como tirar proveito desta biblioteca poderosa para reduzir significativamente a quantidade de código clichê que seria escrito à mão regularmente.

Índice

  • Mapeamentos básicos
  • Mapeamento de nomes de propriedades diferentes
  • Certificando-se de que todas as propriedades estão mapeadas
  • Mapeando propriedade de entidade filha
  • Mapeando toda a entidade filha – usando outro mapeador
  • Mapeamento com um método personalizado
  • @BeforeMapping @AfterMapping
  • Mapeamento com um parâmetro adicional
  • injeção de dependência nos métodos de mapeamento
  • atualizações
  • atualizações de patch

mapeamentos básicos

Vamos iniciar nosso aplicativo com um modelo básico, que contém a classe Doctor. Nosso serviço irá recuperar essa classe da camada do modelo e, em seguida, retornar um DoctorDto classe.

Classe modelo:

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

Classe Dto:

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

Para fazer isso, devemos criar nossa interface de mapeador:

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

Como ambas as classes têm os mesmos nomes de propriedade ( id e nome ), mapstruct incluirá o mapeamento de ambos os campos na classe gerada:

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

Adicionando componentModel = “Spring” , o mapeador gerado será um bean Spring e pode ser recuperado com a anotação @Autowired como qualquer outro 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);
}
}

Mapeando nomes de propriedades diferentes

Se incluirmos uma propriedade telefone no Doctor classe:

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

Para ser mapeado para contato em Doutor Dto :

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

Nosso mapeador não será capaz de mapeá-lo automaticamente. Para fazer isso, precisaremos criar uma regra para este mapeamento:

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

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

Certificando-se de que todas as propriedades estão mapeadas

Se desejarmos para garantir que não nos esquecemos de mapear qualquer propriedade de destino, podemos configurar a opção unmappedTargetPolicy em nosso mapeador:

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

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

Com esta configuração, se removermos

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

nosso código falhará durante a compilação com o erro:

Propriedade de destino não mapeada: “contato”. DoctorDto toDto (Doctor doctor);

Se por algum motivo, queremos ignorar um alvo propriedade, podemos adicionar:

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

Da mesma forma, também podemos garantir que todas as fontes as propriedades são mapeadas configurando a opção unmappedSourcePolicy .

Mapeando propriedade de entidade filha

Na maioria das vezes, a classe de que precisamos mapear contém objetos filhos . Por exemplo:

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

E em nosso Dto, em vez da especialidade completa, precisamos apenas de seu nome:

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

Esta situação também é direta com o mapstruct :

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

Mapeando toda a entidade filha – usando outra mapeador

Como antes, nossa classe Doctor tem um objeto filho Endereço :

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

Mas, neste caso, queremos mapeá-lo para um novo objeto em nossa DoctorDto classe:

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

Para realizar o mapeamento entre a classe Address e AddressDto, devemos criar uma interface de mapeador diferente:

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

Então, em nosso DoctorMapper devemos nos certificar de que este mapeador seja usado ao mapear o Doctor para DoctorDto .Isso pode ser feito com a opção “ usa” na configuração do 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 nosso DoctorMapperImpl fará o Autowire e usará nosso 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;
}

...
}

Mapeamento com um método personalizado

Vamos agora adicionar uma lista de pacientes ao nosso Doutor classe:

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

No entanto, em nosso DoctorDto queremos apenas o 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 mapeamento requer 2 coisas:

  • um método personalizado com a anotação @Named
  • A configuração qualificadaByName na anotação de mapeamento
@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

O exemplo anterior (mapeamento de Listar pacientes to int numPatients ) também pode ser feito com @BeforeMapping e @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());
}
}

Esses métodos serão chamados no início e no final do nosso método de mapeamento gerado:

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

Mapeamento com um parâmetro adicional

Vamos verificar como lidar com uma situação em que seu mapeador precisa receber um parâmetro adicional, além de sua Entidade.

Nesse caso, sua classe Doctor só pode obter o Id da cidade:

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

Mas precisa mapeá-la para o nome da cidade:

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

Seu serviço irá buscar a lista de cidades e passá-las para o 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);
}
}

Em nosso mapeador, precisamos:

  • marcar o parâmetro adicional (lista de cidades) com a anotação @Context
  • criar um personalizado método para lidar com nosso mapeamento
  • solicitar o parâmetro de contexto (lista de cidades) em nosso 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);
}

Injeção de dependência em métodos de mapeamento

Você provavelmente se encontrará em situações onde seus métodos de mapeamento personalizados exigem outro bean (outro mapeador, um repositório, um serviço, etc.).

Nessas situações, você precisará fazer o Autowire desse bean para o seu mapeador, então vamos ver um exemplo de como fazer isso.

Neste exemplo, nossa classe Paciente será uma classe abstrata:

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

Que contém duas implementações:

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

Como vimos anteriormente, este é o estado de nosso Doctor entidade:

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

No entanto, nosso PatientDto requer uma lista distinta para cada classe 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;
}

E

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

Então, para mapear essas classes concretas, devemos começar criando g two mappers:

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

Mas para mapear de List pacientes para PacientesDa pacientes também precisamos de um mapeador diferente que use os mapeadores recém-criados ( WomanMapper e ManMapper ), que acabará sendo usado por nosso DoctorMapper.

Já que nosso PatientsMapper precisamos usar o WomanMapper e ManMapper , em vez de criar uma Interface, precisamos criar uma classe 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;
}
}

Finalmente, para fazer nosso DoctorMapper usar o PatientsMapper , precisamos adicionar algumas configurações:

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

Como a variável pacientes tem o mesmo nome nas classes Entity e Dto, não precisamos especificar mais nada.

Este será o resultado final das classes geradas:

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

Atualizações

Mapstruct também fornecem s uma maneira fácil de lidar com as atualizações. Se quisermos atualizar nossa entidade Doctor com as informações sobre DoctorDto , só precisamos criar:

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

Como podemos ver na implementação gerada, ele mapeará todos variáveis ​​(nulas ou não 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 );
}
}

Atualizações de patch

Como vimos no exemplo anterior, o método de atualização padrão mapeie cada propriedade, mesmo se for nula.Portanto, se você se encontrar em uma situação em que deseja apenas realizar uma atualização de patch (atualizar apenas os valores não nulos), você precisa usar a nullValuePropertyMappingStrategy :

@BeanMapping(nullValuePropertyMappingStrategy = 
NullValuePropertyMappingStrategy.IGNORE)

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

O método gerado executará verificações de nulos antes de atualizar os 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() );
}

Conclusão

Este artigo descreveu como tirar proveito da biblioteca Mapstruct para reduzir significativamente nosso código clichê de uma maneira segura e elegante.

Como visto nos exemplos, Mapstruct oferece um vasto conjunto de funcionalidades e configurações que nos permitem criar a partir de mapeadores básicos para complexos de uma maneira fácil e rápida.