MapStruct使用指南并结合Lombok

MapStruct使用指南并结合Lombok



如何结合 lombok

也就说说如果代码中使用了 lombok 注解来生成代码,mapstruct 的 getter/setter 方法也使用了 lombok 的 api,那就需要额外的配置,因为这两个工具都是使用了编译时生成新的代码,所以需要在 maven 编译的时候指定一下.方案如下

介绍

随着微服务和分布式应用程序迅速占领开发领域,数据完整性和安全性比以往任何时候都更加重要。在这些松散耦合的系统之间,安全的通信渠道和有限的数据传输是最重要的。大多数时候,终端用户或服务不需要访问模型中的全部数据,而只需要访问某些特定的部分。

数据传输对象(Data Transfer Objects, DTO)经常被用于这些应用中。DTO 只是持有另一个对象中被请求的信息的对象。通常情况下,这些信息是有限的一部分。例如,在持久化层定义的实体和发往客户端的 DTO 之间经常会出现相互之间的转换。由于 DTO 是原始对象的反映,因此这些类之间的映射器在转换过程中扮演着关键角色。

这就是 MapStruct 解决的问题:手动创建 bean 映射器非常耗时。 但是该库可以自动生成 Bean 映射器类。

在本文中,我们将深入研究 MapStruct

MapStruct

MapStruct 是一个开源的基于 Java 的代码生成器,用于创建实现 Java Bean 之间转换的扩展映射器。使用 MapStruct,我们只需要创建接口,而该库会通过注解在编译过程中自动创建具体的映射实现,大大减少了通常需要手工编写的样板代码的数量。

MapStruct 依赖

如果你使用 Maven 的话,可以通过引入依赖安装 MapStruct:

<dependencies>
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>${org.mapstruct.version}</version>
    </dependency>
</dependencies>

这个依赖项会导入 MapStruct 的核心注释。由于 MapStruct 在编译时工作,并且会集成到像 Maven 和 Gradle 这样的构建工具上,我们还必须在 <build 中/>​​ 标签中添加一个插件 maven-compiler-plugin​,并在其配置中添加 annotationProcessorPaths​,该插件会在构建时生成对应的代码。

这个配置必须写在你要执行 mvn package 对应的 pom.xml 里面,然后执行 maven,使用 idea 的编译那么下面的 build 命令不会生效

<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>

如果你使用 Gradle 的话,安装 MapStruct 会更简单:

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}"
}

net.ltgt.apt ​插件会负责处理注释。你可以根据你使用的 IDE 启用插件 apt-idea ​或 apt-eclipse ​插件。

MapStruct 及其处理器的最新稳定版本都可以从 Maven 中央仓库中获得。

映射

基本映射

我们先从一些基本的映射开始。我们会创建一个 Doctor 对象和一个 DoctorDto。为了方便起见,它们的属性字段都使用相同的名称:

public class Doctor {
    private int id;
    private String name;
    // getters and setters or builder
}
public class DoctorDto {
    private int id;
    private String name;
    // getters and setters or builder
}

现在,为了在这两者之间进行映射,我们要创建一个 DoctorMapper ​接口。对该接口使用 @Mapper ​注解,MapStruct 就会知道这是两个类之间的映射器。

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

这段代码中创建了一个 DoctorMapper ​类型的实例 INSTANCE​,在生成对应的实现代码后,这就是我们调用的“入口”。

我们在接口中定义了 toDto() ​方法,该方法接收一个 Doctor ​实例为参数,并返回一个 DoctorDto ​实例。这足以让 MapStruct 知道我们想把一个 Doctor ​实例映射到一个 DoctorDto ​实例。

当我们构建/编译应用程序时,MapStruct 注解处理器插件会识别出 DoctorMapper 接口并为其生成一个实现类。

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

}

DoctorMapperImpl ​类中包含一个 toDto() ​方法,将我们的 Doctor ​属性值映射到 DoctorDto ​的属性字段中。如果要将 Doctor ​实例映射到一个 DoctorDto ​实例,可以这样写:

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

注意​:你可能也注意到了上面实现代码中的 DoctorDtoBuilder​。因为 builder 代码往往比较长,为了简洁起见,这里省略了 builder 模式的实现代码。如果你的类中包含 Builder,MapStruct 会尝试使用它来创建实例;如果没有的话,MapStruct 将通过 new ​关键字进行实例化。

不同字段间映射

