封面来源:本文封面来源于 MapStruct 官网,如有侵权,请联系删除。

源码仓库:mofan212/mapstruct-demo

0. 阅读前言

本文内容基于 MapStruct 官方文档进行翻译并拓展,力求保证文章准确性,但博主英语水平堪忧,在某些处理上仍有很多不足,还望各位看官不吝赐教。

翻译工作极其耗时,因此本文禁止转载! 🙏

官网与推荐

MapStruct 官网:MapStruct

MapStruct 官方文档:Reference Guide

不错的 MapStruct 博客系列:属性映射工具——MapStruct(一)

1. MapStruct 概述

1.1 什么是 MapStruct

MapStruct 是一个Java注释处理器,用于生成类型安全的、高性能的、无依赖的 Bean 映射类。

在实际开发中经常会将各种对象相互转换,比如 DTO 转 BO,DTO 转 VO 等等,最简单的做法就是 new 对象,然后对属性值进行一个个地 GET/SET,但这样既繁琐又无聊。

如果有一个专门用来解决转换问题的工具就很棒了,MapStruct 就是这样的一个属性映射工具,只需要定义一个 Mapper 接口,MapStruct 就会自动实现这个映射接口,避免了复杂繁琐的映射实现。

补充与拓展

关于 Java 中的各种 O,可以参考下面这两篇文章:

java中各种O的含义(PO,DO,VO,TO,QO,BO,DAO,DTO,POJO)

浅析DTO、VO、DO、PO的概念

1.2 MapStruct 配置选项

MapStruct 代码生成器可以使用注释处理器选项进行配置。

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

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

关于存在的选项,可以参看官方文档 Configuration options,本文会在使用到的地方再具体讲解。

2. 定义映射器

2.1 Hello MapStruct

创建一个 Maven 项目,在 pom.xml 文件中添加以下配置:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<properties>
<org.mapstruct.version>1.4.2.Final</org.mapstruct.version>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>

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

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.12</version>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.1</version>
<scope>test</scope>
</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> <!-- depending on your project -->
<target>1.8</target> <!-- depending on your project -->
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
<!--使用 Lombok 后,这里也要添加-->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.12</version>
</path>
<!-- other annotation processors -->
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>

注意: Maven 插件要使用 3.6.0 版本及以上、Lombok 使用 1.16.16 版本及以上。不然就会出现以下错误:

java: No property named "numberOfSeats" exists in source parameter(s). Did you mean "null"?

如果 Lombok 版本为 1.18.16 及以上,请再添加上:

1
2
3
4
5
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.1.0</version>
</path>

创建一个 Car 实体类:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* @author mofan 2021/2/2
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Car {
private String make;
private int numberOfSeats;
private String type;
}

再创建一个 CarDto

1
2
3
4
5
6
7
8
9
10
11
12
/**
* @author mofan 2021/2/2
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class CarDto {
private String make;
private int seatCount;
private String type;
}

还需要创建一个接口,用于对实体进行转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @author mofan 2021/2/2
*/
@Mapper
public interface CarMapper {

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

/**
* 将 Car 转换成 CarDto
* @param car Car 实例
* @return 对应的 DTO
*/
@Mapping(source = "numberOfSeats", target = "seatCount")
CarDto carToCarDto(Car car);
}

最后创建测试类测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* @author mofan 2021/2/2
*/
public class CarMapperTest {
@Test
public void shouldMapCarToDto() {
// given
Car car = new Car( "Morris", 5, "Sedan");

// when
CarDto carDto = CarMapper.INSTANCE.carToCarDto(car);

// then
assertNotNull(carDto);
assertEquals("Morris", carDto.getMake());
assertEquals(5, carDto.getSeatCount());
assertEquals("Sedan", carDto.getType());
}
}

运行测试方法,测试通过! 🎉

为什么写一个接口后就能够帮我们实现实体的转换呢?

其实 Java 实体映射工具有两种:

1、在运行期,使用反射调用 Setter/Getter 方法或者是直接对成员变量赋值,这种方式通过 invoke() 执行赋值。一般会使用 Beanutil、Javassist 等开源库进行实现,代表产品有 Dozer 和 ModelMaper。

2、在编译期,动态生成含有逐一赋值的 class 文件 ,在运行时直接调用该 class 文件,这类的代表有 MapStruct、Selma、Orika。

如果不使用任何类库,手动实现实体的转换,可以使用反射(对应第一种方式),也可以对调用每个属性的 Get/Set 方法进行赋值(对应第二种方式)。

还是有点懵?

查看刚刚项目的 target 目录,可以看到:

生成的CarMapperImpl实现类

自动生成了 CarMapperImpl 实现类,其内部仍然使用的是逐一复制的方式,只不过不用手动去写了。

正因如此,原实体和目标实体最好都编写各属性对应的 Getter 和 Setter 方法,以及它们各自的无参构造器。除此之外,别瞎使用 Lombok 中的 @Accessors 注解。

由于这个 class 文件是在编译期生成的,相比使用反射来实现实体映射,这种方式的效率更高。

更多 Bean 映射工具的性能对比,可以参考:5种常见Bean映射工具的性能比对

注意: 由于这些实现类会在编译期生成并存放在 target 目录中,因此当更改实体信息或映射逻辑时,需要重新编译或删除 target 目录再执行程序,否则更改可能不会生效。

注解介绍

在上述 Demo 中,使用到两个注解:

@Mapper:作用于接口上,告诉 MapStruct 需要编译期生成哪个接口的实现类。

在生成的实现类中,当原实体的属性与其目标实体对应的属性名称 相同 时,就直接被 隐式映射。如果名称不一样,就需要使用到 @Mapping 注解。

@Mapping:常作用于方法上,用于原实体与目标实体属性名称不相同时指定映射关系。

2.2 关闭隐式映射

MapStruct 存在默认的隐式映射,也可以关闭它,只需在接口对应的方法上添加注解:

1
@BeanMapping(ignoreByDefault = true)

还是针对最开始的 Demo,在接口中的唯一方法上添加这个注解:

1
2
3
4
@BeanMapping(ignoreByDefault = true)
@Mapping(source = "numberOfSeats", target = "seatCount")
@Mapping(source = "make", target = "make")
CarDto carToCarDto(Car car);

在修改一下测试方法,查看打印结果:

1
2
3
4
5
6
7
8
@Test
public void testBeanMapping() {
Car car = new Car( "Morris", 5, "Sedan");
CarDto carDto = CarMapper.INSTANCE.carToCarDto(car);
System.out.println(carDto.getMake());
System.out.println(carDto.getSeatCount());
System.out.println(carDto.getType());
}
Morris
5
null

由于没有使用 @Mapping 注解为 type 指定映射关系,因此 type 的属性值是 null

但一般来说,没人会闲着没事把隐式映射关闭。🤨

2.3 @Mapping 注解

@Mapping 注解常作用于方法上,其实它也可以作用于注解之上,允许 @Mapping 用于其他(用户自定义的)注释以实现重用。

具体使用

新建 UserInfo 实体类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author mofan 2021/2/2
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserInfo {
private String id;
private String username;
private String pwd;
private String dept;
private Integer age;
private Date createTime;
}

再新建 StudentDto 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author mofan 2021/2/2
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class StudentDto {
private String id;
private String username;
private String password;
private String department;
private String age;
private Date createTime;
}

要实现 UserInfoStudentDto 转换。

再创建一个 FromUserInfo 注解,在这个注解之上使用 @Mapping 注解:

1
2
3
4
5
6
7
8
9
/**
* @author mofan 2021/2/2
*/
@Retention(RetentionPolicy.CLASS)
@Mapping(target = "id", ignore = true)
@Mapping(target = "createTime", expression = "java(new java.util.Date())")
@Mapping(source = "dept", target = "department")
public @interface FromUserInfo {
}

创建 UserInfoMapper 接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @author mofan 2021/2/2
*/
@Mapper
public interface UserInfoMapper {

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

/**
* 转换为 StudentDto
* @param userInfo userInfo
* @return StudentDto 实例
*/
@FromUserInfo
@Mapping(source = "pwd", target = "password")
StudentDto toStudent(UserInfo userInfo);
}

最后来个测试类测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* @author mofan 2021/2/2
*/
public class UserInfoMapperTest {

@Test
public void testToStudent() {
UserInfo userInfo = new UserInfo("1", "mofan", "123456",
// 2020-11-02 21:54:29
"开发部", 19, new Date(1604325269401L));
StudentDto studentDto = UserInfoMapper.INSTANCE.toStudent(userInfo);

System.out.println(studentDto.getId());
System.out.println(studentDto.getUsername());
System.out.println(studentDto.getPassword());
System.out.println(studentDto.getDepartment());
System.out.println(studentDto.getAge());
System.out.println(studentDto.getCreateTime());
}
}

运行测试类后,控制台输出:

null
mofan
123456
开发部
19
Tue Feb 02 18:33:11 CST 2021

@Mapping 属性讲解

上述案例中又使用了 @Mapping 注解的两个属性:ignoreexpression

在实体转换时,如果不想让某个属性值从源实体赋值到新实体,可以使用 @Mapping 注解的 target 指定新实体中不想被赋值的属性,同时将 ignore 设置为 true

expression 的含义就是表达式,使用这个属性,可以为新实体中某个属性指定成表达式设置的值。表达式格式为:java(<EXPRESSION>),其实就是一句 Java 代码。比如想让 username 字段设置为 默烦,可以这些写:

1
@Mapping(target = "username", expression = "java(\"默烦\")")

需要注意的是: source 属性和 expression 属性不能同时存在。使用 expression 时,其值的 Java 代码所使用的类最好使用全类名,但如果这个 Java 代码会抛异常就不要使用 expression 了,而是需要自定义方法并在 qualifyByName 中引用。

像上述案例中,createTime 的值是指 2020-11-02 21:54:29,为其指定表达式后,原本的值发生了改变。

如果仔细观察,还能发现 MapStruct 会对数据进行自动转换。比如在原实体中的 age 属性是 Integer 类型的,而 目标实体中的 age 属性是 String 类型的,即便如此,仍然能够映射成功。 👍

@Mapping 其他属性

@Mapping 注解还有其他的属性:

  1. dateFormat:原属性是 Date,转化为 String
  2. numberFormat:数值类型与 String 类型之间的转化
  3. constant:不管源属性值,直接将目标属性设置为指定的常量
  4. qualifiedByName:根据自定义的方法进行赋值
  5. defaultValue:默认值。如果原属性值为 null,那么设置目标属性值为指定的默认值

这种方式有什么用?

如果有若干个 DTO 都由同一实体转换而来,这些 DTO 有些属性名称是相同的,但有些又不一样,那么我们就可以编写一个注解,在这个注解上使用 @Mapping 注解,以减少重复代码。

使用 @Mapping 注解作用与其他注解上并使用其他注解时,可以让若干个 DTO 都具备相同的特性。在官网上称这种观点为 “duck-typing”(鸭子类型),当一个东西叫声像鸭子,走路像鸭子,还会游泳,那么它很有可能就是鸭子 🦆。简单来说就是特性决定了类型,而不是类型决定了特性。鸭子类型在动态语言中经常使用,比如 Python,这使得 Python 不像 Java 那样专门去弄一大堆的设计模式。🤪

好了,说远了,回到 MapStruct 上。

但是官网又说,这个功能尚不成熟,用户应当谨慎使用,尤其是在不确定何时始终存在属性时:

the method on which the problem occurs is displayed, as well as the concerned values in the @Mapping annotation. However, the composition aspect is not visible.

2.4 默认方法与抽象类

在 JDK 8 及其更高的版本中,接口中也可以有方法的实现,但是这些方法必须被 staticdefault 关键词修饰。

使用 MapStruct 可以实现简单类型的映射,但是没办法完成一些带有逻辑的映射。比如在原实体中有一个名为 age 的属性,在映射到新实体时,想让 age 的值加一,这对于 MapStruct 来说是无法做到的。

此时可以在接口中定义默认方法,只要参数是原实体类型,返回值是新实体类型,那么使用 MapStruct 生成接口的实现类时将调用这个默认方法。

除此之外,还可以使用抽象类来定义映射器,而不仅仅是接口。 在这种情况下,MapStruct 会生成抽象类的子类,并实现所有的抽象方法。而对于非抽象方法来说,只要参数和返回值类型匹配,MapStruct 也会子类中调用这些方法。与声明默认方法相比,使用抽象类来定义映射器可以在映射器类中声明额外的字段。

2.5 多个源参数

MapStruct 支持带有多个源参数的映射方法。 比如,可以将实体 A 中的部分属性值和实体 B 中的部分属性值映射到同一 DTO 中:

1
2
3
4
5
6
@Mapper
public interface AddressMapper {
@Mapping(source = "person.description", target = "description")
@Mapping(source = "address.houseNo", target = "houseNumber")
DeliveryAddressDto personAndAddressToDeliveryAddressDto(Person person, Address address);
}

与单参数映射方法一样,属性是按名称映射的。

如果多个源参数中定义了具有相同名称的属性,那么必须使用 @Mapping 注释来指定要从哪个源参数中的同名属性映射到目标实体,否则就会报错。对于仅在给定源参数中出现一次的属性,由于 隐式映射 的关系,因此可以选择性地指定名称。

注意: 具有多个源参数的映射方法将在 所有 源参数为空的情况下才返回 null。否则,目标实体将被实例化,同时将所有来自所提供参数的属性值映射到目标实体中。

MapStruct 还支持直接引用源参数。 比如源参数中有一个 Integer 类型的参数,可以直接将这个参数映射到目标实体中:

1
2
3
4
5
6
@Mapper
public interface AddressMapper {
@Mapping(source = "person.description", target = "description")
@Mapping(source = "hn", target = "houseNumber")
DeliveryAddressDto personAndAddressToDeliveryAddressDto(Person person, Integer hn);
}

2.6 多个嵌套属性映射

如果在实体 A 中存在某一属性 B,其类型是一个实体,现在想将实体 A 中的 B 对象映射到某一不含任何复杂对象类型属性的 DTO 中,那么需要取出属性 B 的各个属性进行一一映射。

如果实体 A 存在多个复杂对象类型的属性,依次进行映射会十分繁琐。

MapStruct 支持使用 . 作为 @Mapping 注释中 target 的属性值。这告诉 MapStruct 将原实体中所有复杂对象类型的属性中的所有属性映射到目标实体。

1
2
3
4
5
6
7
@Mapper
public interface CustomerMapper {
@Mapping(target = "name", source = "record.name")
@Mapping(target = ".", source = "record")
@Mapping(target = ".", source = "account")
Customer customerDtoToCustomer(CustomerDto customerDto);
}

上述代码将 customerDto 对象中的 record 对象和 account 对象的各个属性都映射到目标实体中,但由于 recordaccount 都有 name 属性,因此需要显式指定以消除歧义。

2.7 更新存在的实例

在某些情况下,可能并不需要创建一个新的目标实体,而是更新一个现有的实例,这个时候就需要用到 @MappingTarget 注解。

简单来说:一个原实体 A 的属性数量比目标实体 B 少,因此将 A 映射为 B 时,B 中有些属性值会是空。B 中的这些属性是存在于实体 C 中的,那么可以使用 C 来更新 B。

具体使用

原实体 UserInfo

1
2
3
4
5
6
7
8
9
10
11
12
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserInfo {
private String id;
private String username;
private String pwd;
private String dept;
private Integer age;
private Date createTime;
}

目标对象 TeacherDto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* @author mofan 2021/2/3
*/
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class TeacherDto {
private String id;
private String username;
private String password;
private String department;
private String age;
private Date createTime;
private String firstName;
private String lastName;
private Integer sex;
private String idCardNum;
}

另一实体 PersonInfo

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* @author mofan 2021/2/3
*/
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class PersonInfo {
private String firstName;
private String lastName;
private Integer gender;
private String idCardNum;
}

转换接口中的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 转换为 TeacherDto --> 缺少部分属性值
*
* @param userInfo userInfo
* @return TeacherDto 实例
*/
@Mapping(source = "pwd", target = "password")
@Mapping(source = "dept", target = "department")
TeacherDto toTeacherDto(UserInfo userInfo);

/**
* 更新 TeacherDto 实例 --> 补充缺少的属性值
*
* @param personInfo personInfo
* @param teacherDto teacherDto
*/
@Mapping(source = "gender", target = "sex")
void updateTeacherDto(PersonInfo personInfo, @MappingTarget TeacherDto teacherDto);

测试方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
public void testToTeacherDto() {
UserInfo userInfo = new UserInfo("1", "mofan", "123456",
// 2020-11-02 21:54:29
"开发部", 19, new Date(1604325269401L));
TeacherDto teacherDto = UserInfoMapper.INSTANCE.toTeacherDto(userInfo);
assertEquals("123456", teacherDto.getPassword());
assertNull(teacherDto.getFirstName());

PersonInfo personInfo = new PersonInfo();
personInfo.setFirstName("默");
personInfo.setLastName("烦");
personInfo.setGender(1);
personInfo.setIdCardNum("123456788914725896");
UserInfoMapper.INSTANCE.updateTeacherDto(personInfo, teacherDto);
assertEquals("默", teacherDto.getFirstName());
assertEquals(1, teacherDto.getSex().intValue());
}

在更新实例时,如果存在字段名不一样的情况,仍然可以使用 @Mapping 注解进行指定。

在更新对象的方法中,除了使用 void 作为返回值外,还可以使用目标参数类型作为返回值,这表示在更新目标参数后又返回它。比如:

1
2
@Mapping(source = "gender", target = "sex")
TeacherDto updateTeacherDto(PersonInfo personInfo, @MappingTarget TeacherDto teacherDto);

2.8 反向映射

上述实例中,都是用实体转换为 DTO,那么怎么将 DTO 转换回实体呢?

当然可以使用 @Mapping 注解一一指定,但这样也太麻烦了,有没有什么简单的方法呢?

MapStruct 提供了一个名为 @InheritInverseConfiguration 的注解用于反向映射。该注解只有一个属性name,表示原映射名,即原映射方法名。

具体使用

新实体 PeopleInfo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @author mofan 2021/2/3
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class PeopleInfo {
private Long id;
private String name;
private String age;
private Date createTime;
private Double weight;
private String bloodType;
private GenderEnum gender;
}

打工人 WorkerDto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @author mofan 2021/2/3
*/
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class WorkerDto {
private Long id;
private String name;
private Integer age;
private String createTime;
private String weight;
private String bloodType;
private Integer gender;
}

一个用于转换实体的接口 PeopleInfoMapper

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/**
* @author mofan 2021/2/3
*/
@Mapper
public interface PeopleInfoMapper {
PeopleInfoMapper INSTANCE = Mappers.getMapper(PeopleInfoMapper.class);

/**
* 将 PeopleInfo 转换为 WorkerDto
*
* @param peopleInfo peopleInfo实例
* @return WorkerDto
*/
@Mappings({
@Mapping(target = "name", defaultValue = "默烦"),
@Mapping(target = "age", expression = "java(java.lang.Integer.valueOf(peopleInfo.getAge()) + 1)"),
@Mapping(target = "createTime", dateFormat = "yyyy-MM-dd"),
@Mapping(target = "weight", numberFormat = "0.00"),
@Mapping(target = "bloodType", constant = "A 型血"),
@Mapping(source = "gender", target = "gender", qualifiedByName = "getGenderEnumCode")
})
WorkerDto toWorkerDto(PeopleInfo peopleInfo);

/**
* 将 DTO 转换为实体
*
* @param workerDto workerDto 实例
* @return PeopleInfo
*/
@InheritInverseConfiguration(name = "toWorkerDto")
@Mappings({
@Mapping(target = "age", expression = "java(java.lang.String.valueOf(workerDto.getAge()))"),
@Mapping(target = "bloodType", constant = "B 型血"),
@Mapping(target = "gender", qualifiedByName = "getGenderEnum")
})
PeopleInfo fromPersonInfo(WorkerDto workerDto);

/**
* 根据枚举值的代码获取枚举值
*
* @param code 枚举值对应的代码
* @return 枚举值
*/
@Named("getGenderEnum")
default GenderEnum getGenderEnum(Integer code) {
for (GenderEnum value : GenderEnum.values()) {
if (value.getCode().equals(code)) {
return value;
}
}
return null;
}

/**
* 根据性别枚举值获取对应的代码
*
* @param genderEnum 枚举值
* @return 枚举值对应的代码
*/
@Named("getGenderEnumCode")
default Integer getGenderEnumCode(GenderEnum genderEnum) {
if (genderEnum == null) {
return null;
}
return genderEnum.getCode();
}
}

编写一手测试方法: 😎

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
@Test
public void testInheritInverseConfiguration() {
PeopleInfo peopleInfo = new PeopleInfo();
peopleInfo.setId(233L);
// 缺少 name 属性值
peopleInfo.setAge("18");
peopleInfo.setCreateTime(new Date(1604325269401L));
peopleInfo.setWeight(122.2345);
peopleInfo.setBloodType("AB 型血");
peopleInfo.setGender(GenderEnum.MAN);

// to workerDto
WorkerDto workerDto = PeopleInfoMapper.INSTANCE.toWorkerDto(peopleInfo);
assertEquals("默烦", workerDto.getName());
assertEquals(1, workerDto.getGender().intValue());
assertEquals(19, workerDto.getAge().intValue());
System.out.println(workerDto.getCreateTime());
System.out.println(workerDto.getWeight());
System.out.println(workerDto.getBloodType());

// from PeopleInfo
PeopleInfo person = PeopleInfoMapper.INSTANCE.fromPersonInfo(workerDto);
assertEquals("B 型血", person.getBloodType());
assertEquals("19", person.getAge());
assertEquals(GenderEnum.MAN, person.getGender());
}

运行测试方法后,测试通过,控制台打印:

2020-11-02
122.23
A 型血

总结

由于 age 的转化中使用了 expression、bloodType 转化的过程中使用了 constant,这两个属性对应的 @Mapping 注释都没有指定 source。所以在使用 @InheritInverseConfiguration 注解进行反向映射时,需要使用 @Mapping 对这两个属性进行单独的映射,不然就会报错。

PeopleInfo 中 gender 属性类型是 GenderEnum 枚举类型,而 WorkerDto 中的 gender 属性是 Integer 类型的,因此在进行属性映射时,需要指定映射方式,这里涉及到 @Named 注解和 @Mapping 注解中 qualifiedByName 属性的使用。

2.9 直接使用字段映射

在前面的案例中,实现与实体之间的映射时都需要实体中有 Getter 和 Setter 方法,但 MapStruct 也支持没有 Getter 和 Setter 的公共字段映射。如果 MapStruct 为属性找不到合适的 Getter 和 Setter 方法,它会把字段作为读 / 写访问器。

如果一个字段被 publicpublic final 修饰,那么它会被认为是一个读访问器。如果一个字段被 static 修饰,它不会被认为是一个读访问器。

当一个字段 仅仅只public 修饰时,它才会被认为是一个写访问器。如果一个字段被 finalstatic 修饰,它不会被认为是一个写访问器。

比如,这样两个实体是能够被相互映射的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Customer {
private Long id;
private String name;
}

public class CustomerDto {
// 注意这些字段是被 public 修饰的
public Long id;
public String customerName;
}

由于 name 属性和 customerName 属性名称不一样,在进行映射时,需要使用 @Mapping 指定规则。

2.10 使用构建器

在 MapStruct 中,支持通过构建器对不可变类型进行映射。

当进行映射时,MapStruct 会检查被映射的类中是否有构建器。这是通过 BuilderProvider 完成的,如果这个类存在构建器,那么该构建器将被用于映射。

BuilderProvider 的默认实现假设如下:

  • 该类型拥有返回 Builder 对象的公共无参静态方法(该方法称为 构建器创建方法)。比如 Person 类中有一个公共静态方法,它返回 PersonBuilder

  • Builder 类型有一个公共无参方法(该方法称为 构建方法),它返回正在构建的对象类型。比如 PersonBuilder 中有一个 build() 方法,该方法的返回值类型是 Person

  • 在有多个 构建方法 的情况下,MapStruct 会寻找一个叫做 build() 的方法,如果存在这样的方法,将使用这个方法,否则编译错误。

  • 可以在 @BeanMapping@Mapper@MapperConfig 等注解中使用 @Builder 来定义特定的 构建方法

  • 如果有多个 构建器创建方法 满足上述条件,DefaultBuilderProvider 会抛出 MoreThanOneBuilderCreationMethodException 异常。如果发生 MoreThanOneBuilderCreationMethodException,MapStruct 将在编译期间写入警告,并且不使用任何生成器。

如果在被映射的类中存在上述的方法和构建器,为了完成映射,MapStruct 会调用构建器的构建方法并生成代码。

还可以通过 MapStruct 的 @BuilderdisableBuilder 属性来禁用构建器检测。MapStruct 将在构建器被禁用的情况下回到常规的 Getter / Setter 来生成代码。还可以在 Maven 处理器插件的配置中使用 <compilerArgs> 添加 <arg>-Amapstruct.disableBuilders=true</arg> 来禁用构建器的检测。

对象工厂也被认为是构建器。例如,如果 PersonBuilder 中存在一个对象工厂,MapStruct 将使用对象工厂而不是 构建器创建方法

