使用MapStruct进行类对象拷贝

基本用法

假设我们有两个类需要进行互相转换,分别是PersonDO和PersonDTO,类定义如下:

@Data
public class PersonDO {
    private int id;
    private String name;
    private Integer age;
    private Date birthday;
}

@Data
public class PersonDTO {
    private String name;
    private Integer age;
    private Date birthday;
}

我们演示下如何使用MapStruct进行bean映射。

想要使用MapStruct,首先需要依赖他的相关的jar包,使用maven依赖方式如下:

...
<properties>
    <org.mapstruct.version>1.3.1.Final</org.mapstruct.version>
    <lombok.version>1.18.10</lombok.version>
</properties>
...
<dependencies>
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>${org.mapstruct.version}</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.12</version>
    <scope>provided</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>
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                            <version>1.18.10</version>
                        </path>  
                    <!-- other annotation processors -->
                </annotationProcessorPaths>
            </configuration>
        </plugin>
    </plugins>
</build>

因为MapStruct需要在编译器生成转换代码,所以需要在maven-compiler-plugin插件中配置上对mapstruct-processor的引用。这部分在后文会再次介绍。

之后,我们需要定义一个做映射的接口,主要代码如下:

@Mapper
public interface PersonConverter {

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

    @Mappings({
            @Mapping(source = "username", target = "name"),
            @Mapping(source = "age", target = "myage")
    })
    PersonDTO do2dto(PersonDO person);
}

使用注解 @Mapper定义一个Converter接口,在其中定义一个do2dto方法,方法的入参类型是PersonDO,出参类型是PersonDTO,这个方法就用于将PersonDO转成PersonDTO。

测试代码如下:

    @Test
    public void mapConstructTest(){
        PersonDO personDO = new PersonDO();
        personDO.setId(1);
        personDO.setName("jim");
        personDO.setAge(29);
        personDO.setBirthday(new Date());
        PersonDTO personDTO = PersonConverter.INSTANCE.do2dto(personDO);
        System.out.println(personDTO);

    }

输出结果:

PersonDTO(name=jim, age=29, birthday=Sun Aug 16 15:00:56 CST 2020)

假如存在名称字段不一致的情况需要映射应该怎么处理呢?下面通过一个案例加以说明,例如:

@Data
public class PersonDO {
    private int id;
    private String userName;
    private Integer age;
    private Date birthday;
}

@Data
public class PersonDTO {
    private String name;
    private Integer myage;
    private Date birthday;
}

改写Converter类如下:

@Mapper
public interface PersonConverter {

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

    PersonDTO do2dto(PersonDO person);
}

单元测试:

@Test
    public void mapConstructTest(){
        PersonDO personDO = new PersonDO();
        personDO.setId(1);
        personDO.setUsername("jim");
        personDO.setAge(29);
        personDO.setBirthday(new Date());
        PersonDTO personDTO = PersonConverter.INSTANCE.do2dto(personDO);
        System.out.println(personDTO);

    }

运行结果:

PersonDTO(name=jim, myage=29, birthday=Sun Aug 16 15:05:53 CST 2020)

如果待转换类中存在子类的属性需要赋值给其他类的属性应该怎么做呢?

@Data
public class PersonDO {
    private int id;
    private String username;
    private Integer age;
    private Date birthday;
    private UserInfo userInfo;
}

@Data
public class PersonDTO {
    private String name;
    private Integer myage;
    private Date birthday;
    private String img;
}

编写类型转换类:

@Mapper
public interface PersonConverter {

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

    @Mappings({
            @Mapping(source = "username", target = "name"),
            @Mapping(source = "age", target = "myage"),
            @Mapping(source="person.userInfo.userImg",target = "img")
    })
    PersonDTO do2dto(PersonDO person);
}

编写单元测试类:

@Test
    public void mapConstructTest(){
        PersonDO personDO = new PersonDO();
        personDO.setId(1);
        personDO.setUsername("jim");
        personDO.setAge(29);
        personDO.setBirthday(new Date());

        UserInfo userInfo=new UserInfo();
        userInfo.setId(1);
        userInfo.setUserImg("test.png");
        personDO.setUserInfo(userInfo);

        PersonDTO personDTO = PersonConverter.INSTANCE.do2dto(personDO);
        System.out.println(personDTO);

    }

运行结果:

PersonDTO(name=jim, myage=29, birthday=Sun Aug 16 15:13:15 CST 2020, img=test.png)

加入希望在转换的同时对日期格式进行格式化,PersonDTO中新增了一个formatDate字段用以表示格式化后的日期:

@Data
public class PersonDTO {
    private String name;
    private Integer myage;
    private Date birthday;
    private String img;
    private String formatDate;
}

改写转换类,

@Mapper
public interface PersonConverter {

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