通常,模型和 DTO 的字段名不会完全相同。由于团队成员各自指定命名,以及针对不同的调用服务,开发者对返回信息的打包方式选择不同,名称可能会有轻微的变化。

MapStruct 通过 @Mapping ​注解对这类情况提供了支持。

不同属性名称

我们先更新 Doctor ​类,添加一个属性 specialty​:

public class Doctor {
    private int id;
    private String name;
    private String specialty;
    // getters and setters or builder
}

DoctorDto ​类中添加一个 specialization ​属性:

public class DoctorDto {
    private int id;
    private String name;
    private String specialization;
    // getters and setters or builder
}

现在,我们需要让 DoctorMapper​ 知道这里的不一致。我们可以使用 @Mapping​ 注解,并设置其内部的 source​ 和 target​ 标记分别指向不一致的两个字段。

@Mapper
public interface DoctorMapper {
    DoctorMapper INSTANCE = Mappers.getMapper(DoctorMapper.class);
@Mapping(source = &quot;doctor.specialty&quot;, target = &quot;specialization&quot;)
DoctorDto toDto(Doctor doctor);

}

这个注解代码的含义是:Doctor ​中的 specialty ​字段对应于 DoctorDto ​类的 specialization​ 。

编译之后,会生成如下实现代码:

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

}

多个源类

有时,单个类不足以构建 DTO,我们可能希望将多个类中的值聚合为一个 DTO,供终端用户使用。这也可以通过在 @Mapping ​注解中设置适当的标志来完成。

我们先新建另一个对象 Education​:

public class Education {
    private String degreeName;
    private String institute;
    private Integer yearOfPassing;
    // getters and setters or builder
}

然后向 DoctorDto ​中添加一个新的字段:

public class DoctorDto {
    private int id;
    private String name;
    private String degree;
    private String specialization;
    // getters and setters or builder
}

接下来,将 DoctorMapper​ 接口更新为如下代码:

@Mapper
public interface DoctorMapper {
    DoctorMapper INSTANCE = Mappers.getMapper(DoctorMapper.class);
@Mapping(source = &quot;doctor.specialty&quot;, target = &quot;specialization&quot;)
@Mapping(source = &quot;education.degreeName&quot;, target = &quot;degree&quot;)
DoctorDto toDto(Doctor doctor, Education education);

}

我们添加了另一个 @Mapping ​注解,并将其 source ​设置为 Education ​类的 degreeName​,将 target ​设置为 DoctorDto ​类的 degree ​字段。

如果 Education​ 类和 Doctor​ 类包含同名的字段,我们必须让映射器知道使用哪一个,否则它会抛出一个异常。举例来说,如果两个模型都包含一个 id ​字段,我们就要选择将哪个类中的 id ​映射到 DTO 属性中。

子对象映射

多数情况下,POJO 中不会包含基本数据类型,其中往往会包含其它类。比如说,一个 Doctor ​类中会有多个患者类:

public class Patient {
    private int id;
    private String name;
    // getters and setters or builder
}

在 Doctor 中添加一个患者列表 List​:

public class Doctor {
    private int id;
    private String name;
    private String specialty;
    private List<Patient> patientList;
    // getters and setters or builder
}

因为 Patient ​需要转换,为其创建一个对应的 DTO:

public class PatientDto {
    private int id;
    private String name;
    // getters and setters or builder
}

最后,在 DoctorDto​ 中新增一个存储 PatientDto ​的列表:

public class DoctorDto {
    private int id;
    private String name;
    private String degree;
    private String specialization;
    private List<PatientDto> patientDtoList;
    // getters and setters or builder
}

在修改 DoctorMapper ​之前,我们先创建一个支持 Patient​ 和 PatientDto​ 转换的映射器接口:

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

这是一个基本映射器,只会处理几个基本数据类型。

然后,我们再来修改 DoctorMapper​ 处理一下患者列表:

@Mapper(uses = {PatientMapper.class})
public interface DoctorMapper {
DoctorMapper INSTANCE = Mappers.getMapper(DoctorMapper.class);

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

}

因为我们要处理另一个需要映射的类,所以这里设置了 @Mapper ​注解的 uses ​标志,这样现在的 @Mapper​ 就可以使用另一个 @Mapper ​映射器。我们这里只加了一个,但你想在这里添加多少 class/mapper 都可以。

我们已经添加了 uses ​标志,所以在为 DoctorMapper ​接口生成映射器实现时,MapStruct 也会把 Patient​ 模型转换成 PatientDto​ ——因为我们已经为这个任务注册了 PatientMapper​。

