MapStruct 使用介绍

MapStruct 使用介绍

MapStruct 是一个 Bean 映射工具,通过配置的注解,可以生成 Java 代码进行 Bean 映射。

由于是通过两个 Bean 的信息生成 Java 代码,并通过调用属性的 Getter/Setter 方法来实现的功能,性能上比其它通过反射实现的 Bean 映射工具要高很多,相当于自己手写代码复制对象。

本文主要介绍 MapStruct 的几种对象映射场景,方便使用时选用。

集成 MapStruct

在 Gradle 中集成很简单,只用增加一个依赖和注解处理器配置既可。

1
2
3
4
dependencies {
compile "org.mapstruct:mapstruct:1.3.1.Final"
annotationProcessor "org.mapstruct:mapstruct-processor:1.3.1.Final"
}

场景

1. 正常的 Domain 到 Dto

对象声明
1
2
3
4
5
6
7
8
9
10
11
@Data
class Domain {
private String userId;
private String password;
}

@Data
class Dto {
private String userId;
private String password;
}
映射代码

MapStruct 默认会根据对象的字段名进行映射,如果存在不同名的字段会在编译的时候告警

1
2
3
4
5
6
private static DomainMapper mapper = Mappers.getMapper(DomainMapper.class);

@Mapper
interface DomainMapper {
Dto toDto(Domain domain);
}

将上面的 Dto 类的 password 字段改为 password2 重新编译可看到以下告警。

1
2
3
4
warning: Unmapped target property: "password2".
Dto toDto(Domain domain);
^
1 warning

MapStruct 是根据目标的类的属性来生成设置方法的,如果目标类存在,源类不存在则会产生以上告警。

2. 存在不同名的字段属性

对于不同名的映射,MapStruct 显然不知道怎么生成代码,所以我们需要使用 @Mapping 告诉 MapStruct 如何配置。
上面的 password2 映射需调整映射类代码如下。 source 表示 domain 中字段名,target 表示在 dto 中的字段名。

1
2
3
4
5
@Mapper
interface DomainMapper {
@Mapping(source = "password", target = "password2")
Dto toDto(Domain domain);
}

3. 内嵌子类的映射

新增以下两个类,并作为属性配置到类中。对于内嵌的属性,MapStruct 会生成一个方法来处理,处理的逻辑和外面是一样的,默认会自动处理同名属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class SubDomain {
private String address;
}

class SubDto {
private String address;
}

class Domain {
// ...
private SubDomain address;
}

class Dto {
// ...
private SubDto address;
}

生成的映射类如下:

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
class Test$DomainMapperImpl implements DomainMapper {

@Override
public Dto toDto(Domain domain) {
if ( domain == null ) {
return null;
}

Dto dto = new Dto();

dto.setPassword2( domain.getPassword() );
dto.setUserId( domain.getUserId() );
dto.setName( domain.getName() );
dto.setAddress( subDomainToSubDto( domain.getAddress() ) );

return dto;
}

protected SubDto subDomainToSubDto(SubDomain subDomain) {
if ( subDomain == null ) {
return null;
}

SubDto subDto = new SubDto();

subDto.setAddress( subDomain.getAddress() );

return subDto;
}
}

4. 指定内嵌子类的属性映射

如果字类中不同属性名做映射,可以用 @Mapping 注解来配置。我们先在 SubDomainSubDto 中分别增加 addressCityaddressProvince 字段。

1
2
3
4
5
6
7
8
class SubDomain {
private String address;
private String addressCity;
}
class SubDto {
private String address;
private String addressProvince;
}

然后修改 Mapper 类为如下样式。通过 .(Dot) 来表示对应属性下的属性。

1
2
3
4
5
6
@Mapper
interface DomainMapper {
@Mapping(source = "password", target = "password2")
@Mapping(source = "address.addressCity", target = "address.addressProvince")
Dto toDto(Domain domain);
}

5. 一个字段映射到多个字段

假如一个字段是一个类或 Map 类型(address),而目标对象的一个属性是源类的该属性(address)的 address 值,另一属性是 addressCity 值。

修改上面介绍过的 SubDomainDto

1
2
3
4
5
6
7
8
9
10
11
12
13
class SubDomain {
private String address;
private String addressCity;
}

class Dto {
private String userId;
private String password2;
private String name;

private String address;
private String addressCity;
}

和第四节的方法类似,Mapper 接口定义如下

1
2
3
4
5
6
7
@Mapper
interface DomainMapper {
@Mapping(source = "password", target = "password2")
@Mapping(source = "address.address", target = "address")
@Mapping(source = "address.addressCity", target = "addressCity")
Dto toDto(Domain domain);
}

那可不可以直接 source 配置为 address,target 设置成不同的目标字段?当然可以,我们只要自己写 SubDomainaddress 属性的映射方法就可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Mapper
interface DomainMapper {
@Mapping(source = "password", target = "password2")
@Mapping(source = "address", target = "address", qualifiedByName = "subDomainAddr")
@Mapping(source = "address", target = "addressCity", qualifiedByName = "subDomainAddrCity")
Dto toDto(Domain domain);

@Named("subDomainAddr")
default String toDtoAddr(SubDomain domain) {
return domain.toString();
}

@Named("subDomainAddrCity")
default String toDtoAddrCity(SubDomain domain) {
return domain.toString();
}
}

MapStruct 是基于类型查找映射方法的,如果此处不用 qualified 的方式,MapStruct 不知道该选择 toDtoAddr 还是 toDtoAddrCity
来处理这里的字段映射,因为这两个方法都是传入 SubDomain 传出 String

6. 多字段到单字段

如果需要多个字段的内容合并到一个字段的类上,一般有三种方法