    @Mappings({
            @Mapping(source = "username", target = "name"),
            @Mapping(source = "age", target = "myage"),
            @Mapping(source="person.userInfo.userImg",target = "img"),
            @Mapping(source = "birthday", target = "formatDate", dateFormat = "yyyy-MM-dd HH:mm:ss")
    })
    PersonDTO do2dto(PersonDO person);
}

运行单元测试类,结果如下:

PersonDTO(name=jim, myage=29, birthday=Sun Aug 16 15:17:15 CST 2020, img=test.png, formatDate=2020-08-16 15:17:15)

加入目标类中有一个属性language为固定常量值zh,且被被复制类中没有该属性,例如:

@Data
public class PersonDO {
    private int id;
    private String username;
    private Integer age;
    private Date birthday;
    private UserInfo userInfo;
}

@Data
public class PersonDTO {
    private String name;
    private Integer myage;
    private String language;
    private Date birthday;
    private String img;
    private String formatDate;
}

撰写转换类:

@Mapper
public interface PersonConverter {

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

    @Mappings({
            @Mapping(source = "username", target = "name"),
            @Mapping(source = "age", target = "myage"),
            @Mapping(source="person.userInfo.userImg",target = "img"),
            @Mapping(source = "birthday", target = "formatDate", dateFormat = "yyyy-MM-dd HH:mm:ss"),
            @Mapping(target = "language", constant = "zh")
    })
    PersonDTO do2dto(PersonDO person);
}

运行结果:

PersonDTO(name=jim, myage=29, language=zh, birthday=Sun Aug 16 15:26:07 CST 2020, img=test.png, formatDate=2020-08-16 15:26:07)

如果我们希望在类属性进行转换的过程中进行一些更加自定义的操作,应该如何基于mapconstruct的转换类进行扩展呢?切看下面的一个简单的示例:

新增类HomeAddress:

@Data
public class HomeAddress {
    private String address;
}

PersonDO中新增属性address

@Data
public class PersonDO {
    private int id;
    private String username;
    private Integer age;
    private Date birthday;
    private UserInfo userInfo;
    private HomeAddress address;
}

PersonDTO中新增属性address

@Data
public class PersonDTO {
    private String name;
    private Integer myage;
    private String language;
    private Date birthday;
    private String img;
    private String formatDate;
    private String address;
}

修改PersonConverter类如下:

@Mapper
public interface PersonConverter {

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

    @Mappings({
            @Mapping(source = "username", target = "name"),
            @Mapping(source = "age", target = "myage"),
            @Mapping(source="person.userInfo.userImg",target = "img"),
            @Mapping(source = "birthday", target = "formatDate", dateFormat = "yyyy-MM-dd HH:mm:ss"),
            @Mapping(target = "language", constant = "zh"),
            @Mapping(target="address",expression = "java(homeAddressToString(person.getAddress()))")
    })
    PersonDTO do2dto(PersonDO person);

    default String homeAddressToString(HomeAddress address){
        return JSON.toJSONString(address);
    }
}

单元测试;

@Mapper
public interface PersonConverter {

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

    @Mappings({
            @Mapping(source = "username", target = "name"),
            @Mapping(source = "age", target = "myage"),
            @Mapping(source="person.userInfo.userImg",target = "img"),
            @Mapping(source = "birthday", target = "formatDate", dateFormat = "yyyy-MM-dd HH:mm:ss"),
            @Mapping(target = "language", constant = "zh"),
            @Mapping(target="address",expression = "java(homeAddressToString(person.getAddress()))")
    })
    PersonDTO do2dto(PersonDO person);

    default String homeAddressToString(HomeAddress address){
        return JSON.toJSONString(address);
    }
}

运行结果:

PersonDTO(name=jim, myage=29, language=zh, birthday=Sun Aug 16 15:32:16 CST 2020, img=test.png, formatDate=2020-08-16 15:32:16, address={"address":"test address"})

实现原理

MapStruct和其他几类框架最大的区别就是:与其他映射框架相比,MapStruct在编译时生成bean映射,这确保了高性能,可以提前将问题反馈出来,也使得开发人员可以彻底的错误检查。
还记得前面我们在引入MapStruct的依赖的时候,特别在maven-compiler-plugin中增加了mapstruct-processor的支持吗?
并且我们在代码中使用了很多MapStruct提供的注解,这使得在编译期,MapStruct就可以直接生成bean映射的代码,相当于代替我们写了很多setter和getter。
如我们在代码中定义了以下一个Mapper:

@Mapper
interface PersonConverter {
    PersonConverter INSTANCE = Mappers.getMapper(PersonConverter.class);

    @Mapping(source = "userName", target = "name")
    @Mapping(target = "address",expression = "java(homeAddressToString(dto2do.getAddress()))")
    @Mapping(target = "birthday",dateFormat = "yyyy-MM-dd HH:mm:ss")
    PersonDO dto2do(PersonDTO dto2do);

    default String homeAddressToString(HomeAddress address){
        return JSON.toJSONString(address);
    }
}

