Skip to content

MapStruct

1. 概述

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

官方地址:https://mapstruct.org/

参考指南:https://mapstruct.org/documentation/reference-guide/

2. 配置

MapStruct 是基于 JSR 269 的 Java 注释处理器,可以在命令行构建(javac,Ant,Maven 等)以及 IDE 中使用,它包含以下组件:

  • org.mapstruct:mapstruct:包含所需的注释,例如 @Mapping

  • org.mapstruct:mapstruct-processor:包含生成映射器实现的注释处理器

2.1 Maven

对于基于 Maven 的项目,需要将以下内容添加到 POM 文件中,以便使用 MapStruct:

xml
<properties>
    <org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
</properties>

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

<build>
<plugins>
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.8.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>

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

2.2 Gradle

将以下内容添加到 Gradle 构建文件中以启用 MapStruct:

groovy
plugins {
    id "com.diffplug.eclipse.apt" version "3.26.0" // Only for Eclipse
}

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

    // If you are using mapstruct in test code
    testAnnotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"
}

2.3 配置选项

可以使用 注释处理器选项 配置映射结构代码生成器。

当直接调用 javac 时,这些选项以 -Akey=value 的形式传递给编译器。当通过 Maven 使用 MapStruct 时,可以在 Maven 处理器插件的配置中使用任何处理器选项,如下所示:

Maven 配置

xml
<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>
        <!-- due to problem in maven-compiler-plugin, for verbose mode add showWarnings -->
        <showWarnings>true</showWarnings>
        <compilerArgs>
            <arg>
                -Amapstruct.suppressGeneratorTimestamp=true
            </arg>
            <arg>
                -Amapstruct.suppressGeneratorVersionInfoComment=true
            </arg>
            <arg>
                -Amapstruct.verbose=true
            </arg>
        </compilerArgs>
    </configuration>
</plugin>

Gradle 配置

groovy
compileJava {
    options.compilerArgs += [
            '-Amapstruct.suppressGeneratorTimestamp=true',
            '-Amapstruct.suppressGeneratorVersionInfoComment=true',
            '-Amapstruct.verbose=true'
    ]
}

存在以下一些配置器选项配置

选项说明默认
mapstruct.suppressGeneratorTimestamp如果设置为 true,将禁止在生成的映射器类中创建时间戳false
mapstruct.verbose设置为 true 会在编译时打印相关转换信息,同时还需要在 maven 插件中配置参数 <showWarnings>true</showWarnings>false
mapstruct.suppressGeneratorVersionInfoComment设置为 true,将禁止在生成的映射器类中创建属性false
mapstruct.defaultComponentModel基于生成映射器的组件模型的名称,支持:defaultcdispringjsr330,默认 default,使用 spring 可以使用 @Autowired 方式注入default
mapstruct.unmappedTargetPolicy在映射方法的目标对象的属性未填充源值时应用的默认报告策略,支持 ERRORWARNIGNOREWARN
mapstruct.unmappedSourcePolicy在映射方法的源对象的属性未填充目标值时应用的默认报告策略,支持 ERRORWARNIGNOREWARN
mapstruct.unmappedSourcePolicy如果设置为 true,则 MapStruct 在执行映射时将不使用构建器模式。这相当于对所有映射器执行操作 @Mapper( builder = @Builder( disableBuilder = true ) )false

3. 使用

3.1 基础使用

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

java
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 就会知道这是两个类之间的映射器。

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

    DoctorDto toDto(Doctor doctor);
}

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

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

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

java
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 实例,可以这样写:

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

3.2 不同字段间映射

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

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

不同属性名称

对于名称不一致的情况,可以使用 @Mapping 注解,设置其内部的 source 和 target 标记分别指向不一样的两个字段;

比如 Doctor 类中账号名称为 acctNo,而 DoctorDto 类中账号名称是 acctNumber,那么在定义的 DoctorMapper 接口的 toDto 方法上,增加一个 @Mapping 注解,如下所示:

java

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

    @Mapping(source = "acctNo", target = "acctNumber")
    DoctorDto toDto(Doctor doctor);
}