构建器的检测会影响 @BeforeMapping@AfterMapping 注解的行为,这在后文会介绍。

具体使用

原实体 Animal

1
2
3
4
5
6
7
8
9
10
11
/**
* @author mofan 2021/2/3
*/
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Animal {
private String name;
private String type;
}

目标实体与构造器:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
* @author mofan 2021/2/3
*/
@Getter
@Setter
public class DogDto {
private String name;
private final String type;

protected DogDto (DogDto.Builder builder) {
this.type = builder.type;
this.name = builder.name;
}

/**
* 构建器创建方法
*
* @return 构建器
*/
public static DogDto.Builder builder() {
return new DogDto.Builder();
}

public static class Builder {
private String type;
private String name;

public Builder type (String type) {
this.type = type;
return this;
}

public Builder name(String name) {
this.name = name;
return this;
}

/**
* 构建方法
*
* @return 被构建的对象
*/
public DogDto create() {
return new DogDto(this);
}
}
}

转换接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author mofan 2021/2/3
*/
@Mapper
public interface AnimalMapper {

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

/**
* 将 Animal 转换为 DogDTO
* @param animal Animal 实例
* @return DogDto 实例
*/
DogDto toDogDto(Animal animal);
}

测试方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author mofan 2021/2/3
*/
public class AnimalMapperTest {
@Test
public void testToDogDto() {
Animal animal = new Animal();
animal.setName("小黑");
animal.setType("dog");

DogDto dogDto = AnimalMapper.INSTANCE.toDogDto(animal);
assertEquals("小黑", dogDto.getName());
assertEquals("dog", dogDto.getTYPE());
}
}

运行测试方法后,测试通过。查看 target 目录,可以看到生成的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class AnimalMapperImpl implements AnimalMapper {
public AnimalMapperImpl() {
}

public DogDto toDogDto(Animal animal) {
if (animal == null) {
return null;
} else {
Builder dogDto = DogDto.builder();
dogDto.type(animal.getType());
dogDto.name(animal.getName());
return dogDto.create();
}
}
}

总结

这其实使用了一种设计模式 —— 构建者模式,或者说建造者模式。

在《Effective Java》一书中有提到,一个类的构造函数参数个数有多个时,而且这些参数有些是可选的参数,要考虑使用构建器,即使用构建者模式。

但上述代码中并没有体现可选参数的概念,要想体现也很简单,只需要在构建器中编写编写一个构造方法,构造方法的参数就是需要必填的参数。

每次使用构建者模式都会书写许多样板代码,Lombok 提供了一个 @Builder 注解,使用这个注解可以很快地实现构建者模式。使用 @Builder 生成的构造方法是共有的,不符合构建者模式的思想,因此还需要指定构造方法的访问修饰符。比如指定成 protected

1
2
3
4
5
6
7
8
9
10
11
/**
* @author mofan 2021/2/3
*/
@Getter
@Setter
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class DogDto {
private String name;
private final String type;
}

将 DogDto 修改成以上形式后,运行测试方法也是可以通过的。 👊

2.11 使用构造函数

在前文中,对象映射使用 Getter / Setter 完成。MapStruct 也支持使用构造方法来映射目标类型。

在进行映射时,MapStruct 会检查被映射的实体中是否存在构建器。如果没有构建器,MapStruct 会寻找一个可访问的构造方法。

构造方法的选择

当有多个构造函数时,MapStruct 按照以下优先级进行选择:

  1. @Default (该注解可以来自任何包!😲)注释所标注的公共构造方法(最高优先级);
  2. 如果 只有一个公共 的构造方法,那么它将被用于构造对象,而其他非公共的构造方法会被忽略;
  3. 如果存在一个无参构造方法,那么它将被用于构造对象,而其他构造方法会被忽略;
  4. 如果有多个符合条件的构造方法,会因为构造方法指代不清而造成编译错误。在这个时候,可以使用 @Default 注解来指定用于构造对象的构造方法。

官网给出的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Vehicle {
protected Vehicle() { }
// MapStruct will use this constructor, because it is a single public constructor
public Vehicle(String color) { }
}

public class Car {
// MapStruct will use this constructor, because it is a parameterless empty constructor
public Car() { }
public Car(String make, String color) { }
}

public class Truck {
public Truck() { }
// MapStruct will use this constructor, because it is annotated with @Default
@Default
public Truck(String make, String color) { }
}

public class Van {
// There will be a compilation error when using this class because MapStruct cannot pick a constructor
public Van(String make) { }
public Van(String make, String color) { }
}

在使用构造方法时,将使用构造方法的参数名并与目标属性进行匹配。当构造函数被 @ConstructorProperties (该注解也可以来自任何包,只不过通常使用 java.beans.ConstructorProperties)标注时,该注解将被用来获取参数的名称。

当目标实体中存在对象工厂方法或用 @ObjectFactory 标记的方法时,这些方法将优于目标中任何构造方法。在这种情况下,目标实体中的构造方法不会被使用。

具体使用

自己编写的 @Default 注解:

1
2
3
4
5
6
7
/**
* @author mofan 2021/2/5
*/
@Target(ElementType.CONSTRUCTOR)
@Retention(RetentionPolicy.CLASS)
public @interface Default {
}

创建一个 Book 实体:

1
2
3
4
5
6
7
8
9
10
/**
* @author mofan 2021/2/5
*/
@Setter
@Getter
public class Book {
private String bookName;
private Double price;
private String author;
}

再创建一个 NovelDto,后续会将 Book 对象转换为 NovelDto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @author mofan 2021/2/5
*/
@Getter
public class NovelDto {
private final String bookName;
private final Double price;
private String author;

@Default
// @ConstructorProperties 注解指定来源对象的属性名
@java.beans.ConstructorProperties({"bookName", "price", "author"})
public NovelDto(String book, Double price, String author) {
this.bookName = book;
this.price = price;
this.author = "author";
}


public NovelDto(String bookName, Double price) {
this.bookName = bookName;
this.price = price;
}
}

一个用于实体映射的接口:

1
2
3
4
5
@Mapper
public interface BookMapper {
BookMapper INSTANCE = Mappers.getMapper(BookMapper.class);
NovelDto toNovelDto(Book book);
}

测试方法:

1
2
3
4
5
6
7
8
9
10
11
@Test
public void testToNovel() {
Book book = new Book();
book.setBookName("Effective Java");
book.setPrice(78.0);
NovelDto novelDto = BookMapper.INSTANCE.toNovelDto(book);

assertEquals("Effective Java", novelDto.getBookName());
assertEquals("author", novelDto.getAuthor());
assertEquals(78.0, novelDto.getPrice(), 0.0);
}

运行测试方法后,测试通过!

查看生成的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public NovelDto toNovelDto(Book book) {
if (book == null) {
return null;
} else {
String bookName = null;
Double price = null;
String author = null;
bookName = book.getBookName();
price = book.getPrice();
author = book.getAuthor();
NovelDto novelDto = new NovelDto(bookName, price, author);
return novelDto;
}
}

总结

通过生成代码来看,使用到了原目标实体的 Getter 方法,用到了目标实体的构造方法,因此在编码时需要保证存在这些方法 才能使用构造函数进行映射

使用 MapStruct 将 Book 实体转换成 NovelDto 时,在 NovelDto 中没有 Setter 方法,但存在多个构造方法,最终会选取合适的构造方法进行映射。

NovelDto 实体类中存在两个构造方法,且都是公共有参构造方法,因此需要使用 @Default 进行指明要使用哪个构造方法进行映射,如果不指明就会在编译阶段报错。

但是在 MapStruct 中并没有提供 @Default 注解,那怎么办?🤔

根据官方文档 14.1. Non-shipped annotations 的说话,只要使用任意一个能够作用在构造方法且名为 @Default 的注解就行,也就是任意第三方的、能作用于构造方法上的 @Default 注解都能够被 MapStruct 识别,并将注解标记的构造方法作为映射的构造方法。那问题来了,在没有引入第三方依赖,且当前项目中没有符合条件的 @Default 注解咋办?

自己写一个就行,MapStruct 也能识别。具体参见上述提供的代码。😁

使用目标实体的构造方法时,会使用该构造方法的参数名并与 来源实体 的属性进行匹配。务必保证这些名称是一样的,否则无法映射成功。 当然,也不是说名称不一样就不能映射,这时候可以使用 @ConstructorProperties 注解,使用这个注解指定构造方法的第几个参数对应来源对象的哪个 Getter 方法,从而完成参数名与属性的匹配。

那如果目标实体的属性名和原实体的属性名不一致怎么办?这就需要用到前面说的 @Mapping 注解了。😏

备注: 这一块或许比较难理解,建议使用上述代码进行小改然后一一理解。😔

2.12 将 Map 映射到对象

在某些情况下,可能需要将 Map<String, Object> 映射到某个对象,MapStruct 也是支持这种需求的,此时 @Mapping 中的 source 属性值为 Map 中的 key(把 Map 当成一个没有特定类型的对象)。比如:

1
2
3
4
5
6
7
8
9
10
11
12
@Setter
@Getter
public class Customer {
private Long id;
private String name;
}

@Mapper
public interface CustomerMapper {
@Mapping(target = "name", source = "customerName")
Customer toCustomer(Map<String, String> map);
}

生成的代码如下:

1
2
3
4
5
6
7
8
9
10
public class CustomerMapperImpl implements CustomerMapper {
@Override
public Customer toCustomer(Map<String, String> map) {
// --snip--
if ( map.containsKey( "customerName" ) ) {
customer.setName( map.get( "customerName" ) );
}
// --snip--
}
}

当使用 raw map(没有指定泛型的 Map) 或没有字符串作为键的 Map 时,将生成警告。如果 Map 本身直接映射到其他目标属性,则不会生成警告。

3. 获取映射器

3.1 映射器工厂

当没有使用依赖注入框架(比如 Spring)时,可以使用 org.mapstruct.factory.Mappers 类来获取一个映射器(Mapper)实例。

使用方法: 调用该类的 Mappers#getMapper() 方法,传入要返回的 Mapper 接口类型。

通常来说,习惯于将映射器实例命名为 INSTANCE

在前文中,大部分都是使用接口来定义映射器,而在抽象类中应该这样定义映射器,比如:

1
2
3
4
5
@Mapper
public abstract class CarMapper {
public static final CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
CarDto carToCarDto(Car car);
}

这种模式使得客户端可以非常容易地使用映射器对象,而不需要重复实例化新的实例。

MapStruct 生成的映射器是无状态和线程安全的,在多线程环境下可以放心使用。

3.2 使用依赖注入

在实际项目中,一般都会使用依赖注入框架(比如:Spring),这个时候就需要通过依赖注入而不是通过 Mappers#getMapper() 来获取映射器对象。可以通过 @MappercomponentModel 属性或使用配置选项中描述的 Configuration options 指定生成的映射器类应基于的组件模型。

目前,MapStruct 支持 CDI 和 Spring 。在这两种情况下,所需的注解将被添加到生成的映射器实现类中,以便使其同样受到依赖注入的影响。

@Mapper#componentModel 属性或 mapstruct.defaultComponentModel 配置选项支持的值:

注入方式
default 默认方式,映射器不使用组件模型,使用 Mappers.getMapper(Class) 来进行获取映射器实例
cdi 生成的映射器是一个应用程序范围的 CDI Bean,使用 @Inject 注入
spring 生成的映射器是一个单例的 Spring Bean,使用 @Autowired 注入
jsr330 生成的映射器中被 @Named 注解标记,使用 @Inject 注入
jakarta 生成的映射器中被 @Named 注解标记,使用 jakarta.inject.@Inject 注入(since 1.5.0.Final)

从 MapStruct 1.5.0.Final 开始,这些值在 org.mapstruct.MappingConstants.ComponentModel 类中以常量的形式存在。

mapstruct.defaultComponentModel 配置选项和 @Mapper#componentModel 同时指定了值,以 @Mapper 注解的为准。

使用其他映射器类的映射器将使用配置的组件模型获得这些映射器。比如,映射器 A 是一个 Spring Bean,映射器 A 中注入了映射器 B,那么映射器 B 也必须是一个可注入的 Spring Bean。

3.3 注入策略

使用依赖注入时,可以指定注入策略。

可以通过 @Mapper@MapperConfig 注解的 injectionStrategy 属性或 mapstruct.defaultInjectionStrategy 来指定注入策略。注入策略有两种:构造方法和属性注入, 默认是属性注入。

针对 @MapperinjectionStrategy 属性来说,其值是一个枚举:

1
2
3
4
5
6
public enum InjectionStrategy {
/** Annotations are written on the field **/
FIELD,
/** Annotations are written on the constructor **/
CONSTRUCTOR
}
  1. 使用属性注入时,依赖关系将被注入到字段中。
  2. 使用构造函数注入时,将生成构造函数,依赖关系将通过构造函数注入。

