Rorschach

V1

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"张三"25new 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接口,可以指定多个。
  • unmappedSourcePolicyunmappedTargetPolicy:对未映射的字段的处理策略,默认为警告,可以改为忽略或者报错。
  • 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还有很多其他功能,受限于篇幅这里就不一一介绍了,而且使用频率也不高,等有需要的时候再去查资料也不迟。

最后,如果本文有任何错误和不足还请不吝指正!

参考


  1. 官方文档:https://mapstruct.org/documentation/stable/reference/html/
  2. 官方文档:https://mapstruct.org/documentation/spring-extensions/reference/html/
  3. 微信文章:https://mp.weixin.qq.com/s/ayG1S7CE8ToQjDRQAV3o7w
  4. 微信文章:https://mp.weixin.qq.com/s/XrmchQUiKcQ9ooteq-6ZlQ
  5. 简书:https://www.jianshu.com/p/48484d2398f1
  6. MapStruct 使用手册:https://mofan212.gitee.io/posts/MapStruct-User-Manual/
  7. https://github.com/ZhaoRd/mapstruct-spring-plus
  8. https://github.com/mapstruct/mapstruct-examples

分类:

后端

标签:

Java

作者介绍

Rorschach
V1

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