多个源类

有时,单个类不足以构建出目标对象,我们可能希望将多个类中的值聚合为一个模型,供终端用户使用。这也可以通过在 @Mapping 注解中设置适当的标志来完成。示例代码如下:

java
public class Education {
    private String degreeName;
    private String institute;
    private Integer yearOfPassing;
    // getters and setters or builder
}
java
@Mapper
public interface DoctorMapper {
    DoctorMapper INSTANCE = Mappers.getMapper(DoctorMapper.class);

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

提示

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

子对象映射

多数情况下,POJO 中不会只包含基本数据类型,其中往往会包含其它类,需要对包含的其它类进行映射时,首先需要对该内部类创建对应的映射接口,然后在引用类的映射接口中,给注解 @Mapper 添加上 uses 标志,示例代码如下:

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

3.3 更新现有实例

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

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

3.4 数据类型转换

自动类型映射

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

自动类型转换适用于:

  • 基本类型及对应的包装类之间;比如 intIntegerfloatFloatbooleanBoolean 等;

  • 任意基本类型与任意包装类之间;如 intlongbyteInteger 等;

  • 所有基本类型及包装类与 String 之间;如 booleanStringIntegerString 等;

  • 枚举和 String 之间;

  • Java大数类型(java.math.BigIntegerjava.math.BigDecimal) 与 String 之间。

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

日期格式映射

当对日期进行转换时,我们也可以使用 dateFormat 设置格式声明。

java
@Mapper
public interface PatientMapper {

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

数字格式映射

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

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

枚举映射

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

java
public enum PaymentType {
    CASH,
    CHEQUE,
    CARD_VISA,
    CARD_MASTER,
    CARD_CREDIT
}
java
public enum PaymentTypeView {
    CASH,
    CHEQUE,
    CARD
}
java
@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);
}

上述代码中,将很多值转换为一个更一般的值,其实我们不必手动分配每一个值,只需要让 MapStruct 将所有剩余的可用枚举项(在目标枚举中找不到相同名称的枚举项),直接转换为对应的另一个枚举项。

可以通过 MappingConstants 实现这一点:

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

还有一种选择是使用 ANY UNMAPPED

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

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

3.5 集合映射

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

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

java
@Mapper
public interface DoctorMapper {
    //List映射
    List<DoctorDto> map(List<Doctor> doctor);

    //Set映射
    Set<DoctorDto> setConvert(Set<Doctor> doctor);

    //Map映射
    Map<String, DoctorDto> mapConvert(Map<String, Doctor> doctor);
}

4. 进阶操作

4.1 依赖注入

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

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

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

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

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

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

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

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

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

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

4.2 添加默认值

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

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

4.2 添加表达式

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

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

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

4.3 添加自定义方法

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

java

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

4.4 创建自定义映射器

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

java
@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 方法。

4.5 @BeforeMapping 和 @AfterMapping

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

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

5. 注意事项

集成 MapStruct 时,如果项目中还使用到了 Lombok,那么集成后可能会出现以下问题:

编译项目时发现,Lombok 的注解不生效,并不会自动生成 getter、setter、toString 等一系列方法,MapStruct 自动生成的代码中只会空创建一个类,并不会进行属性的赋值;

解决方法如下:

  • pom 文件中调整依赖的顺序,先引入 Lombok,然后引入 MapStruct;

  • 插件 maven-compiler-plugin 中需要先引入 Lombok 路径,然后引入 MapStruct 的路径。

配置实例如下:

xml
<dependencies>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

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

<build>
<plugins>
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.8.1</version>
        <configuration>
            <source>1.8</source>
            <target>1.8</target>
            <annotationProcessorPaths>
                <path>
                    <groupId>org.projectlombok</groupId>
                    <artifactId>lombok</artifactId>
                    <version>${lombok.version}</version>
                </path>
                <path>
                    <groupId>org.mapstruct</groupId>
                    <artifactId>mapstruct-processor</artifactId>
                    <version>${mapstruct.version}</version>
                </path>
            </annotationProcessorPaths>
        </configuration>
    </plugin>
</plugins>
</build>