如果 MapStruct 检测到 需要使用 @Mapper#uses 属性的实例进行映射,则生成的映射器将注入在 uses 属性中定义的类。注意 需要使用 几个字,如果参与映射字段不需要使用指定类型对应的实例进行映射,那么生成的映射器不会注入 uses 属性中定义的类。

对于抽象类或装饰器应使用属性注入,因为它们不能被实例化。

具体使用

使用最开始的 CarCarDto 两个实体进行测试,省略实体代码。

导入 Spring 所需要的依赖:

1
2
3
4
5
6
7
8
9
10
11
12
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.2.7.RELEASE</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>5.2.7.RELEASE</version>
<scope>test</scope>
</dependency>

编写一个 SpringConfig 配置类,主要用于扫描包:

1
2
3
4
5
6
7
/**
* @author mofan 2021/2/6
*/
@Configuration
@ComponentScan("indi.mofan.mapper")
public class SpringConfig {
}

编写映射器,使用 Spring 的依赖注入:

1
2
3
4
5
6
7
8
9
/**
* @author mofan 2021/2/6
*/
@Mapper(componentModel = "spring")
public interface CarInjectMapper {

@Mapping(source = "numberOfSeats", target = "seatCount")
CarDto carToCarDto(Car car);
}

测试类与测试方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* @author mofan 2021/2/6
*/
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = SpringConfig.class)
public class DependencyInjectionTest {

@Autowired
private CarInjectMapper carInjectMapper;

@Test
public void shouldMapCarToDto() {
Car car = new Car("Morris", 5, "Sedan");
CarDto carDto = carInjectMapper.carToCarDto(car);
assertEquals("Morris", carDto.getMake());
assertEquals(5, carDto.getSeatCount());
assertEquals("Sedan", carDto.getType());
}
}

查看生成的实现类 CarInjectMapperImpl,可以看到这个类被 Spring 的 @Component 注解标记。

4. 数据类型转换

在许多情况下,MapStruct 会自动处理类型转换。

例如,一个属性在源 Bean 中是 int 类型,但在目标 Bean 中是 String 类型,通过生成的代码可以看到分别调用 String.valueOf(int)Integer.parseInt(String) 两个方法进行了类型的转换。

又比如在 Car 类型中有一个 Person 类型的 driver 属性,当对 Car 进行映射时,应当把 driver 映射为 PersonDto 类型。

4.1 隐式类型转换

在此列举一些常见的自动类型转换:

  • Java 基本数据类型和其对应的包装类之间。把包装类转换成基本数据类型时,会执行 null 值检查。
  • Java 基本数据类型和封装类型之间。例如 intlong 之间或 byteInteger 之间。

注意: 从精度较大类型的数据转换为精度较小的数据时,可能会造成值或精度缺失(例如从 long 转换为 int)。@Mapper@MapperConfig 注解有一个属性 typeConversionPolicy 来控制警告或错误,其默认值为 ReportingPolicy.IGNORE

  • 基本数据类型及其包装类和 String 之间。可以在 @MappingnumberFormat 属性中指定 java.text.DecimalFormat 能理解的格式化字符串对数值格式化。
1
2
3
4
5
6
7
8
9
@Mapper
public interface CarMapper {

@Mapping(source = "price", numberFormat = "$#.00")
CarDto carToCarDto(Car car);

@IterableMapping(numberFormat = "$#.00")
List<String> prices(List<Integer> prices);
}
  • enumString 之间。

  • 大数类型(java.math.BigIntegerjava.math.BigDecimal)和基本数据类型(及其包装类)以及 String 之间。同样可以使用 @MappingnumberFormat 属性对数值格式化。

1
2
3
4
5
6
7
@Mapper
public interface CarMapper {

@Mapping(source = "power", numberFormat = "#.##E0")
CarDto carToCarDto(Car car);

}
  • JAXBElement<T>T 之间,List<JAXBElement<T>>List<T> 之间。
  • java.util.Calendarjava.util.Date 和 JAXB 的 XMLGregorianCalendar 之间。
  • java.util.Date 日期类型或 XMLGregorianCalendarString 之间。可以在 @MappingdateFormat 属性中指定 java.text.SimpleDateFormat 能理解的格式化字符串对日期字符串格式化。
1
2
3
4
5
6
7
8
9
@Mapper
public interface CarMapper {

@Mapping(source = "manufacturingDate", dateFormat = "dd.MM.yyyy")
CarDto carToCarDto(Car car);

@IterableMapping(dateFormat = "dd.MM.yyyy")
List<String> stringListToDateList(List<Date> dates);
}
  • Jodas 中的日期时间类型(DateTimeLocalDateTimeLocalDateLocalTime)和 String 之间。可以使用 @MappingdateFormat 属性对数值进行格式化。

  • Jodas 的 DateTime 类型和 javax.xml.datatype.XMLGregorianCalendarjava.util.Calendar 之间。

  • Jodas 的 LocalDateTimeLocalDatejavax.xml.datatype.XMLGregorianCalendarjava.util.Date 之间。

  • java.time.LocalDatejava.time.LocalDateTimejavax.xml.datatype.XMLGregorianCalendar 之间。

  • Java 8 日期时间类型(ZonedDateTimeLocalDateTimeLocalDateLocalTime)和 String 之间。可以使用 @MappingdateFormat 属性对数值进行格式化。

  • Java 8 日期类型(InstantDurationPeriod)和 String 之间。

  • Java 8 的 ZonedDateTime 类型和 Date 之间,当从给定的 Date 转换成 ZonedDateTime 时,使用系统默认时区。

  • Java 8 的 LocalDateTime 类型和 Date 之间,使用 UTC 作为时区。

  • Java 8 的 LocalDate 类型和 java.util.Datejava.sql.Date 之间,使用 UTC 作为时区。

  • Java 8 的 Instant 类型和 Date 之间。

  • Java 8 的 ZonedDateTime 类型和 Calendar 之间。

  • java.sql.Datejava.sql.Timejava.sql.Timestampjava.util.Date 之间。

  • 当从一个字符串转换时,如果省略 @Mapping 注解的 dateFormat,会使用默认的格式和符号来转换,但 XmlGregorianCalendar 是个例外,将根据 XML Schema 1.0 Part 2, Section 3.2.7-14.1, Lexical Representation

  • java.util.CurrencyString 之间。当从一个字符串转换时,其值应符合 ISO-4217 规范,否则会抛出 IllegalArgumentException 异常。

  • java.util.UUIDString 之间。当从一个字符串转换时,其值应该是一个有效的 UUID,否则会抛出 IllegalArgumentException 异常。

  • StringStringBuilder 之间。

  • java.net.URLString 之间。当从一个字符串转换时,其值应该是一个有效的 URL,否则会抛出 MalformedURLException 异常。

4.2 映射对象引用

通常,一个对象不仅具有基本数据类型的属性,还引用其他对象。比如,一个 Cage 实体内部含有 Animal 类型的属性,表示笼子里关着的动物。

在这种情况下,也只需为引用的对象类型定义一个映射方法。

具体使用

创建一个新的 Cage 实体:

1
2
3
4
5
6
7
8
9
10
/**
* @author mofan 2021/2/7
*/
@Setter
@Getter
public class Cage {
private String name;
private String size;
private Animal animal;
}

将映射成的 CageDto 实体:

1
2
3
4
5
6
7
8
9
10
/**
* @author mofan 2021/2/7
*/
@Getter
@Setter
public class CageDto {
private String name;
private String size;
private DogDto dog;
}

用于实体映射的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* @author mofan 2021/2/7
*/
@Mapper
public interface CageMapper {

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

@Mapping(source = "animal", target = "dog")
CageDto toCageDto(Cage cage);

DogDto toDogDto(Animal animal);
}

测试方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
public void testToCageDto() {
Cage cage = new Cage();
Animal animal = new Animal("小黑", "Dog");
cage.setAnimal(animal);
cage.setName("小黑的家");
cage.setSize("70*50*60");
CageDto cageDto = CageMapper.INSTANCE.toCageDto(cage);
Assert.assertEquals("小黑的家", cageDto.getName());
Assert.assertEquals("70*50*60", cageDto.getSize());
Assert.assertEquals("小黑", cageDto.getDog().getName());
Assert.assertEquals("Dog", cageDto.getDog().getType());
}

运行测试方法后,测试通过。

查看生成的实现类 CageMapperImpl 接口实现类:

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
public class CageMapperImpl implements CageMapper {

@Override
public CageDto toCageDto(Cage cage) {
if ( cage == null ) {
return null;
}

CageDto cageDto = new CageDto();

cageDto.setDog( toDogDto( cage.getAnimal() ) );
cageDto.setName( cage.getName() );
cageDto.setSize( cage.getSize() );

return cageDto;
}

@Override
public DogDto toDogDto(Animal animal) {
if ( animal == null ) {
return null;
}

DogDtoBuilder dogDto = DogDto.builder();

dogDto.name( animal.getName() );
dogDto.type( animal.getType() );

return dogDto.build();
}
}

MapStruct 对源对象与目标对象属性的映射规则

  1. 如果源属性和目标属性类型相同,值将直接从源复制到目标。如果属性是一个集合(如 List),集合的副本(新建一个集合,然后把源数据一个个地塞进去)将被设置到目标属性中。

  2. 如果源属性和目标属性类型不同,将在当前类检查是否存在将源属性的类型作为参数类型,将目标属性的类型作为返回类型的另一种映射方法。如果存在这样的方法,它将在生成的实现方法中被调用。

  3. 如果不存在这样的方法,MapStruct 将查看源属性类型和目标属性类型是否存在内置转换。如果存在,生成的代码将采用这种转换。

  4. 如果不存在这样的方法,MapStruct 将采用复杂的转换:

    • 映射方法,通过映射方法映射结果,比如:target = method1(method2(source))
    • 内置转换,通过映射方法映射结果,比如:target = method(conversion(source))
    • 映射方法,通过内置转换映射结果,比如:target = conversion(method(source))
  5. 如果没有找到这样的方法,MapStruct 将尝试生成一个自动的 sub-mapping 方法,使用方法进行源属性和目标属性之间的映射。

  6. 如果 MapStruct 无法创建基于名称的映射方法,则会在编译时引发错误,显示不可映射的属性及其路径。

可以在以下四个注解上设置 mappingControl(映射控件),优先级依次增加:

  • @MapperConfig
  • @Mapper
  • @BeanMapping
  • @Mapping

比如 @Mapper(mappingControl = NoComplexMapping.class) 的优先级高于 @MapperConfig(mappingControl = DeepClone.class)

@IterableMapping@MapMapping 的工作原理与 @Mapping 类似。

映射控件是在 MapStruct 1.4 中引入的实验性功能。

映射控件的类型由枚举 org.mapstruct.control.MappingControl.Use 表示:

  • BUILT_IN_CONVERSION
  • COMPLEX_MAPPING
  • DIRECT
  • MAPPING_METHOD

枚举 Use 的存在使得用户可以对 MapStruct 的映射细节进行控制,当缺失某种枚举时,表示关闭对应的映射方式,默认情况下 mappingControl = MappingControl.class,表示启用所有映射选项。

属性间应用拓展

  1. 如果不想让 MapStruct 生成自动的 sub-mapping 方法,可以使用:
1
@Mapper(disableSubMappingMethodsGeneration = true)
  1. 用户可以通过元注解来控制映射。提供了一些方便的注解,比如 @DeepClone 只允许直接映射。使用该注解后,如果源类型和目标类型相同,MapStruct 将对源类型进行深拷贝。sub-mappings-methods 必须被允许(默认情况下)。
  2. 在自动的 sub-mapping 方法生成过程中,共享配置将不会被考虑在内。
  3. 目标对象的构造函数属性也被视为目标属性。

4.3 控制嵌套对象映射

MapStruct 会使用源属性名和目标属性名生成映射方法,但不幸的是,这两个名称通常不会相等。在这这种情况下,需要使用 @Mapping 注解来指定属性之间的映射。

比如:

1
2
3
4
5
6
7
8
9
10
@Mapper
public interface FishTankMapper {
@Mapping(target = "fish.kind", source = "fish.type")
@Mapping(target = "fish.name", ignore = true)
@Mapping(target = "ornament", source = "interior.ornament")
@Mapping(target = "material.materialType", source = "material")
@Mapping(target = "quality.report.organisation.name",
source = "quality.report.organisationName")
FishTankDto map( FishTank source );
}

再或者:

1
2
3
4
5
6
7
8
9
10
11
@Mapper
public interface FishTankMapperWithDocument {
@Mapping(target = "fish.kind", source = "fish.type")
@Mapping(target = "fish.name", expression = "java(\"Jaws\")")
@Mapping(target = "plant", ignore = true )
@Mapping(target = "ornament", ignore = true )
@Mapping(target = "material", ignore = true)
@Mapping(target = "quality.document", source = "quality.report")
@Mapping(target = "quality.document.organisation.name", constant = "NoIdeaInc" )
FishTankWithNestedDocumentDto map( FishTank source );
}

MapStruct 将对源对象中的每个嵌套属性执行 null 检查。

MapStruct 鼓励用户编写明确的 嵌套属性映射方法,而不是通过 @Mapping 配置来解决一切,这样还可以实现代码复用,更加优雅~ 👊

在某些情况下,用于生成嵌套方法的 ReportingPolicy 可能是 IGNORE,这意味着 MapStruct 不会显示嵌套映射中未映射的目标属性。

4.4 执行自定义映射方法

有时映射并不简单,有些字段可能需要自定义逻辑。

比如将 FishTank 中的 lengthwidthheight 属性映射到 FishTankWithVolumeDto 中的 volume 属性,这个映射也不是一个简单的映射,需要计算鱼缸 FishTank 的体积,然后输出描述信息。

参与转换的类:

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
33
34
35
36
37
38
39
/**
* @author mofan 2021/2/7
*/
@Getter
@Setter
public class FishTank {
private Fish fish;
private int length;
private int width;
private int height;
}

@Getter
@Setter
@AllArgsConstructor
public class VolumeDto {
private int volume;
private String description;
}

@Getter
@Setter
public class FishTankWithVolumeDto {
private FishDto fish;
private VolumeDto volume;
}

@Getter
@Setter
public class Fish {
private String type;
}

@Getter
@Setter
public class FishDto {
private String kind;
private String name;
}

用于实体映射的抽象类(换一种方法,不要接口):

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
/**
* @author mofan 2021/2/7
*/
@Mapper
public abstract class FishTankMapperWithVolume {

public static FishTankMapperWithVolume INSTANCE
= Mappers.getMapper(FishTankMapperWithVolume.class);

/**
* <p>将 FishTank 转换为 FishTankWithVolumeDto</p>
* <code>@Mapping</code> 注解的 source 属性值是 map() 方法的参数,
* 而不是 FishTank 中的属性
*
* @param source FishTank实例
* @return FishTankWithVolumeDto 实例
*/
@Mapping(target = "fish.kind", source = "source.fish.type")
@Mapping(target = "volume", source = "source")
public abstract FishTankWithVolumeDto map(FishTank source);

protected VolumeDto mapVolume(FishTank source) {
int volume = source.getLength() * source.getWidth() * source.getHeight();
String desc = volume < 100 ? "Small" : "Large";
return new VolumeDto(volume, desc);
}
}

@Mapping 注解的 source 属性值是 map() 方法的参数 source,而不是 FishTank 中名为 source 的属性。

测试方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* @author mofan 2021/2/7
*/
public class FishTankMapperWithVolumeTest {
@Test
public void testToFishTankWithVolumeDto() {
FishTank fishTank = new FishTank();
fishTank.setLength(70);
fishTank.setWidth(50);
fishTank.setHeight(60);
Fish fish = new Fish();
fish.setType("Carp");
fishTank.setFish(fish);
FishTankWithVolumeDto dto = FishTankMapperWithVolume.INSTANCE.map(fishTank);
Assert.assertEquals(210000, dto.getVolume().getVolume());
Assert.assertEquals("Large", dto.getVolume().getDescription());
Assert.assertEquals("Carp", dto.getFish().getKind());
}
}

运行测试方法后,测试通过。

查看生成的实现类 FishTankMapperWithVolumeImpl 接口实现类:

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
public class FishTankMapperWithVolumeImpl extends FishTankMapperWithVolume {

@Override
public FishTankWithVolumeDto map(FishTank source) {
if ( source == null ) {
return null;
}

FishTankWithVolumeDto fishTankWithVolumeDto = new FishTankWithVolumeDto();

fishTankWithVolumeDto.setFish( fishToFishDto( source.getFish() ) );
fishTankWithVolumeDto.setVolume( mapVolume( source ) );

return fishTankWithVolumeDto;
}

protected FishDto fishToFishDto(Fish fish) {
if ( fish == null ) {
return null;
}

FishDto fishDto = new FishDto();

fishDto.setKind( fish.getType() );

return fishDto;
}
}

4.5 调用其他映射器

MapStruct 还可以调用其他类中定义的映射方法,这些映射方法可以是 MapStruct 生成的也可以是手动编写的。这对于在多个类中编写映射代码,或者想提供无法由 MapStruct 生成的自定义映射逻辑,是非常有用的。

调用其他映射器时,只需在 @Mapper#uses 配置想使用的映射器类即可,可以指定多个。

具体使用

假设有一属性 manufacturingDate 在源实体中的类型为 Date,而在目标实体中类型为 String,在转换过程中,对日期值按需求进行格式化,这时候就需要使用自己编写的映射器进行映射处理。

源实体:

1
2
3
4
5
6
7
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Car {
private Date manufacturingDate;
}

目标实体:

1
2
3
4
5
6
7
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class CarDto {
private String manufacturingDate;
}

自定义的映射方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* @author mofan 2021/2/9
*/
public class DateMapper {

public String asString(Date date) {
return date != null ? new SimpleDateFormat("yyyy-MM-dd")
.format(date) : null;
}

public Date asDate(String date) {
try {
return date != null ? new SimpleDateFormat("yyyy-MM-dd")
.parse(date) : null;
} catch (ParseException e) {
throw new RuntimeException(e);
}
}
}

由于需要使用自定义的映射方法,因此需要在 @Mapper#uses 上指定映射方法所在的类。

映射器接口:

1
2
3
4
5
6
7
8
9
10
11
12
@Mapper(uses = DateMapper.class)
public interface CarMapper {

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

/**
* 将 Car 转换成 CarDto,不忽略 Date
* @param car Car 实例
* @return 对应的 DTO
*/
CarDto carToCarDtoUseDate(Car car);
}

测试方法:

1
2
3
4
5
6
7
@Test
public void testUseDate() {
// 时间戳表示的时间为 2021-02-09 00:00:00
Car car = new Car(new Date(1612800000000L));
CarDto carDto = CarMapper.INSTANCE.carToCarDtoUseDate(car);
assertEquals("2021-02-09", carDto.getManufacturingDate());
}

运行测试方法后,测试通过。

查看生成的实现类 CarMapperImpl 接口实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class CarMapperImpl implements CarMapper {
private final DateMapper dateMapper = new DateMapper();

public CarMapperImpl() {
}

public CarDto carToCarDtoUseDate(Car car) {
if (car == null) {
return null;
} else {
CarDto carDto = new CarDto();
carDto.setManufacturingDate(this.dateMapper
.asString(car.getManufacturingDate()));
return carDto;
}
}
}

在为实现 carToCarDto() 方法生成代码时,MapStruct 将寻找一个将 Date 对象映射为 String 的方法,最终在 DateMapper 类中找到它,使用其中的 asString() 方法来映射不同类型的 manufacturingDate 属性。

总结思考

生成的映射器使用为其配置的组件模型(使用 @Mapper#componentModel 配置组件模型)获取引用的映射器。

在示例代码生成的映射器实现类中,使用了 new 关键词 调用无参构造方法 创建了一个包含自定义映射方法的对象。

因此,默认情况下, 在自定义映射方法的所在类中,一定要含有公共无参构造方法,以便能够被实例化。

在使用了依赖注入时,自定义映射方法的所在类也要是一个可注入的 Bean。比如使用了 Spring 框架时,需要让自定义映射方法的所在类也被 Spring 管理。

4.6 将目标类型传递给自定义映射器

使用 @Mapper#uses() 将自定义映射器与生成的映射器挂钩时,可以在自定义映射方法中定义 Class 类型(或其超类)的附加参数,以便针对特定的目标对象类型执行常规映射任务。该属性必须使用 @TargetType 进行标注,以便 MapStruct 生成与目标对象中属性对应类型一致的 Class 实例。

例如,CarDto 中有一个 Reference 类型的 owner 属性,它包含了 Person 实体的主键。现在可以创建通用的自定义映射器,将任何 Reference 对象解析为其对应的、由 JPA 管理的实体实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@ApplicationScoped // CDI component model
public class ReferenceMapper {

@PersistenceContext
private EntityManager entityManager;

public <T extends BaseEntity> T resolve(Reference reference,
@TargetType Class<T> entityClass) {
return reference != null ?
entityManager.find(entityClass, reference.getPk()) : null;
}

public Reference toReference(BaseEntity entity) {
return entity != null ? new Reference( entity.getPk() ) : null;
}
}

@Mapper(componentModel = "cdi", uses = ReferenceMapper.class )
public interface CarMapper {
Car carDtoToCar(CarDto carDto);
}

生成的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//GENERATED CODE
@ApplicationScoped
public class CarMapperImpl implements CarMapper {

@Inject
private ReferenceMapper referenceMapper;

@Override
public Car carDtoToCar(CarDto carDto) {
if ( carDto == null ) {
return null;
}

Car car = new Car();

car.setOwner( referenceMapper.resolve( carDto.getOwner(), Owner.class ) );
// ...

return car;
}
}

4.7 传递上下文或状态对象给自定义方法

可以通过生成的映射方法将额外上下文或状态信息传递给具有 @Context 参数的自定义方法。

这样的参数会传递给其他映射方法、适用的情况下的 @ObjectFactory 方法或 @BeforeMapping@AfterMapping 方法,因此可以在自定义代码中使用。

@ObjectFactory 方法中搜索被 @Context 标记的参数,如果适用,将根据提供的上下文参数值调用这些方法。

还会在 @BeforeMapping@AfterMapping 方法中搜索被 @Context 标记的参数,如果适用,将根据提供的上下文参数值调用这些方法。

注意:在调用传入上下文参数的映射方法之前,不会进行 空检查。在这种情况下,调用方需要确保没有传入 null

为了在生成的代码中能够调用含有 @Context 参数的自定义方法,正在生成的映射方法的声明中也需要包含这些(或子类)@Context 参数。生成的代码中不会为缺少的 @Context 参数创建新的实例,也不会传递一个空值代替。

具体使用

使用 @Context 参数将数据传递给我们自定义的属性映射方法:

1
2
3
4
5
6
public abstract CarDto toCar(Car car, @Context Locale translationLocale);

protected OwnerManualDto translateOwnerManual(OwnerManual ownerManual,
@Context Locale locale) {
// manually implemented logic to translate the OwnerManual with the given Locale
}

生成的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
//GENERATED CODE
public CarDto toCar(Car car, Locale translationLocale) {
if ( car == null ) {
return null;
}

CarDto carDto = new CarDto();

carDto.setOwnerManual( translateOwnerManual( car.getOwnerManual(), translationLocale );
// more generated mapping code

return carDto;
}

4.8 映射方法的解析

将属性从一种类型映射到另一种类型时,MapStruct 寻找将源类型映射到目标类型的最明确的方法。该方法可以是在同一个映射器接口中声明的,也可以是通过 @Mapper#uses() 注册的另一个映射器中声明的。这同样适用于工厂方法。

寻找映射方法或工厂方法的算法与 Java 的方法解析算法很类似,尤其是具有更明确源类型的方法都将被优先获取(假如有两个方法,一个方法映射寻找的源类型,另一个映射相同的父类型)。如果有多个详细程度相当的方法时,则会引发错误。

在使用 JAXB 时,比如将 String 转换为对应的 JAXBElement<String> 时,MapStruct 在寻找映射方法时将考虑 @XmlElementDecl 注解的 scopename 属性值,这将确保创建的 JAXBElement 实例具有正确的 QNAME 值。

4.9 基于限定符的映射方法选择

在很多情况下需要具有相同方法签名(除了名称)但具有不同行为的映射方法。

MapStruct 提供 @Qualifier(org.mapstruct.Qualifier)注解来处理这种情况。一个“qualifier”(可译为“限定符”)可以是用户自定义的一个注解,这个注解作用在一个能被使用的映射器中的映射方法上,这个映射方法可以在对象属性映射、iterable 映射或 map 映射中被引用。一个方法和映射器上可以有多个限定符。

具体使用

假设有一个手写的映射器 Titles,其内部有多个参数类型是 String,返回值类型也是 String 的自定义映射方法:

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

public String translateTitleEG(String title) {
// some mapping logic
}

public String translateTitleGE(String title) {
// some mapping logic
}
}

在 Mapper 接口中需要使用到前文中手写的映射器:

1
2
3
4
@Mapper(uses = Titles.class)
public interface MovieMapper {
GermanRelease toGerman(OriginalRelease movies);
}

但这样是有问题的,因为在手写的映射器中有多个符合条件的映射方法,MapStruct 无法区分。

这个时候需要使用到 @Qualifier 注解。

编写一个注解,这个注解作用于 手写的映射器 上,将 @Qualifier 作为元注解使用:

1
2
3
4
5
6
7
import org.mapstruct.Qualifier;

@Qualifier
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.CLASS)
public @interface TitleTranslator {
}