编译查看最新想实现代码:

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&lt;PatientDto&gt; patientListToPatientDtoList(List&lt;Patient&gt; list) {
    if ( list == null ) {
        return null;
    }

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

    return list1;
}

}

显然,除了 toDto() ​映射方法外,最终实现中还添加了一个新的映射方法—— patientListToPatientDtoList()​。这个方法是在没有显式定义的情况下添加的,只是因为我们把 PatientMapper ​添加到了 DoctorMapper ​中。

该方法会遍历一个 Patient ​列表,将每个元素转换为 PatientDto​,并将转换后的对象添加到 DoctorDto ​对象内中的列表中。

更新现有实例

有时,我们希望用 DTO 的最新值更新一个模型中的属性,对目标对象(我们的例子中是 DoctorDto​)使用 @MappingTarget ​注解,就可以更新现有的实例.

@Mapper(uses = {PatientMapper.class})
public interface DoctorMapper {
DoctorMapper INSTANCE = Mappers.getMapper(DoctorMapper.class);

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

}

重新生成实现代码,就可以得到 updateModel() ​方法:

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

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

}

值得注意的是,由于患者列表是该模型中的子实体,因此患者列表也会进行更新。

数据类型转换

数据类型映射

MapStruct 支持 source ​和 target ​属性之间的数据类型转换。它还提供了基本类型及其相应的包装类之间的自动转换。

自动类型转换适用于:

  • 基本类型及其对应的包装类之间。比如, int​ 和 Integer​, float​ 和 Float​, long​ 和 Long​,boolean​ 和 Boolean​ 等。
  • 任意基本类型与任意包装类之间。如 int​ 和 long​, byte​ 和 Integer​ 等。
  • 所有基本类型及包装类与 String ​之间。如 boolean​ 和 String​, Integer​ 和 String​, float​ 和 String​ 等。
  • 枚举和 String ​之间。
  • Java 大数类型(java.math.BigInteger​, java.math.BigDecimal​) 和 Java 基本类型(包括其包装类)与 String ​之间。
  • 其它情况详见 MapStruct 官方文档

因此,在生成映射器代码的过程中,如果源字段和目标字段之间属于上述任何一种情况,则 MapStrcut 会自行处理类型转换。

我们修改 PatientDto​ ,新增一个 dateofBirth ​字段:

public class PatientDto {
    private int id;
    private String name;
    private LocalDate dateOfBirth;
    // getters and setters or builder
}

另一方面,加入 Patient​ 对象中有一个 String​ 类型的 dateOfBirth​ :

public class Patient {
    private int id;
    private String name;
    private String dateOfBirth;
    // getters and setters or builder
}

在两者之间创建一个映射器:

@Mapper
public interface PatientMapper {
@Mapping(source = &quot;dateOfBirth&quot;, target = &quot;dateOfBirth&quot;, dateFormat = &quot;dd/MMM/yyyy&quot;)
Patient toModel(PatientDto patientDto);

}

当对日期进行转换时,我们也可以使用 dateFormat​ 设置格式声明。生成的实现代码形式大致如下:

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(&quot;dd/MMM/yyyy&quot;)
                            .format(patientDto.getDateOfBirth()));
    }
    patient.id(patientDto.getId());
    patient.name(patientDto.getName());

    return patient.build();
}

}

可以看到,这里使用了 dateFormat​ 声明的日期格式。如果我们没有声明格式的话,MapStruct 会使用 LocalDate ​的默认格式,大致如下:

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

数字格式转换

上面的例子中可以看到,在进行日期转换的时候,可以通过 dateFormat ​标志指定日期的格式。

除此之外,对于数字的转换,也可以使用 numberFormat ​指定显示格式:

   // 数字格式转换示例
   @Mapping(source = "price", target = "price", numberFormat = "$#.00")

枚举映射

枚举映射的工作方式与字段映射相同。MapStruct 会对具有相同名称的枚举进行映射,这一点没有问题。但是,对于具有不同名称的枚举项,我们需要使用 @ValueMapping ​注解。同样,这与普通类型的 @Mapping ​注解也相似。

我们先创建两个枚举。第一个是 PaymentType​:

public enum PaymentType {
    CASH,
    CHEQUE,
    CARD_VISA,
    CARD_MASTER,
    CARD_CREDIT
}

比如说,这是一个应用内可用的支付方式,现在我们要根据这些选项创建一个更一般、有限的识图:

public enum PaymentTypeView {
    CASH,
    CHEQUE,
    CARD
}

现在,我们创建这两个 enum ​之间的映射器接口:

@Mapper
public interface PaymentTypeMapper {
PaymentTypeMapper INSTANCE = Mappers.getMapper(PaymentTypeMapper.class);

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

}

这个例子中,我们设置了一般性的 CARD ​值,和更具体的 CARD_VISA​, CARD_MASTER​ 和 CARD_CREDIT​ 。两个枚举间的枚举项数量不匹配—— PaymentType​ 有 5 个值,而 PaymentTypeView​ 只有 3 个。

为了在这些枚举项之间建立桥梁,我们可以使用 @ValueMappings ​注解,该注解中可以包含多个 @ValueMapping ​注解。这里,我们将 source ​设置为三个具体枚举项之一,并将 target ​设置为 CARD​。

MapStruct 自然会处理这些情况:

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( &quot;Unexpected enum constant: &quot; + paymentType );
    }
    return paymentTypeView;
}

}

CASH ​和 CHEQUE ​默认转换为对应值,特殊的 CARD​ 值通过 switch ​循环处理。

但是,如果你要将很多值转换为一个更一般的值,这种方式就有些不切实际了。其实我们不必手动分配每一个值,只需要让 MapStruct 将所有剩余的可用枚举项(在目标枚举中找不到相同名称的枚举项),直接转换为对应的另一个枚举项。

可以通过 MappingConstants ​实现这一点:

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

在这个例子中,完成默认映射之后,所有剩余(未匹配)的枚举项都会映射为 CARD​:

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

}

还有一种选择是使用 ANY UNMAPPED​:

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

采用这种方式时,MapStruct 不会像前面那样先处理默认映射,再将剩余的枚举项映射到 target ​值。而是,直接将所有未通过 @ValueMapping ​注解做显式映射的值都转换为 target ​值。

集合映射

简单来说,使用 MapStruct 处理集合映射的方式与处理简单类型相同。

我们创建一个简单的接口或抽象类并声明映射方法。 MapStruct 将根据我们的声明自动生成映射代码。 通常,生成的代码会遍历源集合,将每个元素转换为目标类型,并将每个转换后元素添加到目标集合中。

List 映射

我们先定义一个新的映射方法:

@Mapper
public interface DoctorMapper {
    List<DoctorDto> map(List<Doctor> doctor);
}

生成的代码大致如下:

public class DoctorMapperImpl implements DoctorMapper {
@Override
public List&lt;DoctorDto&gt; map(List&lt;Doctor&gt; doctor) {
    if ( doctor == null ) {
        return null;
    }

    List&lt;DoctorDto&gt; list = new ArrayList&lt;DoctorDto&gt;( doctor.size() );
    for ( Doctor doctor1 : doctor ) {
        list.add( doctorToDoctorDto( doctor1 ) );
    }

    return list;
}

protected DoctorDto doctorToDoctorDto(Doctor doctor) {
    if ( doctor == null ) {
        return null;
    }

    DoctorDto doctorDto = new DoctorDto();

    doctorDto.setId( doctor.getId() );
    doctorDto.setName( doctor.getName() );
    doctorDto.setSpecialization( doctor.getSpecialization() );

    return doctorDto;
}

}

可以看到,MapStruct 为我们自动生成了从 Doctor ​到 DoctorDto ​的映射方法。

但是需要注意,如果我们在 DTO 中新增一个字段 fullName​,生成代码时会出现错误:

警告: Unmapped target property: "fullName".

基本上,这意味着 MapStruct 在当前情况下无法为我们自动生成映射方法。因此,我们需要手动定义 Doctor ​和 DoctorDto ​之间的映射方法。具体参考之前的小节。

Set 和 Map 映射

Set 与 Map 型数据的处理方式与 List 相似。按照以下方式修改 DoctorMapper​:

@Mapper
public interface DoctorMapper {
Set&lt;DoctorDto&gt; setConvert(Set&lt;Doctor&gt; doctor);

Map&lt;String, DoctorDto&gt; mapConvert(Map&lt;String, Doctor&gt; doctor);

}

生成的最终实现代码如下:

public class DoctorMapperImpl implements DoctorMapper {
@Override
public Set&lt;DoctorDto&gt; setConvert(Set&lt;Doctor&gt; doctor) {
    if ( doctor == null ) {
        return null;
    }

    Set&lt;DoctorDto&gt; set = new HashSet&lt;DoctorDto&gt;( Math.max( (int) ( doctor.size() / .75f ) + 1, 16 ) );
    for ( Doctor doctor1 : doctor ) {
        set.add( doctorToDoctorDto( doctor1 ) );
    }

    return set;
}

@Override
public Map&lt;String, DoctorDto&gt; mapConvert(Map&lt;String, Doctor&gt; doctor) {
    if ( doctor == null ) {
        return null;
    }

    Map&lt;String, DoctorDto&gt; map = new HashMap&lt;String, DoctorDto&gt;( Math.max( (int) ( doctor.size() / .75f ) + 1, 16 ) );

    for ( java.util.Map.Entry&lt;String, Doctor&gt; entry : doctor.entrySet() ) {
        String key = entry.getKey();
        DoctorDto value = doctorToDoctorDto( entry.getValue() );
        map.put( key, value );
    }

    return map;
}

protected DoctorDto doctorToDoctorDto(Doctor doctor) {
    if ( doctor == null ) {
        return null;
    }

    DoctorDto doctorDto = new DoctorDto();

    doctorDto.setId( doctor.getId() );
    doctorDto.setName( doctor.getName() );
    doctorDto.setSpecialization( doctor.getSpecialization() );

    return doctorDto;
}

}

与 List 映射类似,MapStruct 自动生成了 Doctor ​转换为 DoctorDto ​的映射方法。

集合映射策略

很多场景中,我们需要对具有父子关系的数据类型进行转换。通常来说,会有一个数据类型(父),其字段是另一个数据类型(子)的集合。

对于这种情况,MapStruct 提供了一种方法来选择如何将子类型设置或添加到父类型中。具体来说,就是 @Mapper ​注解中的 collectionMappingStrategy ​属性,该属性可以取值为 ACCESSOR_ONLY​, SETTER_PREFERRED​, ADDER_PREFERRED​ 或 TARGET_IMMUTABLE​。

这些值分别表示不同的为子类型集合赋值的方式。默认值是 ACCESSOR_ONLY​,这意味着只能使用访问器来设置子集合。

当父类型中的 Collection 字段 setter ​方法不可用,但我们有一个子类型 add ​方法时,这个选项就派上用场了;另一种有用的情况是父类型中的 Collection 字段是不可变的。

我们新建一个类:

public class Hospital {
    private List<Doctor> doctors;
    // getters and setters or builder
}

同时定义一个映射目标 DTO 类,同时定义子类型集合字段的 getter、setter 和 adder:

public class HospitalDto {
private List&lt;DoctorDto&gt; doctors;

	// 子类型集合字段getter
public List&lt;DoctorDto&gt; getDoctors() {
    return doctors;
}
	// 子类型集合字段setter
public void setDoctors(List&lt;DoctorDto&gt; doctors) {
    this.doctors = doctors;
}
	// 子类型数据adder
public void addDoctor(DoctorDto doctorDTO) {
    if (doctors == null) {
        doctors = new ArrayList&lt;&gt;();
    }

    doctors.add(doctorDTO);
}

}

创建对应的映射器:

@Mapper(uses = DoctorMapper.class)
public interface HospitalMapper {
    HospitalMapper INSTANCE = Mappers.getMapper(HospitalMapper.class);
HospitalDto toDto(Hospital hospital);

}

生成的最终实现代码为:

public class HospitalMapperImpl implements HospitalMapper {
@Override
public HospitalDto toDto(Hospital hospital) {
    if ( hospital == null ) {
        return null;
    }

    HospitalDto hospitalDto = new HospitalDto();

    hospitalDto.setDoctors( doctorListToDoctorDtoList( hospital.getDoctors() ) );

    return hospitalDto;
}

}

可以看到,在默认情况下采用的策略是 ACCESSOR_ONLY​,使用 setter 方法 setDoctors() ​向 HospitalDto ​对象中写入列表数据。

相对的,如果使用 ADDER_PREFERRED​ 作为映射策略:

@Mapper(collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED,
        uses = DoctorMapper.class)
public interface HospitalMapper {
    HospitalMapper INSTANCE = Mappers.getMapper(HospitalMapper.class);
HospitalDto toDto(Hospital hospital);

}

此时,会使用 adder 方法逐个将转换后的子类型 DTO 对象加入父类型的集合字段中。