6.1 使用 @Mappingexpression 属性

expression 中可以写 Java 代码,可以通过 Java 代码进行组合

1
2
@Mapping( target = "address", expression = "java(dto.getAddressCity() + dto.getAddress())") 
Domain toDomain(Dto dto);
6.2 使用 @AfterMapping 注解

使用这种方法需要先使用 @Mapping 注解把 address 给忽略掉,不然可能会有报错(如果同名不同类型会报错,或者未映射告警)

1
2
3
4
5
6
7
@Mapping(target = "address", ignore = true)
Domain toDomain(Dto dto);

@AfterMapping
default void setAddress(@MappingTarget Domain domain, Dto dto) {
domain.setAddress(new SubDomain(dto.getAddress(), dto.getAddressCity()));
}
6.3 使用直接设置

如果源和目标的类型一样,不需要做特殊处理的话,使用直接设置的方法是比较建议的。

1
2
3
@Mapping(source = "addressDetail", target = "address.address")
@Mapping(source = "addressCity", target = "address.addressCity")
Domain toDomain(Dto dto);

7. 两个对象到一个对象

比如 C 对象是字段是 A 和 B 两个对象组合的,可以直接使用参数的变量名来配置映射。

1
2
3
4
5
6
@Mapper
interface DomainMapper {
@Mapping(source = "a.address", target = "address")
@Mapping(source = "b.name", target = "name")
C toDto(A a, B b);
}

8. 列表类型到单个类型

比如 List<String>String,这种是默认没有方法的,需要自己编写方法处理

1
2
3
4
5
6
@Mapper
interface DomainMapper {
default String toStr(List<String> strings) {
return String.join(",", strings);
}
}

9. 字符串到日期类型

MapStruct 本身内置了日期和字符串转换的功能,如果需要自定义日期格式可以在 @Mapping 注解的 dateFormat 属性中指定。

1
@Mapping(source = "date", target = "date", dateFormat = "yyyy-MM-dd HH:mm:ss")

10. 无 source 字段直接设置 target

  1. 使用 6.1 中讲的 expression 属性,给 target 直接设值。
  2. @Mappingconstant 属性,直接给字段设置值
  3. @MappingdefaultValue 属性给字段设置默认值,如果 source 的字段为 null 生效

通用

对于一些非常常用的方法,我们可以把方法放到通用的类中,使用的时候用 @Mapper 注解的 uses 属性引进来。我们以字符串数组转换为字符串 为例。

字符串数组转换为字符串

List<String>String 的转换,我们默认以 , 作分隔,将字符串拼接起下,Mapper 接口如下

1
2
3
4
5
6
@Mapper
interface ListStrMapper {
default String toStr(List<String> strings) {
return String.join(",", strings);
}
}

使用的时候,直接将 Mapper 类引进来就可以 ListStrMapper 类中的 toStr 方法就像写在 DomainMapper 中一样

1
2
3
@Mapper(uses = {ListStrMapper.class})
interface DomainMapper {
}

但是显然我们如果不只是需要 , 号分隔的字符串应该如何实现?比如默认用逗号,可以用额外的参数来配置。

传参配置 Mapper 方法

MapStruct 支持通过 @Context 注解,将参数传递给方法中,我们将上面的 ListStrMapper 调整一下,增加个参数 delimiter 让用户可以自定义分隔符。

1
2
3
4
5
6
@Mapper
interface ListStrMapper {
default String toStr(List<String> strings, @Context String delimiter) {
return String.join(delimiter, strings);
}
}

这样使用的时候也需要传递 @Context 参数

1
2
3
4
5
6
@Mapper(uses = ListStrMapper.class)
interface DomainMapper {
Domain toDomain(Dto dto, @Context String delimiter);


}

为了方便,我们可以增加一个重载方法,使用默认的分隔符

1
2
3
4
5
6
7
8
@Mapper(uses = ListStrMapper.class)
interface DomainMapper {
Domain toDomain(Dto dto, @Context String delimiter);

default Domain toDomain(Dto dto) {
return toDomain(dto, ",");
}
}

如果想直接在 ListStrMapper 类中实现默认方法的话需要使用 qualified 的方式,因为 MapStruct 目前是无法将有没有 @Context 分开的,所以需要在其它方法上将加上 @Namedqualified 注解,
而默认的方法上不加该注解。

这样设置,如果没在 @Mapping 注解上使用 qualified 属性,MapStruct 将使用没注解的默认方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Mapper
interface ListStrMapper {
String TO_STR_WITH_DELIMITER = "toStrWithDelimiter";

@Named(TO_STR_WITH_DELIMITER)
default String toStrWithDelimiter(List<String> strings, @Context String split) {
return String.join(split, strings);
}

default String toStr(List<String> strings) {
return toStr(strings, ",");
}
}

使用的 Mapper 类也要做相应的调整,如果使用下面这样的会直接使用 toStr 方法,不管是否提供了 @Context

1
2
@Mapping(source = "stringList", target = "string")
Domain toDomain(Dto dto, @Context String delimiter);

而指定特定名字就没问题了,以下方法使用的是 toStrWithDelimiter

1
2
3
4
5
@Mapping(source = "stringList", target = "string", qualifiedByName = {ListStrMapper.TO_STR_WITH_DELIMITER})
Domain toDomain(Dto dto, @Context String delimiter);

@InheritConfiguration
Domain toDomain(Dto dto);

没有 @Context 的直接用 @InheritConfiguration 来把上一个方法的配置继承下来。

总结

MapStruct 是一个高效易用的工具,完全可以替换各种基于反射的 BeanUtils 工具。

作者

Jakes Lee

发布于

2020-04-01

更新于

2021-11-18

许可协议

评论