由于存在多个符合条件的自定义映射方法,这些映射方法也需要使用“限定符”,再编写一些注解,这些注解作用于 自定义映射方法 上:

1
2
3
4
5
6
7
import org.mapstruct.Qualifier;

@Qualifier
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface EnglishToGerman {
}
1
2
3
4
5
6
7
import org.mapstruct.Qualifier;

@Qualifier
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface GermanToEnglish {
}

然后在手写的映射器及其内部自定义映射方法上使用这些注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
@TitleTranslator
public class Titles {

@EnglishToGerman
public String translateTitleEG(String title) {
// some mapping logic
}

@GermanToEnglish
public String translateTitleGE(String title) {
// some mapping logic
}
}

最终,Mapper 会变成这样:

1
2
3
4
5
6
@Mapper(uses = Titles.class)
public interface MovieMapper {
@Mapping(target = "title",
qualifiedBy = {TitleTranslator.class, EnglishToGerman.class})
GermanRelease toGerman( OriginalRelease movies );
}

注意:

  1. 编写的注解的生命周期必须是 @Retention(RetentionPolicy.CLASS) 的,这表示注解被编译器编译进 class 文件,但运行时会忽略。
  2. 用限定符标记的类或方法将不再适用于没有使用 qualifiedBy 属性的映射逻辑。
  3. 同样的机制也存在于 Bean 映射上,@BeanMapping#qualifiedBy 将选择用指定限定符标记的工厂方法。

使用 @Named 注解选择映射方法

在大多数情况下,声明一个新注解来完成映射方法的选择有些大材小用。对于这些情况,MapStruct 提供了 @Named 的注解,使用这个注解也可以达到 @Qualifier 的效果。👍

@Named 注解是预定义的限定符(它也被 @Qualifier 注解标记),@Named 可用于命名映射器,或者更直接地通过其 value 值命名映射方法:

1
2
3
4
5
6
@Target({ ElementType.TYPE, ElementType.METHOD})
@Retention( RetentionPolicy.CLASS )
@Qualifier
public @interface Named {
String value();
}

在手写的映射器及其内部自定义映射方法上使用 @Named 注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Named("TitleTranslator")
public class Titles {

@Named("EnglishToGerman")
public String translateTitleEG(String title) {
// some mapping logic
}

@Named("GermanToEnglish")
public String translateTitleGE(String title) {
// some mapping logic
}
}

而 Mapper 接口中不再使用 @Mapping#qualifiedBy,而是使用 @Mapping#qualifiedByName

1
2
3
4
5
@Mapper( uses = Titles.class )
public interface MovieMapper {
@Mapping(target = "title", qualifiedByName = {"TitleTranslator", "EnglishToGerman"})
GermanRelease toGerman( OriginalRelease movies );
}

使用 @Named 需要定义一个限定名称并使用,而使用 @Qualifier 则比较麻烦,需要自定义注解。虽然两者都能达到相同的效果,但使用 @Named 时应当更加小心,因为在使用 @Named 注解时,IDE 并不会提示名称是否书写正确,也不会在重构限定符名称时像重构自定义注解的名称那样巧妙地重构代码中所有使用到的地方。

4.10 结合限定符与默认值

请注意,@Mapping#defaultValue 本质上是一个 String,需要转换为 @Mapping#target。使用 @Mapping#qualifiedByName@Mapping#qualifiedBy 时将强制 MapStruct 使用指定的方法。如果想要 @Mapping#defaultValue 有不同的行为,请提供一个适当的映射方法。这个方法需要将 String 转换成 @Mapping#target 想要的类型,同时还要被限定符标记,以便 @Mapping#qualifiedByName@Mapping#qualifiedBy 能够找到。

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

@Mapping(target = "category",
qualifiedByName = "CategoryToString",
defaultValue = "DEFAULT")
GermanRelease toGerman(OriginalRelease movies);

@Named("CategoryToString")
default String defaultValueForQualifier(Category cat) {
// some mapping logic
}
}

上述示例 cat 来源中的 category 如果是 nullCategoryToString( Enum.valueOf( Category.class, "DEFAULT" ) ) 方法将被调用,其返回结果被设置给目标的 category 字段。

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

@Mapping(target = "category",
qualifiedByName = "CategoryToString",
defaultValue = "Unknown")
GermanRelease toGerman(OriginalRelease movies);

@Named("CategoryToString")
default String defaultValueForQualifier(Category cat) {
// some mapping logic
}

@Named("CategoryToString")
default String defaultValueForQualifier(String value) {
return value;
}
}

上述示例 cat 来源中的 category 如果是 nulldefaultValueForQualifier( "Unknown" ) 方法将被调用(参数为 String 的方法),其返回结果被设置给目标的 category 字段。

如果上面提到的方法不起作用,可以使用 defaultExpression 来设置默认值。

1
2
3
4
5
6
7
8
9
@Mapper
public interface MovieMapper {

@Mapping(target = "category",
qualifiedByName = "CategoryToString",
defaultExpression = "java(\"Unknown\")")
GermanRelease toGerman( OriginalRelease movies );

}

5. 映射集合

5.1 映射 Set 与 List

MapStruct 也支持 Java 中各种集合类型(比如 Set、List)的映射,包括 Java Collection Framework 中各种 可迭代 类型,其使用与映射对象类型一样。🐮

生成的代码中将包含一个循环,在循环中遍历源集合,转换每个元素并将它们放入目标集合中。

如果在给定的映射器或它使用的映射器中找到集合中元素类型的映射方法,则调用此方法执行元素转换。再或者,如果存在源元素类型和目标元素类型的隐式转换,则调用那个隐式转换。

具体使用

1
2
3
4
5
6
7
8
9
@Mapper
public interface CarMapper {

Set<String> integerSetToStringSet(Set<Integer> integers);

List<CarDto> carsToCarDtos(List<Car> cars);

CarDto carToCarDto(Car car);
}

重写的 integerSetToStringSet() 方法为每个元素执行从 IntegerString 的转换,而生成的 carsToCarDtos() 方法为每个包含的元素调用 carToCarDto() 方法。

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
// GENERATED CODE
@Override
public Set<String> integerSetToStringSet(Set<Integer> integers) {
if ( integers == null ) {
return null;
}

Set<String> set = new HashSet<String>();

for ( Integer integer : integers ) {
set.add( String.valueOf( integer ) );
}

return set;
}

@Override
public List<CarDto> carsToCarDtos(List<Car> cars) {
if ( cars == null ) {
return null;
}

List<CarDto> list = new ArrayList<CarDto>();

for ( Car car : cars ) {
list.add( carToCarDto( car ) );
}

return list;
}

当映射集合类型的属性时,MapStruct 将查找具有匹配的参数和返回类型的集合映射方法。

例如从 Car#passengersList<Person> 类型)到 CarDto#passengersList<PersonDto> 类型):

1
2
3
//GENERATED CODE
carDto.setPassengers( personsToPersonDtos( car.getPassengers() ) );
...

一些框架和库只暴露了 JavaBeans 的 Getter,而不暴露集合类型属性的 Setter。使用 JAXB 从 XML 生成的类型默认遵循这种模式。在这种情况下,用于映射这种属性的生成代码将调用其 Getter 并添加所有映射元素:

1
2
3
// GENERATED CODE
carDto.getPassengers().addAll( personsToPersonDtos( car.getPassengers() ) );
...

注意: 不允许使用可迭代的源和不可迭代的目标来声明映射方法(或者相反),否则会报错。

5.2 映射 Map

MapStruct 也支持映射 Map。比如:

1
2
3
4
5
public interface SourceTargetMapper {

@MapMapping(valueDateFormat = "dd.MM.yyyy")
Map<String, String> longDateMapToStringStringMap(Map<Long, Date> source);
}

与 【5.1 映射 Set 与 List】类似,在生成的代码将遍历源 Map,转换每个 key 和 value(通过隐式转换或调用另一个映射方法),并将它们放入目标 Map 中:

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
// GENERATED CODE
@Override
public Map<Long, Date> stringStringMapToLongDateMap(Map<String, String> source) {
if (source == null) {
return null;
}

Map<Long, Date> map = new HashMap<Long, Date>();

for (Map.Entry<String, String> entry : source.entrySet()) {

Long key = Long.parseLong(entry.getKey());
Date value;
try {
value = new SimpleDateFormat("dd.MM.yyyy" ).parse( entry.getValue());
}
catch(ParseException e) {
throw new RuntimeException(e);
}

map.put(key, value);
}

return map;
}

5.3 集合映射策略

参考博文:MapStruct文档(五)——集合映射

@Mapper 注解中有一个属性 CollectionMappingStrategy,表示集合映射策略,这个属性是个枚举类型,它有四个枚举值:

  • ACCESSOR_ONLY

  • SETTER_PREFERRED

  • ADDER_PREFERRED

  • TARGET_IMMUTABLE

目标对象中的 set、get 和 add 等方法的存在在不同场景下有不同的情况。下表介绍了目标对象在不同的集合映射策略下对 set、get 和 add 方法的应用:

只有 set 方法 只有 add 方法 set、add 方法都有 set、add 方法都没有 已经存在的集合对象
ACCESSOR_ONLY 直接 set 集合 先 get 获取集合再a ddAll 直接 set 集合 先 get 获取集合再 addAll 先 get 获取集合再 addAll
SETTER_PREFERRED 直接 set 集合 add 集合的一项 直接 set 集合 先 get 获取集合再 addAll 先 get 获取集合再 addAll
ADDER_PREFERRED 直接 set 集合 add 集合的一项 add 集合的一项 先 get 获取集合再 addAll 先 get 获取集合再 addAll
TARGET_IMMUTABLE 直接 set 集合 异常 直接 set 集合 异常 直接 set 集合

在生成 JPA 实体的情况下,通常使用 adder 方法将单个元素(实体)添加到集合中。在父级(执行 adder 方法的实体)与子级(集合中的元素,这些元素也是实体)之间通过调用 adder 方法来建立父子关系。为了找到合适的 adder,MapStruct 将尝试在集合的泛型参数类型和候选的 adder 的单个参数之间进行匹配。当有更多的候选项时,复数形式的 Setter/Getter 名称将转换为单数,并用于匹配。

不应该显式地使用 DEFAULT 选项。它用于区分用户希望显式地重写 @MapperConfig 中的默认值与 @Mapper 中 MapStruct 的隐式选择。DEFAULT 选项与 ACCESSOR_ONLY 同义。

当使用 adder 方法和 JPA 实体时,MapStruct 假设目标集合是使用该集合类型对应的某种实现(比如 List 与 ArrayList)来初始化的。可以使用工厂创建带有已被初始化的集合的新目标实体,而不是 MapStruct 通过构造方法来创建目标实体。

5.4 集合映射的实现类

当迭代器映射方法或 Map 映射方法使用一个接口类型作为返回类型时,它的一个实现类型将在生成的代码中被实例化。具体接口类型与对应的实现类型如下表:

Interface type Implementation type
Iterable ArrayList
Collection ArrayList
List ArrayList
Set LinkedHashSet
SortedSet TreeSet
NavigableSet TreeSet
Map LinkedHashMap
SortedMap TreeMap
NavigableMap TreeMap
ConcurrentMap ConcurrentHashMap
ConcurrentNavigableMap ConcurrentSkipListMap

6. 映射 Stream

java.util.Stream 的映射和集合的映射是类似的,即通过在映射器接口中定义具有所需源和目标类型的映射方法。

生成的代码将包含从所提供的迭代器或数组来创建一个 Stream,或者将所提供的 Stream 收集到一个迭代器或数组中。如果存在源元素类型和目标元素类型的映射方法或隐式转换,那么这种转换将在 Stream#map() 中完成。

具体使用

1
2
3
4
5
6
7
8
9
@Mapper
public interface CarMapper {

Set<String> integerStreamToStringSet(Stream<Integer> integers);

List<CarDto> carsToCarDtos(Stream<Car> cars);

CarDto carToCarDto(Car car);
}

生成的 integerStreamToStringSet() 方法的实现为每个元素执行从 Integer 到 String 的转换,而生成的 carsToCarDtos() 方法的实现为每个包含的元素执行 carToCarDto()方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//GENERATED CODE
@Override
public Set<String> integerStreamToStringSet(Stream<Integer> integers) {
if (integers == null) {
return null;
}

return integers.map(integer -> String.valueOf(integer))
.collect(Collectors.toCollection(HashSet<String>::new));
}