经过代码编译后,会自动生成一个PersonConverterImpl:

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2020-08-09T12:58:41+0800",
    comments = "version: 1.3.1.Final, compiler: javac, environment: Java 1.8.0_181 (Oracle Corporation)"
)
class PersonConverterImpl implements PersonConverter {

    @Override
    public PersonDO dto2do(PersonDTO dto2do) {
        if ( dto2do == null ) {
            return null;
        }

        PersonDO personDO = new PersonDO();

        personDO.setName( dto2do.getUserName() );
        if ( dto2do.getAge() != null ) {
            personDO.setAge( dto2do.getAge() );
        }
        if ( dto2do.getGender() != null ) {
            personDO.setGender( dto2do.getGender().name() );
        }

        personDO.setAddress( homeAddressToString(dto2do.getAddress()) );

        return personDO;
    }
}

在运行期,对于bean进行映射的时候,就会直接调用PersonConverterImpl的dto2do方法,这样就没有什么特殊的事情要做了,只是在内存中进行set和get就可以了。

所以,因为在编译期做了很多事情,所以MapStruct在运行期的性能会很好,并且还有一个好处,那就是可以把问题的暴露提前到编译期。

使得如果代码中字段映射有问题,那么应用就会无法编译,强制开发者要解决这个问题才行。

学会查看编译后的源代码还是可以帮助我们解决不少问题的,下面以一个笔者在实际使用过程中遇到和解决问题的经历解释一下如何去查看编译后的源码并且借此解决问题的。

首先,像上面提到的进的经典用法一下,笔者写了一个Converter的转换类。

    @Mappings({
            @Mapping(source = "page", target = "pageNo"),
            @Mapping(source = "limit", target = "pageSize")
    })
    TaskCarbonQueryParam getNotifyMeInCorpQuery2TaskCarbonQueryParam(GetNotifyMeInCorpQuery getNotifyMeInCorpQuery);

但是在编译的时候却产生了如下的报错:


image.png

由于目标类不是笔者定义的,而是兄弟团队的小伙伴提供的一个二方API包中定义的bean,去看一下这个类的类定义如下:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TaskCarbonQueryParam extends Paginator {

    private String tenantId;

    private String carbonUserId;

   ....
}

@Getter
public abstract class Paginator extends DTO {

    private int pageNo = 1;

    private int pageSize  = 20;

    public void setPageNo(int pageNo) {
        if (pageNo <= 0) {
            pageNo = 1;
        }
        this.pageNo = pageNo;
    }

    public void setPageSize(int pageSize) {
        if (pageSize <= 0) {
            pageSize = 20;
        }
        this.pageSize = pageSize;
    }
}

确认类属性名称确实没有写错,不知道因何原因报错,我们选择去看一下编译出来的代码:

image.png

发现正常可以编译成功的是直接new一个对象然后往里面挨个set值,但是下面出现问题类,因为子类使用了lombok的@Builder注解,但是@Builder注解的副作用在于无法将父类的属性加入到builder模式中,导致在builder的时候无法取用到父类的属性造成了失败。解决方案是将子类的@builder注解去除掉。

性能对比

Echart折线图中撰写echarts脚本:

option = {
    title: {
        text: '拷贝工具类性能对比'
    },
    tooltip: {
        trigger: 'axis'
    },
    legend: {
        data: ['MapStruct', 'Spring BeanUtils', 'Cglib BeanCopier', 'Apache PropertyUtils', 'Apache BeanUtils', 'Dozer']
    },
    grid: {
        left: '3%',
        right: '4%',
        bottom: '3%',
        containLabel: true
    },
    toolbox: {
        feature: {
            saveAsImage: {}
        }
    },
    xAxis: {
        type: 'category',
        boundaryGap: false,
        data: ['1000', '10000', '100000', '1000000']
    },
    yAxis: {
        type: 'value'
    },
    series: [
        {
            name: 'MapStruct',
            type: 'line',
            stack: '总量',
            data: [0, 1, 3, 6]
        },
        {
            name: 'Spring BeanUtils',
            type: 'line',
            stack: '总量',
            data: [5,10,45,169]
        },
        {
            name: 'Cglib BeanCopier',
            type: 'line',
            stack: '总量',
            data: [4,18,45,91]
        },
        {
            name: 'Apache PropertyUtils',
            type: 'line',
            stack: '总量',
            data: [60,265,1444,11492]
        },
        {
            name: 'Apache BeanUtils',
            type: 'line',
            stack: '总量',
            data: [138,816,4154,36938]
        },
        {
            name: 'Dozer',
            type: 'line',
            stack: '总量',
            data: [566, 2254, 11136, 102965]
        }
    
    ]
};
image.png

可以看到,MapStruct的耗时相比较于其他几款工具来说是非常短的。

参考资料

  1. # 为什么阿里巴巴禁止使用Apache Beanutils进行属性的copy?
  2. 丢弃掉那些BeanUtils工具类吧,MapStruct真香!!!