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
注解来配置。我们先在 SubDomain
和 SubDto
中分别增加 addressCity
和 addressProvince
字段。
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
值。
修改上面介绍过的 SubDomain
和 Dto
。
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 设置成不同的目标字段?当然可以,我们只要自己写 SubDomain
到 address
属性的映射方法就可以。
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 使用 @Mapping
的 expression
属性
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
- 使用 6.1 中讲的
expression
属性,给 target
直接设值。
@Mapping
的 constant
属性,直接给字段设置值
@Mapping
的 defaultValue
属性给字段设置默认值,如果 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
分开的,所以需要在其它方法上将加上 @Named
等 qualified
注解,
而默认的方法上不加该注解。
这样设置,如果没在 @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 工具。