@Override
public List<CarDto> carsToCarDtos(Stream<Car> cars) {
if (cars == null) {
return null;
}

return cars.map(car -> carToCarDto(car))
.collect(Collectors.toCollection(ArrayList<CarDto>::new));
}

注意: 如果进行了从 Stream 到迭代器或数组的映射,那么所传递的 Stream 将被消费,并且不能再消费它。因为 Java 中的 Stream 只允许被消费(使用)一次。

在进行 Stream 到迭代器的映射时,迭代器的具体实现类型与【5.4 集合映射的实现类】中一致。

7. 映射值

7.1 映射枚举

MapStruct 支持将一种 Java 枚举类型映射到另一种 Java 枚举类型的方法的生成。

默认情况下,源枚举中的每个枚举项都会被映射到目标枚举类型中同名的枚举项。

如果需要,可以使用 @ValueMapping 注解(而非 @Mapping)将源枚举中的某个枚举项映射到另一个名称的枚举项。源枚举中的多个枚举项可以映射到目标枚举中的同一枚举项。

具体使用

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

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

@ValueMappings({
@ValueMapping(source = "EXTRA", target = "SPECIAL"),
@ValueMapping(source = "STANDARD", target = "DEFAULT"),
@ValueMapping(source = "NORMAL", target = "DEFAULT")
})
ExternalOrderType orderTypeToExternalOrderType(OrderType orderType);
}

生成的实现类:

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
33
34
// GENERATED CODE
public class OrderMapperImpl implements OrderMapper {

@Override
public ExternalOrderType orderTypeToExternalOrderType(OrderType orderType) {
if (orderType == null) {
return null;
}

ExternalOrderType externalOrderType_;

switch (orderType) {
case EXTRA:
externalOrderType_ = ExternalOrderType.SPECIAL;
break;
case STANDARD:
externalOrderType_ = ExternalOrderType.DEFAULT;
break;
case NORMAL:
externalOrderType_ = ExternalOrderType.DEFAULT;
break;
case RETAIL:
externalOrderType_ = ExternalOrderType.RETAIL;
break;
case B2B:
externalOrderType_ = ExternalOrderType.B2B;
break;
default:
throw new IllegalArgumentException( "Unexpected enum constant: " + orderType );
}

return externalOrderType_;
}
}

默认情况下,如果源枚举类型的枚举项在目标枚举中没有同名的枚举项,并且也未通过 @ValueMapping 映射到其他枚举值,那么 MapStruct 将产生错误。这确保了所有枚举项都以一种安全且可预测的方式进行映射。如果由于某种原因出现了无法识别的源枚举项,生成的映射方法将抛出 IllegalStateException。

MapStruct 还具有将任何剩余(未指定的)的映射映射到默认映射的机制。这只能在同一组值映射中使用一次,并且仅适用于源。它有两种策略:<ANY_REMAINING><ANY_UNMAPPED>它们不能同时使用。 比如:

1
2
@ValueMapping( source = MappingConstants.ANY_REMAINING, target = "SPECIAL" )
ExternalOrderType orderTypeToExternalOrderType(OrderType orderType);

<ANY_REMAINING>:只能用在 source 上。表示源枚举中除了同名自动映射和显式指定的映射外,其余所有枚举项都映射到指定的目标枚举项上(相当于 switch 中的 default)。

<ANY_UNMAPPED>:只能用在 source 上,不能和 <ANY_REMAINING> 同时使用,会将源枚举中除 显式 指定的映射外(此时同名自动映射将失效)的其他剩余枚举项映射到目标枚举项上。也就是说,没有经过 @ValueMapping 注解 显式 指定的枚举项都会映射到目标枚举项上。

MapStruct 可以通过 <NULL> 处理来源的 null 值和目标的 null 值。设置在 source 上表示源枚举是 null 时,被映射到指定的目标枚举;设置在 target 上表示给定的来源枚举项映射到 null

这三个值在 MappingConstants 类中以常量的形式存在:

1
2
3
4
5
6
7
8
9
10
11
12
13
public final class MappingConstants {

private MappingConstants() {
}

public static final String NULL = "<NULL>";

public static final String ANY_REMAINING = "<ANY_REMAINING>";

public static final String ANY_UNMAPPED = "<ANY_UNMAPPED>";

// --snip--
}

@InheritInverseConfiguration@InheritConfiguration 可以与 @ValueMappings 组合使用。这种情况下,<ANY_REMAINING><ANY_UNMAPPED> 会被忽略。

具体使用

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

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

@ValueMappings({
@ValueMapping(source = MappingConstants.NULL, target = "DEFAULT"),
@ValueMapping(source = "STANDARD", target = MappingConstants.NULL),
@ValueMapping(source = MappingConstants.ANY_REMAINING, target = "SPECIAL")
})
ExternalOrderType orderTypeToExternalOrderType(OrderType orderType);
}

生成的实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// GENERATED CODE
public class SpecialOrderMapperImpl implements SpecialOrderMapper {

@Override
public ExternalOrderType orderTypeToExternalOrderType(OrderType orderType) {
if ( orderType == null ) {
return ExternalOrderType.DEFAULT;
}

ExternalOrderType externalOrderType_;

switch ( orderType ) {
case STANDARD: externalOrderType_ = null;
break;
case RETAIL: externalOrderType_ = ExternalOrderType.RETAIL;
break;
case B2B: externalOrderType_ = ExternalOrderType.B2B;
break;
default: externalOrderType_ = ExternalOrderType.SPECIAL;
}

return externalOrderType_;
}
}

使用 <ANY_UNMAPPED> 时,MapStruct 将不会自动映射 RETAILB2B

不建议 使用 @Mapping 注解将枚举映射到枚举。使用 @Mapping 注解来映射枚举的方式将在未来的 MapStruct 版本中删除。

7.2 枚举与字符串的相互映射

MapStruct 支持枚举与字符串间的相互映射,这与枚举与枚举之间的映射很类似,但也存在着部分差异。

枚举映射到字符串

  1. 相同:在未显式定义映射方式的情况下,每个源枚举项被映射为具有相同名称的 String 值。
  2. 相同:<ANY_UNMAPPED> 在处理定义的映射后停止,并继续执行 switch 中 default 子句值。
  3. 差异:不支持 <ANY_REMAINING> ,使用这个值将导致错误。它的作用前提是源枚举项和目标枚举项之间存在名称相似性,而这对于 String 类型没有意义。
  4. 差异:鉴于 1.3. 因此永远不会有未映射的值。

字符串映射到枚举

  1. 相同:在未显式定义映射方式的情况下,给定的 String 将被映射到与目标枚举项名称匹配的值。
  2. 相同:<ANY_UNMAPPED> 在处理定义的映射后停止,并继续执行 switch 中 default 子句值。
  3. 相同:<ANY_REMAINING> 将为每个目标枚举项创建一个映射,然后继续执行 switch 中 default 子句值。
  4. 差异:需要提供 switch 中 default 子句才能得出确定的结果(枚举值集有限,String 选项不限)。不指定 <ANY_REMAINING><ANY_UNMAPPED> 将出现警告。

具体使用

1
2
3
4
5
6
7
8
9
public enum ExternalOrderType {
DEFAULT,

RETAIL,

B2B,

SPECIAL,
}
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
@Mapper
public interface EnumStringMapper {
EnumStringMapper INSTANCE = Mappers.getMapper(EnumStringMapper.class);

@ValueMappings({
@ValueMapping(source = MappingConstants.NULL, target = "DEFAULT"),
@ValueMapping(source = "RETAIL", target = MappingConstants.NULL),
@ValueMapping(source = MappingConstants.ANY_UNMAPPED, target = "SPECIAL")
})
String enumToString(ExternalOrderType type);

@ValueMappings({
@ValueMapping(source = MappingConstants.NULL, target = "DEFAULT"),
@ValueMapping(source = "STANDARD", target = MappingConstants.NULL),
@ValueMapping(source = MappingConstants.ANY_UNMAPPED, target = "SPECIAL")
})
ExternalOrderType stringToEnumWithUnmapped(String str);

@ValueMappings({
@ValueMapping(source = MappingConstants.NULL, target = "DEFAULT"),
@ValueMapping(source = "STANDARD", target = MappingConstants.NULL),
@ValueMapping(source = MappingConstants.ANY_REMAINING, target = "SPECIAL")
})
ExternalOrderType stringToEnumWithRemaining(String str);
}

生成的实现类:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public class EnumStringMapperImpl implements EnumStringMapper {

@Override
public String enumToString(ExternalOrderType type) {
if ( type == null ) {
return "DEFAULT";
}

String string;

switch ( type ) {
case RETAIL: string = null;
break;
default: string = "SPECIAL";
}

return string;
}

@Override
public ExternalOrderType stringToEnumWithUnmapped(String str) {
if ( str == null ) {
return ExternalOrderType.DEFAULT;
}

ExternalOrderType externalOrderType;

switch ( str ) {
case "STANDARD": externalOrderType = null;
break;
default: externalOrderType = ExternalOrderType.SPECIAL;
}

return externalOrderType;
}

@Override
public ExternalOrderType stringToEnumWithRemaining(String str) {
if ( str == null ) {
return ExternalOrderType.DEFAULT;
}

ExternalOrderType externalOrderType;

switch ( str ) {
case "STANDARD": externalOrderType = null;
break;
case "DEFAULT": externalOrderType = ExternalOrderType.DEFAULT;
break;
case "RETAIL": externalOrderType = ExternalOrderType.RETAIL;
break;
case "B2B": externalOrderType = ExternalOrderType.B2B;
break;
case "SPECIAL": externalOrderType = ExternalOrderType.SPECIAL;
break;
default: externalOrderType = ExternalOrderType.SPECIAL;
}

return externalOrderType;
}
}

7.3 自定义名称转换

在未使用 @ValueMapping 的情况下,源枚举中的每个枚举项将 默认 映射到目标枚举中具有相同名称的枚举项。

但在某些情况下,源枚举需要在进行映射之前进行转换。比如目标枚举相比源枚举增加了统一的后缀呢,这时候将无法进行同名自动映射。

使用 @ValueMapping 依次指定映射规则固然可以,但相对繁琐。MapStruct 提供了一个名为 @EnumMapping 的注解来解决这个问题。

具体使用

假设有如下两个枚举:

1
2
3
4
5
6
7
8
9
public enum CheeseType {
BRIE,
ROQUEFORT
}

public enum CheeseTypeSuffixed {
BRIE_TYPE,
ROQUEFORT_TYPE
}

可以这样定义 Mapper 接口实现两个枚举类之间的转换:

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

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

@EnumMapping(nameTransformationStrategy = "suffix", configuration = "_TYPE")
CheeseTypeSuffixed map(CheeseType cheese);

@InheritInverseConfiguration // 用于反向映射
CheeseType map(CheeseTypeSuffix cheese);
}

最终生成的实现类:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// GENERATED CODE
public class CheeseSuffixMapperImpl implements CheeseSuffixMapper {

@Override
public CheeseTypeSuffixed map(CheeseType cheese) {
if (cheese == null) {
return null;
}

CheeseTypeSuffixed cheeseTypeSuffixed;

switch (cheese) {
case BRIE:
cheeseTypeSuffixed = CheeseTypeSuffixed.BRIE_TYPE;
break;
case ROQUEFORT:
cheeseTypeSuffixed = CheeseTypeSuffixed.ROQUEFORT_TYPE;
break;
default:
throw new IllegalArgumentException("Unexpected enum constant: " + cheese);
}

return cheeseTypeSuffixed;
}

@Override
public CheeseType map(CheeseTypeSuffixed cheese) {
if (cheese == null) {
return null;
}

CheeseType cheeseType;

switch (cheese) {
case BRIE_TYPE:
cheeseType = CheeseType.BRIE;
break;
case ROQUEFORT_TYPE:
cheeseType = CheeseType.ROQUEFORT;
break;
default:
throw new IllegalArgumentException("Unexpected enum constant: " + cheese);
}

return cheeseType;
}
}

MapStruct 提供了以下四种开箱即用的枚举名称转换策略:

名称 含义
suffix 为源枚举添加后缀
stripSuffix 从源枚举中删除后缀
prefix 为源枚举添加前缀
stripPrefix 从源枚举中删除前缀

在 MapStruct 1.5.0.Final 中新增转换策略 case,这种策略将为源枚举进行大小写转换,支持的方式有:

  • upper:将源枚举转换为大写
  • lower:将源枚举转换为大写
  • capital:将源枚举中每个单词的首字母大写,其余字母小写,每个单词之间以下划线分割

