
Rorschach
2023/02/23阅读:64主题:前端之巅同款
MapStruct食用指南
前言
日常开发中,不同JavaBean之间的拷贝是非常常见的,常用的框架有:Spring BeanUtils、Apache BeanUtils、Dozer、Orika、JMapper、MapStruct等。
BeanUtils等是基于反射调用get,set方法,性能相对较弱,并且你不知道它是如何处理每个字段的映射的。
而手写get、set方法又太麻烦,维护也不是很方便,比如新增字段、删除字段等,都需要去手动调整。
MapStruct就比较折中,它会在编译阶段自动帮你生成get、set方法,兼具了手写get、set的性能和BeanUtils的便捷,并且还可以看到自动生成的代码,同时还有强大的字段自定义映射的功能。
本文将介绍MapStruct的常见使用方法。
准备工作
先引入依赖,以Maven为例:
...
<properties>
<org.mapstruct.version>1.4.2.Final</org.mapstruct.version>
</properties>
...
<dependencies>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
</dependencies>
...
此外也可以增加构建工具,但这不是必须的:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
<!--如果使用了lombok最好加上,否则编译器不会生成get、set方法-->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<!--如果 Lombok 版本为 1.18.16 及以上,需要添加,否则可以不用-->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.1.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
如果使用的是IDEA,可以额外装个插件:MapStruct Support,可以有自动提示,当然,这也不是必须的。
快速上手
举个栗子
假设有如下两个对象需要做转换
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserDO {
private Integer id;
private String name;
private Integer age;
private Date birthday;
private String gender;
}
@Data
public class UserVO {
private String userName;
private Integer age;
private Date birthday;
private String gender;
}
只需创建一个UserConverter的接口或抽象类
@Mapper
public interface UserConverter {
//获取生成的实现类
UserConverter INSTANCE = Mappers.getMapper(UserConverter.class);
UserVO convert(UserDO person);
}
使用
UserDO userDO = new UserDO(1, "张三", 25, new Date(), "男");
UserVO userVO = UserConverter.INSTANCE.convert(userDO);
以上便是MapStruct最基本的使用了,相比于BeanUtils麻烦的地方在于,需要定义一个接口,然后写一个转换的方法。
项目编译以后就会生成如下的实现类,文件位于项目中的target/generated-sources/annotations/...
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2022-05-07T19:03:37+0800",
comments = "version: 1.4.2.Final, compiler: javac, environment: Java 1.8.0_291 (Oracle Corporation)"
)
public class UserConverterImpl implements UserConverter {
@Override
public UserVO convert(UserDO person) {
if ( person == null ) {
return null;
}
UserVO userVO = new UserVO();
userVO.setAge( person.getAge() );
userVO.setBirthday( person.getBirthday() );
userVO.setGender( person.getGender() );
return userVO;
}
}
通过这个生成的实现类,我们可以很清晰得知道MapStruct是如何映射每个字段的。
如果你两个转换的对象字段名和类型都完全一致的话,那只需要这样就可以了,MapStruct会根据字段名自动去做映射,即隐式映射,不需要额外的配置。
更新存在的实例
上面的方式是new一个新的实例出来,有时候我们可能已经有一个实例对象了,希望给这个已有的实例set值,则可以这么写
void convert(UserDO source, @MappingTarget UserVO target);
此时convert方法返回空,UserVO前面增加了一个@MappingTarget
注解,这个注解是告诉MapStruct我们需要将值写入到这个对象中。
生成的代码如下:
...
@Override
public void convert(UserDO source, UserVO target) {
if ( source == null ) {
return;
}
target.setAge( source.getAge() );
target.setGender( source.getGender() );
target.setBirthday( source.getBirthday() );
}
...
定义映射器
基础使用
有些时候两个对象可能会有字段不完全一样的情况(类型不一致或者字段名不一致),则此时需要使用@Mapping
手动指定映射关系,下面以最常见的两种情况为例,介绍下@Mapping的基本使用方法。
字段名不同、类型相同
上面定义的两个对象中,有两个名称不一致的字段:
//UserDO
private String name;
//UserVO
private String userName;
由于字段名不同,所以MapStruct就做不了映射了,需要在convert方法上加上@Mapping注解:
@Mapping(source = "name", target = "userName")
UserVO convert(UserDO person);
source
就是UserBO中的字段名,target
就是UserVO中的字段名,做好映射以后MapStruct就会为我们生成对应的代码:
...
userVO.setUserName( person.getName() );
...
字段名相同、类型不同
我们做转换的对象可能会出现字段名一致,但是类型不同的情况,以上面两个对象为例,改变下birthday的类型:
//UserDO
private Date birthday;
//UserVO
private Long birthday;
UserVO中改成了Long型,如果此时编译,则会报错,因为MapStruct不知道如何将Date转换成Long,这个时候就需要写一个转换方法了,所以UserConverter会变成这样:
@Mapper
public interface UserConverter {
//获取生成的实现类
UserConverter INSTANCE = Mappers.getMapper(UserConverter.class);
//指定映射关系,此处可不写
@Mapping(target = "birthday", expression = "java(date2Long(person.getBirthday()))")
UserVO convert(UserDO person);
//在UserConverter中增加一个默认方法
default Long date2Long(Date date) {
return date != null ? date.getTime() : null;
}
}
稍微有点点麻烦了,特别是@Mapping中的expression,写起来很麻烦的样子,但实际上,如果你的转换类还不是很复杂,就如现在这样的话,这个@Mapping是可以省去的,MapStruct会根据入参和返回值类型,自动匹配你定义的方法,即隐式调用,所以你即使不写@Mapping,MapStruct也会使用你的date2Long方法去转换birthDay的值(看上去很智能,但有时候也会弄巧成拙,后文会提到)。
但是如果你的Mapper接口比较复杂了,里面定义了出参和返回值相同的两个方法,则必须使用@Mapping指定使用哪个方法(或者使用@Named标记方法防止隐式调用,后文会提到),否则在编译时MapStruct会因为不知道用哪个方法而报错。
当然你可以不用想这么多,先编译再说,如果报错了再去处理即可,这也是MapStruct的一个好处:在编译期就可以发现对象转换的错误,而不是到运行时。
再说说这个expression
,其实就是允许你写一段java代码,格式:java(<EXPRESSION>)
,括号里写代码即可。如果你安装了我刚刚说的IDEA的插件,还能有智能提示。
最终会生成如下代码:
...
userVO.setBirthday( date2Long(person.getBirthday()) );
...
@Mapping注解
上面简单介绍了下最常见的用法,@Mapping还提供了很多其他的参数,常用的如下:
-
target
:上文已介绍过,目标对象的字段名,同一目标属性不得多次映射。 -
source
:上文已介绍过,数据源对象的字段名。此属性不能与constant()或expression()一起使用。 -
expression
:上文已介绍过,表达式必须以 Java 表达式的形式给出,格式如下: java()。 表达式中引用的任何类型都必须通过它们的完全限定名称给出。或者,可以通过Mapper.imports()导入类型。
🌟此属性不能与source() 、 defaultValue() 、 defaultExpression() 、 qualifiedBy() 、 qualifiedByName()或constant()一起使用。
-
ignore
:如果设置为true,则指定的字段不会做转换,默认false。 -
dateFormat
:如果属性从String映射到Date或反之亦然,则可以由SimpleDateFormat处理的格式字符串。对于所有其他属性类型和映射枚举常量时,将被忽略。例:
dateFormat = "yyyy-MM-dd HH:mm:ss"
-
numberFormat
:如果带注释的方法从Number映射到String ,则可以由DecimalFormat处理的格式字符串,反之亦然。对于所有其他元素类型将被忽略。例:
numberFormat = "$#.00"
-
defaultValue
:默认值。如果source
属性为null ,则会使用此处的默认值。如果target字段不是String类型,会尝试找到可以匹配的转换方法,否则会报错。🌟此属性不能与constant() 、 expression()或defaultExpression()一起使用。
-
constant
:不管原属性值,直接将目标属性设置为指定的常量。如果target字段不是String类型,会尝试找到可以匹配的转换方法,否则会报错。🌟此属性不能与source() 、 defaultValue() 、 defaultExpression()或expression()一起使用。
-
qualifiedBy
:使用自定义方法对target字段赋值 -
qualifiedByName
:
其他参数就不一一说明了,掌握这些已经可以应付绝大多数情况,实在不行,就写个转换方法,然后用expression就行了。
@Mapping注解复用
如果我们对一个业务对象定义了多个JavaBean,比如UserDO,UserVO,UserRequest等,里面都需要将userName映射成name,或者gender映射成sex等,那在多个convert方法上都去写重复的@Mapping就很麻烦了,后面调整修改也要修改多处。所以可以通过自定义新的注解的方式来复用@Mapping。
@Mapping注解除了可以加在方法上,还可以加在注解上,我们可以自定义一个注解,如:
@Mapping(target = "sex", source = "gender")
@Mapping(target = "name", source = "userName")
public @interface UserMapping {
}
此时,在convert方法上加@UserMapping就等于加那两个Mapping注解
//原写法
@Mapping(target = "sex", source = "gender")
@Mapping(target = "name", source = "userName")
UserVO convert(UserDO person);
/**********************分割线**********************/
//自定义新注解以后的写法
@UserMapping
UserVO convert(UserDO person);
关闭隐式映射
上文提到了MapStruct会进行隐式映射,如果你不希望这样,可以在方法上加上@BeanMapping(ignoreByDefault = true)
来关闭隐式映射,此时,只有定义了@Mapping的字段才会做转换
关闭隐式调用
隐式调用提供了很大的方便,但有时候这种智能的行为也会带来一些麻烦。
假设有这么一个场景,对某个特定的字段值需要转大写,比如身份证号(末位可能是X),如果在Mapper接口中定义一个转大写的默认方法,则所有target和source为String类型的字段都会去调用这个方法来转换:
//接口定义
@Mapper
public interface UserConverter {
UserVO convert(UserDO person);
default String upperCase(String s) {
return s != null ? s.toUpperCase() : null;
}
}
//生成的实现类
public class UserConverterImpl implements UserConverter {
@Override
public UserVO convert(UserDO person) {
if ( person == null ) {
return null;
}
UserVO userVO = new UserVO();
userVO.setUserName( upperCase( person.getName() ) );
userVO.setBirthday( date2Long( person.getBirthday() ) );
userVO.setAge( person.getAge() );
userVO.setIdCard( upperCase( person.getIdCard() ) );
userVO.setGender( upperCase( person.getGender() ) );
return userVO;
}
}
可以看到,除了idCard字段外,name、gender字段也都使用了upperCase方法,这不是我们想要的。
有两种方法解决这个问题:
自定义注解
这是官方提供的一种方法,原文可以参考:https://mapstruct.org/faq/#How-to-avoid-MapStruct-selecting-a-method
方法就是自己定义一个注解,名称可以随意,只要用@org.mapstruct.Qualifier
标记即可
@Qualifier // 需要使用org.mapstruct.Qualifier
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS) // RetentionPolicy必须为CLASS
public @interface DoIgnore {
}
然后把这个注解加在定义的默认方法上,MapStruct就不会主动去调用了,需要你声明才行
@Mapper
public interface UserConverter {
@Mapping(target = "idCard", expression = "java(upperCase(person.getIdCard()))")
UserVO convert(UserDO person);
@DoIgnore
default String upperCase(String s) {
return s != null ? s.toUpperCase() : null;
}
}
使用@org.mapstruct.Named注解
自定义一个额外的注解比较麻烦,所以可以使用MapStruct提供的注解,看下这个注解的定义,发现它上面也标记了@Qualifier,所以标记了这个注解的方法也不会被隐式调用
@Target( { ElementType.TYPE, ElementType.METHOD } )
@Retention( RetentionPolicy.CLASS )
@Qualifier
public @interface Named {
/**
* A name qualifying the annotated element
*
* @return the name.
*/
String value();
}
这个注解的功能就是给方法指定一个别名,然后和@Mapping#qualifiedByName结合使用,上面的例子就会变成这样
@Mapper
public interface UserConverter {
@Mapping(target = "idCard", qualifiedByName = "upper")
UserVO convert(UserDO person);
@Named("upper")
default String upperCase(String s) {
return s != null ? s.toUpperCase() : null;
}
}
使用@Named后,调用方法可以用qualifiedByName
取代之前的expression
,又稍微简洁了一些~
是否关闭隐式映射和隐式调用是依据具体场景来的,看情况结合使用即可。好在最不济MapStruct也可以查看生成的方法,以此来规避一些预期之外的错误。
嵌套属性的映射
一些复杂的对象里面可能某个属性也是一个对象,如:
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class UserDO {
private Integer id;
private String name;
private Integer age;
private Date birthday;
private String gender;
private UserInfo userInfo;
}
@Data
public static class UserInfo {
private String idCard;
private String avatar;
}
@Data
public static class UserVO {
private String userName;
private Integer age;
private String idCard;
private Long birthday;
private String gender;
private String avatar;
}
可以使用.
引用内部的字段,有两种写法,两者是等价的:
//第一种写法:逐一指定各个资源的映射关系
//优点:方便精细控制需要转换的字段
@Mapping(source = "userInfo.idCard", target = "idCard")
@Mapping(source = "userInfo.avatar", target = "avatar")
UserVO convert(UserDO person);
//第二种写法:利用隐式转换对所有同名字段做转换
//优点:书写简单
@Mapping(source = "userInfo", target = ".")
UserVO convert(UserDO person);
生成的方法
public class MapStructDemo$UserConverterImpl implements UserConverter {
@Override
public UserVO convert(UserDO person) {
if ( person == null ) {
return null;
}
UserVO userVO = new UserVO();
userVO.setUserName( person.getName() );
userVO.setBirthday( date2Long( person.getBirthday() ) );
userVO.setIdCard( personUserInfoIdCard( person ) );
userVO.setAvatar( personUserInfoAvatar( person ) );
userVO.setAge( person.getAge() );
userVO.setGender( person.getGender() );
return userVO;
}
//自动生成的方法
private String personUserInfoIdCard(UserDO userDO) {
if ( userDO == null ) {
return null;
}
UserInfo userInfo = userDO.getUserInfo();
if ( userInfo == null ) {
return null;
}
String idCard = userInfo.getIdCard();
if ( idCard == null ) {
return null;
}
return idCard;
}
//自动生成的方法
private String personUserInfoAvatar(UserDO userDO) {
if ( userDO == null ) {
return null;
}
UserInfo userInfo = userDO.getUserInfo();
if ( userInfo == null ) {
return null;
}
String avatar = userInfo.getAvatar();
if ( avatar == null ) {
return null;
}
return avatar;
}
}
多参数映射
多个入参也支持转换,并且同样会使用隐式映射对名字字段做转换,对于不同名字的则需要手动指定
//额外的字段映射,格式为:{参数名}.{字段名},如
// @Mapping(source = "person.xx", target = "xx")
// @Mapping(source = "userInfo.xxxx", target = "xxxx")
@Mapping(target = "birthday", qualifiedByName = "date2Long")
UserVO convert(UserDO person, UserInfo userInfo);
//单个字段也可以直接映射
@Mapping(source = "num", target = "age")
UserVO convert(UserDO person, Integer num);
🌟 一些注意事项:
-
具有多个源参数的映射方法将在所有源参数为空的情况下才返回 null
。否则,目标实体将被实例化,同时将所有来自所提供参数的属性值映射到目标实体中,如果与预期不符,则只能自行实现方法。
//UserVO convert(UserDO person, UserInfo userInfo)生成的方法如下
@Override
public UserVO convert(UserDO person, UserInfo userInfo) {
if ( person == null && userInfo == null ) {
return null;
}
UserVO userVO = new UserVO();
if ( person != null ) {
userVO.setBirthday( date2Long( person.getBirthday() ) );
userVO.setAge( person.getAge() );
userVO.setGender( person.getGender() );
}
if ( userInfo != null ) {
userVO.setIdCard( userInfo.getIdCard() );
userVO.setAvatar( userInfo.getAvatar() );
}
return userVO;
}
-
如果多个源参数中指定了同名的字段,必须使用@Mapping指定,否则会报错
@Mapper注解
@Mapper为自定义的Mapper接口提供了一系列的配置项,常用的如下:
-
componentModel
:指定生成的映射器应遵循的组件模型,总共支持4种模式-
default
: 这是默认的情况,MapStruct不使用任何组件类型,可以通过Mappers.getMapper(Class)方式获取自动生成的实例对象。 -
spring
: 生成的实现类上面会自动添加一个@Component注解,可以通过Spring的 @Autowired方式进行注入。下面会详细说明。 -
cdi
: 生成的映射器是应用程序范围的 CDI bean,可以通过@Inject检索。 -
jsr330
: 生成的实现类上会添加@javax.inject.Named 和@Singleton注解,可以通过 @Inject注解获取。
-
-
uses
:引用其他Mapper接口,可以指定多个。 -
unmappedSourcePolicy
和unmappedTargetPolicy
:对未映射的字段的处理策略,默认为警告,可以改为忽略或者报错。 -
imports
:为实现类引入外部方法,如果@Mapping的expression中使用了外部的方法,可以用这个引入,避免在java()中写完整路径。 -
config
:可以引用标记了@MapperConfig的Class作为配置的模板。@Mapper中设置的配置将优先于@MapperConfig中的设置。可以指定多个。后面会介绍这个参数的使用。
其他的一些并不常用(主要是没用过...),此处不一一介绍了。
与Spring结合
大部分项目都会使用spring框架,MapStruct也对spring提供了支持。
比如上面定义的UserConverter中需要使用Mappers.getMapper来获取实现类,这个方式略显麻烦,因为每个Mapper接口都要写上这么一句,所以可以改成如下:
@Mapper(componentModel = "spring")
public interface UserConverter {
......
}
指定componentModel = spring以后,生成的实现类会变成这样:
@Component
public class UserConverterImpl implements UserConverter {
......
}
其他都没有变,只是多了一个@Component注解,有了这个注解以后,我们就知道可以使用自动装配了:
//默认方式
UserConverter.INSTANCE.convert();
/**********************分割线**********************/
//spring的方式
@Autowired
private UserConverter userConverter;
...
userConverter.convert();
...
配置继承
假如你希望给每个Mapper接口都配置一堆参数的话,还是挺麻烦的,所以,MapStruct提供了@MapperConfig
,可以由其他Mapper引用配置,达到复用的目的:
//定义一个配置项
@MapperConfig(componentModel = "spring",
unmappedSourcePolicy = ReportingPolicy.IGNORE,
unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface MapStructConfig {
}
//Mapper引用
@Mapper(config = MapStructConfig.class)
public interface UserConverter {
}
这样就不用重复写配置了,@MapperConfig和@Mapper拥有同样的字段属性,如果@Mapper使用了配置项并且没有额外定义的话,就将使用@MapperConfig中的配置,如果定义同样的字段,则以@Mapper中的为准。
上面提到的uses
属性可以使用其他Mapper中的转换方法,达到复用的目的。
不过需要注意的是,如果@Mapper中componentModel设置为了spring,则uses的Mapper也需要使用这种方法,否则会报错。
拓展用法
以上为基础用法,泛用性最好,自由度最高,比较推荐。
除此之外,再介绍两种方式,但是有一定的局限性。
MapStruct Spring Extensions
这是官方推出的一个Spring插件,支持通过继承org.springframework.core.convert.converter.Converter
来实现统一对象转换方法的调用。这篇文章讲得比较详细了,此处不再赘述:https://mp.weixin.qq.com/s/XrmchQUiKcQ9ooteq-6ZlQ
但是这种方式依然还是需要定义很多的Mapper接口,而且一个Mapper接口中只能有两个类型对象的转换,即Converter
中指定的<S, T>泛型。
原本我们可以在一个Mapper接口中定义同一个业务对象的不同JavaBean的转换方法,比如DO,BO,VO等等,现在我们可能需要定义更多的接口了,感觉也没有方便太多。
MapStruct Spring Plus
这是一个第三方插件,在MapStruct Spring Extensions的基础上扩展而来,具体使用可以参考Github上的README:https://github.com/ZhaoRd/mapstruct-spring-plus,或者这篇文章:https://www.jianshu.com/p/48484d2398f1
这个框架解决了需要定义一堆Mapper接口的麻烦,只需要在对象上加注解即可,如:@AutoMap(targetType = UserVO.class)
非常优雅了,不过对于字段转换比较复杂,需要自定义方法的情况来说,就有点麻烦了,你还是需要单独定义一个Mapper接口,然后在@AutoMap
使用uses
来引用,这就不怎么方便了。
而且这还会导致转换方法比较分散(原本都定义在了Mapper接口中),管理上会麻烦些。另外,这个第三方的插件,后期是否能继续维护,是否存在什么Bug,也是未知的,在成熟的商业项目上引入第三方依赖是个需要慎重考虑的问题。
结语
感谢你看到这里,到这基本上也介绍完了MapStruct,以及常用的方法。MapStruct提供了一种编写高效、性能优异的对象拷贝方式,虽然一般来说系统的性能瓶颈也不会在这,但技多不压身,多了解一个工具,总是有些好处滴!
MapStruct还有很多其他功能,受限于篇幅这里就不一一介绍了,而且使用频率也不高,等有需要的时候再去查资料也不迟。
最后,如果本文有任何错误和不足还请不吝指正!
参考
-
官方文档:https://mapstruct.org/documentation/stable/reference/html/ -
官方文档:https://mapstruct.org/documentation/spring-extensions/reference/html/ -
微信文章:https://mp.weixin.qq.com/s/ayG1S7CE8ToQjDRQAV3o7w -
微信文章:https://mp.weixin.qq.com/s/XrmchQUiKcQ9ooteq-6ZlQ -
简书:https://www.jianshu.com/p/48484d2398f1 -
MapStruct 使用手册:https://mofan212.gitee.io/posts/MapStruct-User-Manual/ -
https://github.com/ZhaoRd/mapstruct-spring-plus -
https://github.com/mapstruct/mapstruct-examples
作者介绍

Rorschach
这个人很懒,什么都没留下~