public class CompanyMapperAdderPreferredImpl implements CompanyMapperAdderPreferred {
private final EmployeeMapper employeeMapper = Mappers.getMapper( EmployeeMapper.class );

@Override
public CompanyDTO map(Company company) {
    if ( company == null ) {
        return null;
    }

    CompanyDTO companyDTO = new CompanyDTO();

    if ( company.getEmployees() != null ) {
        for ( Employee employee : company.getEmployees() ) {
            companyDTO.addEmployee( employeeMapper.map( employee ) );
        }
    }

    return companyDTO;
}

}

如果目标 DTO 中既没有 setter ​方法也没有 adder ​方法,会先通过 getter ​方法获取子类型集合,再调用集合的对应接口添加子类型对象。

可以在参考文档中看到不同类型的 DTO 定义(是否包含 setter 方法或 adder 方法),采用不同的映射策略时,所使用的添加子类型到集合中的方式。

目标集合实现类型

MapStruct 支持将集合接口作为映射方法的目标类型。

在这种情况下,在生成的代码中会使用一些集合接口默认实现。 例如,上面的示例中,List ​的默认实现是 ArrayList​。

常见接口及其对应的默认实现如下:

Interface type Implementation type
Collection ArrayList
List ArrayList
Map HashMap
SortedMap TreeMap
ConcurrentMap ConcurrentHashMap

你可以在参考文档中找到 MapStruct 支持的所有接口列表,以及每个接口对应的默认实现类型。

进阶操作

依赖注入

到目前为止,我们一直在通过 getMapper() ​方法访问生成的映射器:

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

但是,如果你使用的是 Spring,只需要简单修改映射器配置,就可以像常规依赖项一样注入映射器。

修改 DoctorMapper​ 以支持 Spring 框架:

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

@Mapper ​注解中添加 (componentModel = "spring")​,是为了告诉 MapStruct,在生成映射器实现类时,我们希望它能支持通过 Spring 的依赖注入来创建。现在,就不需要在接口中添加 INSTANCE​ 字段了。

这次生成的 DoctorMapperImpl​ 会带有 @Component​ 注解:

@Component
public class DoctorMapperImpl implements DoctorMapper {}

只要被标记为 @Component​,Spring 就可以把它作为一个 bean 来处理,你就可以在其它类(如控制器)中通过 @Autowire ​注解来使用它:

@Controller
public class DoctorController() {
    @Autowired
    private DoctorMapper doctorMapper;
}

如果你不使用 Spring, MapStruct 也支持 Java CDI

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

添加默认值

@Mapping​ 注解有两个很实用的标志就是常量 constant​ 和默认值 defaultValue​ 。无论 source ​如何取值,都将始终使用常量值; 如果 source ​取值为 null​,则会使用默认值。

修改一下 DoctorMapper​ ,添加一个 constant​ 和一个 defaultValue​ :

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

如果 specialty ​不可用,我们会替换为 "Information Not Available" ​字符串,此外,我们将 id ​硬编码为 -1​。

生成代码如下:

@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(&quot;Information Not Available&quot;);
    }
    doctorDto.setPatientDtoList(patientListToPatientDtoList(doctor.getPatientList()));
    doctorDto.setName(doctor.getName());

    doctorDto.setId(-1);

    return doctorDto;
}

}

可以看到,如果 doctor.getSpecialty()​ 返回值为 null​,则将 specialization ​设置为我们的默认信息。无论任何情况,都会对 id ​赋值,因为这是一个 constant​。

添加表达式

MapStruct 甚至允许在 @Mapping ​注解中输入 Java 表达式。你可以设置 defaultExpression​ ( source​ 取值为 null ​时生效),或者一个 expression​(类似常量,永久生效)。

Doctor​ 和 DoctorDto ​两个类中都加了两个新属性,一个是 String​ 类型的 externalId​ ,另一个是 LocalDateTime ​类型的 appointment​ ,两个类大致如下:

public class Doctor {
private int id;
private String name;
private String externalId;
private String specialty;
private LocalDateTime availability;
private List&lt;Patient&gt; patientList;
// getters and setters or builder

}

public class DoctorDto {

    private int id;
    private String name;
    private String externalId;
    private String specialization;
    private LocalDateTime availability;
    private List<PatientDto> patientDtoList;
    // getters and setters or builder
}

修改 DoctorMapper​:

@Mapper(uses = {PatientMapper.class}, componentModel = "spring", imports = {LocalDateTime.class, UUID.class})
public interface DoctorMapper {
@Mapping(target = &quot;externalId&quot;, expression = &quot;java(UUID.randomUUID().toString())&quot;)
@Mapping(source = &quot;doctor.availability&quot;, target = &quot;availability&quot;, defaultExpression = &quot;java(LocalDateTime.now())&quot;)
@Mapping(source = &quot;doctor.patientList&quot;, target = &quot;patientDtoList&quot;)
@Mapping(source = &quot;doctor.specialty&quot;, target = &quot;specialization&quot;)
DoctorDto toDtoWithExpression(Doctor doctor);

}

可以看到,这里将 externalId ​的值设置为 java(UUID.randomUUID().toString())​ ,如果源对象中没有 availability​ 属性,则会把目标对象中的 availability​ 设置为一个新的 LocalDateTime ​对象。

由于表达式只是字符串,我们必须在表达式中指定使用的类。但是这里的表达式并不是最终执行的代码,只是一个字母的文本值。因此,我们要在 @Mapper​ 中添加 imports = {LocalDateTime.class, UUID.class}​ 。

添加自定义方法

到目前为止,我们一直使用的策略是添加一个“占位符”方法,并期望 MapStruct 能为我们实现它。其实我们还可以向接口中添加自定义的 default ​方法,也可以通过 default ​方法直接实现一个映射。然后我们可以通过实例直接调用该方法,没有任何问题。

为此,我们创建一个 DoctorPatientSummary ​类,其中包含一个 Doctor​ 及其 Patient ​列表的汇总信息:

public class DoctorPatientSummary {
    private int doctorId;
    private int patientCount;
    private String doctorName;
    private String specialization;
    private String institute;
    private List<Integer> patientIds;
    // getters and setters or builder
}

接下来,我们在 DoctorMapper ​中添加一个 default ​方法,该方法会将 Doctor​ 和 Education​ 对象转换为一个 DoctorPatientSummary​:

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

}

这里使用了 Builder 模式创建 DoctorPatientSummary ​对象。

在 MapStruct 生成映射器实现类之后,你就可以使用这个实现方法,就像访问任何其它映射器方法一样:

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

创建自定义映射器

前面我们一直是通过接口来设计映射器功能,其实我们也可以通过一个带 @Mapper​ 的 abstract​ 类来实现一个映射器。MapStruct 也会为这个类创建一个实现,类似于创建一个接口实现。

我们重写一下前面的示例,这一次,我们将它修改为一个抽象类:

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

}

你可以用同样的方式使用这个映射器。由于限制较少,使用抽象类可以在创建自定义实现时给我们更多的控制和选择。另一个好处是可以添加 @BeforeMapping ​和 @AfterMapping ​方法。

@BeforeMapping 和 @AfterMapping

为了进一步控制和定制化,我们可以定义 @BeforeMapping​ 和 @AfterMapping ​方法。显然,这两个方法是在每次映射之前和之后执行的。也就是说,在最终的实现代码中,会在两个对象真正映射之前和之后添加并执行这两个方法。

可以在 DoctorCustomMapper ​中添加两个方法:

@Mapper(uses = {PatientMapper.class}, componentModel = "spring")
public abstract class DoctorCustomMapper {
@BeforeMapping
protected void validate(Doctor doctor) {
    if(doctor.getPatientList() == null){
        doctor.setPatientList(new ArrayList&lt;&gt;());
    }
}

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

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

}

基于该抽象类生成一个映射器实现类:

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

}

可以看到, validate()​ 方法会在 DoctorDto​ 对象实例化之前执行,而 updateResult() ​方法会在映射结束之后执行。

映射异常处理

异常处理是不可避免的,应用程序随时会产生异常状态。MapStruct 提供了对异常处理的支持,可以简化开发者的工作。

考虑这样一个场景,我们想在 Doctor​ 映射为 DoctorDto ​之前校验一下 Doctor​ 的数据。我们新建一个独立的 Validator​ 类进行校验:

public class Validator {
    public int validateId(int id) throws ValidationException {
        if(id == -1){
            throw new ValidationException("Invalid value in ID");
        }
        return id;
    }
}

我们修改一下 DoctorMapper​ 以使用 Validator​ 类,无需指定实现。跟之前一样, 在 @Mapper ​使用的类列表中添加该类。我们还需要做的就是告诉 MapStruct 我们的 toDto()​ 会抛出 throws ValidationException​:

@Mapper(uses = {PatientMapper.class, Validator.class}, componentModel = "spring")
public interface DoctorMapper {
@Mapping(source = &quot;doctor.patientList&quot;, target = &quot;patientDtoList&quot;)
@Mapping(source = &quot;doctor.specialty&quot;, target = &quot;specialization&quot;)
DoctorDto toDto(Doctor doctor) throws ValidationException;

}