也可以注册自定义策略,这会在后文中讲到。

8. Spring 拓展

官方文档:MapStruct Spring Extensions 1.0.0 Reference Guide

MapStruct Spring Extensions 是一个 Java 注释处理器,它扩展了著名的 MapStruct 项目并带有 Spring 框架特性。

在 Spring 有一个名为 Converter,重写该接口的 convert() 方法后,可以将某个对象转换成另一个对象,这与 MapStruct 的功能不谋而合。使用 MapStruct Spring Extensions 后,在编译期间将生成一个适配器,该适配器允许标准 MapStruct 映射器使用 Spring 的 ConversionService

这使开发人员能够在其 uses 属性中仅使用 ConversionService 来定义 MapStruct 映射器,而不必单独导入每个 Mapper,从而使得各个 Mapper 之间解耦。

注意: MapStruct Spring Extensions 至少要在 MapStruct 1.4.0.Final 的版本上运行!

8.1 所选依赖信息

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- 别用最新版,1.5.x 由 JDK11 编译 -->
<org.mapstruct.version>1.4.2.Final</org.mapstruct.version>
<org.mapstruct.extensions.spring.version>0.1.1</org.mapstruct.extensions.spring.version>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<!-- 就用这版本,否则可能出现 No property named “XXX“ exists in source parameter(s) 的错误 -->
<lomnok.version>1.18.12</lomnok.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.3.25</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>5.3.25</version>
<scope>test</scope>
</dependency>

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

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lomnok.version}</version>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.mapstruct.extensions.spring</groupId>
<artifactId>mapstruct-spring-annotations</artifactId>
<version>${org.mapstruct.extensions.spring.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> <!-- depending on your project -->
<target>1.8</target> <!-- depending on your project -->
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
<path>
<groupId>org.mapstruct.extensions.spring</groupId>
<artifactId>mapstruct-spring-extensions</artifactId>
<version>${org.mapstruct.extensions.spring.version}</version>
</path>
<!--使用 Lombok 后,这里也要添加-->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lomnok.version}</version>
</path>
<!-- other annotation processors -->
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>

8.2 映射器作为转换器

新建 MapStruct 映射器接口,继承 Spring 的 Converter 接口:

1
2
3
4
5
6
7
8
9
10
11
12
package indi.mofan.converter;

import org.springframework.core.convert.converter.Converter;

@Mapper(componentModel = "spring")
public interface CarConverter extends Converter<Car, CarDto> {
@Mappings({
@Mapping(target = "seatCount", source = "numberOfSeats"),
@Mapping(target = "manufacturingDate", source = "manufacturingDate", dateFormat = "yyyy-MM-dd")
})
CarDto convert(Car car);
}

为这个映射器编写一一段测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @author mofan
* @date 2023/2/26 21:50
*/
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = SpringConfig.class)
public class SpringExtensionsTest {
@Autowired
private ConversionService conversionService;

@Test
public void testSimpleUse() {
Date manufacturingDate = new Date();
Car car = new Car("Morris", 5, "Sedan", manufacturingDate);
CarDto carDto = conversionService.convert(car, CarDto.class);
Assert.assertNotNull(carDto);
Assert.assertEquals("Morris", carDto.getMake());
Assert.assertEquals(5, carDto.getSeatCount());
Assert.assertEquals("Sedan", carDto.getType());
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
String date = format.format(manufacturingDate);
Assert.assertEquals(date, carDto.getManufacturingDate());
}
}

运行测试代码后,测试并没有通过,提示 Spring 容器中不存在 ConversionService 的 Bean。

尝试在 Spring 配置类 SpringConfig 中添加 ConversionService 类型的 Bean 后,再次运行,依旧测试失败,提示不存在目标转换器。

因此还需要将定义的转换器注入到 ConversionService 类型的 Bean 中,最终 Spring 配置类中的信息如下:

1
2
3
4
5
6
7
8
9
10
11
@Configuration
@ComponentScan({"indi.mofan.mapper", "indi.mofan.converter"})
public class SpringConfig {

@Bean
public <S, T> DefaultConversionService conversionService(List<Converter<S, T>> converters) {
DefaultConversionService service = new DefaultConversionService();
converters.forEach(service::addConverter);
return service;
}
}

其中 @ComponentScan 注解扫描的 indi.mofan.converter 包为转换器所在的包。

最后再次运行测试方法后,测试通过。

8.3 ConversionServiceAdapter

使用 MapStruct Spring Extensions 后,会生成一个包含所有转换方法的适配器类 —— ConversionServiceAdapter

当一个 Mapper 调用另一个 Mapper 时,需要在调用 Mapper 的 @Mapper#uses 属性中指定被调用的 Mapper 信息,这会造成两个 Mapper 之间的耦合。

利用生成的适配器类,现在只需要在 @Mapper#uses 指定 ConversionServiceAdapter 即可,而不是具体的 Mapper。

代码示例

被调用的 Mapper:

1
2
3
4
5
@Mapper(componentModel = "spring")
public interface AnimalConverter extends Converter<Animal, DogDto> {
@Override
DogDto convert(Animal source);
}

调用的 Mapper:

1
2
3
4
5
6
7
8
9
10
11
12
import org.mapstruct.extensions.spring.converter.ConversionServiceAdapter;

/**
* @author mofan
* @date 2023/2/27 0:34
*/
@Mapper(componentModel = "spring", uses = ConversionServiceAdapter.class)
public interface CageConverter extends Converter<Cage, CageDto> {
@Override
@Mapping(source = "animal", target = "dog")
CageDto convert(Cage source);
}

默认生成的 ConversionServiceAdapterorg.mapstruct.extensions.spring.converter 包下,在未编译前,这些代码可能报错,不用担心,大胆运行!

还要在 Spring 的配置类中让 Spring 扫描 ConversionServiceAdapter 所在的包:

1
2
3
4
5
@Configuration
@ComponentScan({"indi.mofan.mapper", "indi.mofan.converter", "org.mapstruct.extensions.spring.converter"})
public class SpringConfig {
// --snip--
}

测试方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
public void testUseAdapter() {
Cage cage = new Cage();
Animal animal = new Animal("小黑", "Dog");
cage.setAnimal(animal);
cage.setName("小黑的家");
cage.setSize("70*50*60");
CageDto cageDto = conversionService.convert(cage, CageDto.class);
Assert.assertNotNull(cageDto);
Assert.assertEquals("小黑的家", cageDto.getName());
Assert.assertEquals("70*50*60", cageDto.getSize());
Assert.assertEquals("小黑", cageDto.getDog().getName());
Assert.assertEquals("Dog", cageDto.getDog().getType());
}

运行测试方法后,测试通过!

8.4 自定义名称

默认情况下,生成的适配器名为 ConversionServiceAdapter,在 org.mapstruct.extensions.spring.converter 包下。

如果想要自定义名称和包名,可以使用 @SpringMapperConfig 注解:

1
2
3
4
5
6
@SpringMapperConfig(
conversionServiceAdapterPackage = "indi.mofan.adapter",
conversionServiceAdapterClassName = "MyAdapter"
)
public interface MapperSpringConfig {
}

指定生成的适配器名为 MyAdapter,所在包路径为 indi.mofan.adapter。这样 Spring 在扫描包时就不用再扫描 org.mapstruct.extensions.spring.converter,直接扫描 indi.mofan 即可:

1
2
3
4
5
@Configuration
@ComponentScan("indi.mofan")
public class SpringConfig {
// --snip--
}

使用 @SpringMapperConfig 注解如果没有特别指定 conversionServiceAdapterPackage,那么适配器类将生成在 @SpringMapperConfig 所标记的类的相同路径下。

8.5 指定 ConversionService Bean 的名称

当 Spring 容器中存在多个 ConversionService 类型的 Bean 时,需要指定 MapStruct Spring Extensions 所用的 ConversionService 类型的 Bean 的名称。

可以使用 @SpringMapperConfig 注解的 conversionServiceBeanName 属性进行指定。

1
2
3
4
5
6
7
@SpringMapperConfig(
conversionServiceAdapterPackage = "indi.mofan.adapter",
conversionServiceAdapterClassName = "MyAdapter",
conversionServiceBeanName = "myConversionService"
)
public interface MapperSpringConfig {
}

Spring 配置类中注入 ConversionService 类型的 Bean 的方法名发生变化:

1
2
3
4
@Bean
public <S, T> DefaultConversionService myConversionService(List<Converter<S, T>> converters) {
// --snip--
}

@Bean 注解通过所标记的方法名称来指定 Bean 的名称。

而在依赖注入 ConversionService 的地方,需要使用 @Qualifier 注解指定 Bean 的名称,比如:

1
2
3
@Autowired
@Qualifier("myConversionService")
private ConversionService conversionService;

8.6 外置转换

Spring 提供了许多的内置转换,比如将 String 类型转换成 Locale 类型,将 Object 类型转换成 Optional 类型。为了使 MapStruct 在运行过程中也能使用这些转换器,可以在 @SpringMapperConfig 注解的 externalConversions 属性中指定使用的外置转换。

在与 Spring 的整合过程中,映射器的 @Mapper 注解都要指定 componentModelspring,如果还有调用 Mapper 的情况,还需要指定 uses 为生成的适配器类型。当配置更多时,重复的 @Mapper 看着十分恶心,此时可以使用 @MapperConfig 注解来共享配置,而后在 @Mapper 注解中指定 config 属性值为 @MapperConfig 标记的类即可。

1
2
3
4
5
6
7
8
9
10
11
12
@MapperConfig(componentModel = "spring", uses = MyAdapter.class)
@SpringMapperConfig(
conversionServiceAdapterPackage = "indi.mofan.adapter",
conversionServiceAdapterClassName = "MyAdapter",
conversionServiceBeanName = "myConversionService",
externalConversions = {@ExternalConversion(
sourceType = CarDto.class,
targetType = Optional.class
)}
)
public interface MapperSpringConfig {
}

新定义的转换器:

1
2
3
4
5
6
@Mapper(config = MapperSpringConfig.class)
public interface OptionalConverter extends Converter<NestedDto, OptionalDto> {
@Override
@Mapping(source = "carDto", target = "optional")
OptionalDto convert(NestedDto source);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
public void testExternalConversions() {
CarDto carDto = new CarDto();
carDto.setMake("Morris");
NestedDto nestedDto = new NestedDto();
nestedDto.setCarDto(carDto);

OptionalDto convert = conversionService.convert(nestedDto, OptionalDto.class);
Assert.assertNotNull(convert);
Optional<CarDto> optional = convert.getOptional();
Assert.assertNotNull(optional);
Assert.assertTrue(optional.isPresent());
Assert.assertEquals("Morris", optional.map(CarDto::getMake).orElse(""));
Assert.assertNull(optional.map(CarDto::getType).orElse(null));
}

8.7 转换 List

完成了单个对象的转换,但对于是列表的来源数据,除了可以使用循环、Stream 等操作外,可以编写一个工具类完成转换:

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
33
34
35
36
@Component
public class SpringExtensionHelper implements ApplicationContextAware {
private static ApplicationContext context;

public static final String CONVERSION_SERVICE_BEAN_NAME = "myConversionService";

private static ConversionService getConversionService() {
return context.getBean(CONVERSION_SERVICE_BEAN_NAME, ConversionService.class);
}

public static <T> T convert(Object source, Class<T> targetType) {
return getConversionService().convert(source, targetType);
}


public static Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
return getConversionService().convert(source, sourceType, targetType);
}

@SuppressWarnings("unchecked")
public static <T, R> List<R> convertList(List<T> source, Class<R> targetClass) {
if (CollectionUtils.isEmpty(source)) {
return Collections.emptyList();
}
return (List<R>) convert(
source,
TypeDescriptor.collection(List.class, TypeDescriptor.valueOf(source.get(0).getClass())),
TypeDescriptor.collection(List.class, TypeDescriptor.valueOf(targetClass))
);
}

@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
SpringExtensionHelper.context = applicationContext;
}
}
1
2
3
4
5
6
7
8
9
10
11
@Test
public void testConvertList() {
Car car = new Car();
car.setMake("Morris");

List<CarDto> carDtoList = SpringExtensionHelper.convertList(Collections.singletonList(car), CarDto.class);
Assert.assertEquals(1, carDtoList.size());
CarDto carDto = carDtoList.get(0);
Assert.assertNotNull(carDto);
Assert.assertEquals("Morris", carDto.getMake());
}

9. 结语

博主精力有限(好吧,就是懒狗 🐶),暂时就翻译这么多,以上内容足以满足日常所需,更高级的特性待使用到再进行补充。

待完善的内容:

  • 对象工厂
  • 高级映射选项
  • 重用映射配置
  • 自定义映射
  • 使用 MapStruct SPI