最终生成的映射器代码如下:

@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 自动将 doctorDto ​的 id ​设置为 Validator ​实例的方法返回值。它还在该方法签名中添加了一个 throws 子句。

注意,如果映射前后的一对属性的类型与 Validator ​中的方法出入参类型一致,那该字段映射时就会调用 Validator ​中的方法,所以该方式请谨慎使用。

映射配置

MapStruct 为编写映射器方法提供了一些非常有用的配置。多数情况下,如果我们已经定义了两个类型之间的映射方法,当我们要添加相同类型之间的另一个映射方法时,我们往往会直接复制已有方法的映射配置。

其实我们不必手动复制这些注解,只需要简单的配置就可以创建一个相同/相似的映射方法。

继承配置

我们回顾一下“更新现有实例”,在该场景中,我们创建了一个映射器,根据 DoctorDto 对象的属性更新现有的 Doctor 对象的属性值:

@Mapper(uses = {PatientMapper.class})
public interface DoctorMapper {
DoctorMapper INSTANCE = Mappers.getMapper(DoctorMapper.class);

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

}

假设我们还有另一个映射器,将 DoctorDto ​转换为 Doctor​ :

@Mapper(uses = {PatientMapper.class, Validator.class})
public interface DoctorMapper {
@Mapping(source = &quot;doctorDto.patientDtoList&quot;, target = &quot;patientList&quot;)
@Mapping(source = &quot;doctorDto.specialization&quot;, target = &quot;specialty&quot;)
Doctor toModel(DoctorDto doctorDto);

}

这两个映射方法使用了相同的注解配置, source ​和 target ​都是相同的。其实我们可以使用 @InheritConfiguration ​注释,从而避免这两个映射器方法的重复配置。

如果对一个方法添加 @InheritConfiguration​ 注解,MapStruct 会检索其它的已配置方法,寻找可用于当前方法的注解配置。一般来说,这个注解都用于 mapping ​方法后面的 update ​方法,如下所示:

@Mapper(uses = {PatientMapper.class, Validator.class}, componentModel = "spring")
public interface DoctorMapper {
@Mapping(source = &quot;doctorDto.specialization&quot;, target = &quot;specialty&quot;)
@Mapping(source = &quot;doctorDto.patientDtoList&quot;, target = &quot;patientList&quot;)
Doctor toModel(DoctorDto doctorDto);

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

}

继承逆向配置

还有另外一个类似的场景,就是编写映射函数将 Model 转为 ​DTO​,以及将 DTO 转为 ​Model​。如下面的代码所示,我们必须在两个函数上添加相同的注释。

@Mapper(componentModel = "spring")
public interface PatientMapper {
@Mapping(source = &quot;dateOfBirth&quot;, target = &quot;dateOfBirth&quot;, dateFormat = &quot;dd/MMM/yyyy&quot;)
Patient toModel(PatientDto patientDto);

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

}

两个方法的配置不会是完全相同的,实际上,它们应该是相反的。将 Model 转为 ​DTO​,以及将 DTO 转为 ​Model​——映射前后的字段相同,但是源属性字段与目标属性字段是相反的。

我们可以在第二个方法上使用 @InheritInverseConfiguration ​注解,避免写两遍映射配置:

@Mapper(componentModel = "spring")
public interface PatientMapper {
@Mapping(source = &quot;dateOfBirth&quot;, target = &quot;dateOfBirth&quot;, dateFormat = &quot;dd/MMM/yyyy&quot;)
Patient toModel(PatientDto patientDto);

@InheritInverseConfiguration
PatientDto toDto(Patient patient);

}

这两个 Mapper 生成的代码是相同的。

总结

在本文中,我们探讨了 MapStruct——一个用于创建映射器类的库。从基本映射到自定义方法和自定义映射器,此外, 我们还介绍了 MapStruct 提供的一些高级操作选项,包括依赖注入,数据类型映射、枚举映射和表达式使用。

MapStruct 提供了一个功能强大的集成插件,可减少开发人员编写模板代码的工作量,使创建映射器的过程变得简单快捷。

如果要探索更多、更详细的使用方式,可以参考 MapStruct 官方提供的参考指南


posted @ 2025-02-19 23:09  红豆绿豆abc  阅读(197)  评论(0)    收藏  举报