【Java生态圈技术总结】之深度剖析MapStruct对象拷贝工具

一、常用的对象拷贝工具基本介绍

属性拷贝工具有很多,也许你用过如下的一些:

  • Apache commons-beanutils
  • Spring BeanUtils
  • cglib BeanCopier
  • HuTool BeanUtils
  • MapStruct
  • getter & setter

这些属性拷贝工具各自有什么特点和区别?在日常开发使用中,我们该如何做出选择?

1.1 Apache BeanUtils

  • 参数顺序和其它的工具正好相反,导致使用不顺手,容易产生问题;
  • 阿里巴巴代码扫描插件会给出明确的告警;
  • 基于反射实现,性能较差;
  • 不推荐使用;

1.2 Spring BeanUtils

  • 基于内省+反射,借助getter/setter方法实现属性拷贝,性能比apache高;
  • 在简单的属性拷贝场景下推荐使用;

1.3 cglib BeanCopier

  • 通过动态代理的方式来实现属性拷贝;
  • 性能高效;
  • 在简单的属性拷贝场景下推荐使用;

1.4 HuTool BeanUtils

  • 性能介于apache和Spring之间;
  • 需要额外引入HuTool的依赖;

1.5 MapStruct

  • 基于getter/setter方法实现属性拷贝,在编译时自动生成实现类的代码;
  • 性能媲美getter & setter;
  • 强大的功能可以实现深度拷贝;
  • 缺点是需要声明bean的转换接口类;

1.6 getter & setter

  • 性能最高,但是需要手动拷贝;

1.7 总结

经过第三方的对比结果,总的下来,推荐使用顺序为:

apache < HuTool < Spring < cglib < Mapstruct

二、使用介绍

2.1 准备工作

<!-- 导入MapStruct的核心注释 -->
<dependencies>
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>${org.mapstruct.version}</version>
    </dependency>
</dependencies>
...
<!-- MapStruct在编译时工作,并且会集成到像Maven和Gradle这样的构建工具上,我们还必须在<build中/>标签中添加一个插件maven-compiler-plugin,并在其配置中添加annotationProcessorPaths,该插件会在构建时生成对应的代码。 -->
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.5.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>
                </annotationProcessorPaths>
            </configuration>
        </plugin>
    </plugins>
</build>

2.2 映射

2.2.1 基本映射

我们现在需要实现一个场景,Car是一个domain层的对象实例,在从数据库读取出来后传递给service层需要转换为CarDTO,这两个实例的所有属性全部相同,现在需要使用mapstruct来完成这个目标。

public class Car {
    private String brand;
    private Double price;
    private Boolean onMarket;
    ...
    // setters + getters + toString
}
public class CarDTO {
    private String brand;
    private Double price;
    private Boolean onMarket;
    ...
    // setters + getters + toString
}

我们需要新建一个Mapper接口,来映射这两个对象之间的属性。

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

    CarDTO carToCarDTO(Car car);
}

然后就可以进行测试了:

@Test
public void test1(){
    Car wuling = new Car();
    wuling.setBrand("wuling");
    wuling.setPrice(6666.66);
    wuling.setOnMarket(true);

    CarDTO wulingDTO = CarMapper.INSTANCE.carToCarDTO(wuling);
    // 结果为:Car{brand='wuling',price=6666.66,onMarket=true}
    System.out.println("结果为:" + wulingDTO);
}

可以看到,mapstruct很好地完成了我们的目标,那么它是如何做到的呢?我们查看CarMapper.INSTANCE.carToCarDTO(wuling)的实现类,可以看到在编译过程中自动生成了如下内容的接口实现类:

public class CarMapperImpl implements CarMapper {

    @Override
    public CarDTO carToCarDTO(Car car) {
        if ( car == null ) {
            return null;
        }

        CarDTO carDTO = new CarDTO();

        carDTO.setBrand( car.getBrand() );
        carDTO.setPrice( car.getPrice() );
        carDTO.setOnMarket( car.getOnMarket() );

        return carDTO;
    }
}

所以,mapstruct并没有使用反射的机制,而是使用了普通的set和get方法来进行属性拷贝的,因此要求我们的对象也一定要有set和get方法。

2.2.2 不同属性名映射

在如上示例中,我们源对象和目标对象的属性名称全都一致,但是在很多的场景下,源对象和目标对象的同一个字段很可能名称是不同的,这种情况下,只需要在映射接口类中指定即可:

public class Car {
    ...
    private Boolean onMarket;
    ...
    // setters + getters + toString
}
public class CarDTO {
    ...
    private Boolean onSale;
    ...
    // setters + getters + toString
}
@Mapper
public interface CarMapper {
    CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);

    @Mapping(source = "car.onMarket", target = "onSale")
    CarDTO carToCarDTO(Car car);
}

如此,生成的接口实现类如下:

@Override
public CarDTO carToCarDTO(Car car) {
    if ( car == null ) {
        return null;
    }

    CarDTO carDTO = new CarDTO();

    carDTO.setOnSale( car.getOnMarket() );
    carDTO.setBrand( car.getBrand() );
    carDTO.setPrice( car.getPrice() );

    return carDTO;
}

2.2.3 不同个数属性映射

我们假设Car和CarDTO各有一个对方没有的属性,那么在进行对象拷贝时会发生什么?

public class Car {
    ...
    private Date birthdate;
    // setters + getters + toString
}
public class CarDTO {
    ...
    private String owner;
    // setters + getters + toString
}
@Test
public void test1(){
    Car wuling = new Car();
    wuling.setBrand("wuling");
    wuling.setPrice(6666.66);
    wuling.setOnMarket(true);
    wuling.setBirthdate(new Date());

    CarDTO wulingDTO = CarMapper.INSTANCE.carToCarDTO(wuling);
    System.out.println("结果为:" + wulingDTO);
}

然后我们执行如上转换的案例,发现并没有报错,从Car拷贝属性到CarDTO时,CarDTO由于没有birthdate属性,则不会赋值;同时,CarDTO的owner因为Car中没有,因此也不会被赋值,生成的接口实现类如下:

@Override
public CarDTO carToCarDTO(Car car) {
    if ( car == null ) {
        return null;
    }

    CarDTO carDTO = new CarDTO();

    carDTO.setOnSale( car.getOnMarket() );
    carDTO.setBrand( car.getBrand() );
    carDTO.setPrice( car.getPrice() );

    return carDTO;
}

因此,mapstruct只会对共有的交集属性进行拷贝操作。

2.2.4 多个源合并映射

我们新增一个Person类,其中的name属性对应CarDTO中的owner属性。

public class Person {
    private String name;
    private String age;
    // setters + getters + toString
}
@Mapper
public interface CarMapper {
    CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);

    @Mapping(source = "car.onMarket", target = "onSale")
    @Mapping(source = "person.name", target = "owner")
    CarDTO carToCarDTO(Car car, Person person);
}
public class TestMapper1 {

    @Test
    public void test1(){
        Car wuling = new Car();
        wuling.setBrand("wuling");
        wuling.setPrice(6666.66);
        wuling.setOnMarket(true);
        wuling.setBirthdate(new Date());

        Person jack = new Person();
        jack.setName("jack");
        jack.setAge("22");

        CarDTO wulingDTO = CarMapper.INSTANCE.carToCarDTO(wuling, jack);
        // 结果为:CarDTO{brand='wuling',onSale=true,owner='jack'}
        System.out.println("结果为:" + wulingDTO);
    }

自动生成的接口实现类如下:

@Override
public CarDTO carToCarDTO(Car car, Person person) {
    if ( car == null && person == null ) {
        return null;
    }

    CarDTO carDTO = new CarDTO();

    if ( car != null ) {
        carDTO.setOnSale( car.getOnMarket() );
        carDTO.setBrand( car.getBrand() );
        carDTO.setPrice( car.getPrice() );
    }
    if ( person != null ) {
        carDTO.setOwner( person.getName() );
    }

    return carDTO;
}

2.2.5 子对象映射

如果需要转换的Car对象中的某个属性不是基本数据类型,而是一个对象怎么处理呢。

public class Person {

    private String name;

    private String age;
    
    // setters + getters + toString
}

public class PersonDTO {

    private String name;

    private String age;
    
    // setters + getters + toString
}
public class Car {
    private String brand;
    private Double price;
    private Boolean onMarket;
    private Person owner;
    
    // setters + getters + toString
}

public class CarDTO {
    private String brand;
    private Double price;
    private Boolean onMarket;
    private PersonDTO owner;
    
    // setters + getters + toString
}
@Mapper
public interface PersonMapper {
    PersonMapper INSTANCE = Mappers.getMapper(PersonMapper.class);

    PersonDTO personToPersonDTO(Person person);
}
@Mapper(uses = {PersonMapper.class})
public interface CarMapper {
    CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);

    @Mapping(source = "onMarket", target = "onSale")
    CarDTO carToCarDTO(Car car);
}
@Test
public void test1(){
    Person jack = new Person();
    jack.setName("jack");
    jack.setAge("22");

    Car wuling = new Car();
    wuling.setBrand("wuling");
    wuling.setPrice(6666.66);
    wuling.setOnMarket(true);
    wuling.setOwner(jack);

    CarDTO wulingDTO = CarMapper.INSTANCE.carToCarDTO(wuling);
    // 结果为:CarDTO{brand='wuling',owner=Person{name='jack',age='22'}}
    System.out.println("结果为:" + wulingDTO);
}

这里最重要的是:

  • 需要增加PersonMapper接口,让mapstruct能够对Person和PersonDTO进行转换;
  • CarMapper中需要引入PersonMapper,如果存在多个对象属性,此处就要引入多个对象属性的Mapper接口;

2.2.6 集合属性映射

如果需要转换的Car对象中的某个属性不是基本数据类型,而是一个集合类型该怎么处理?

public class Car {
    private String brand;
    private Double price;
    private Boolean onMarket;
    private List<Person> ownerList;
    
    // setters + getters + toString
}

同2.2.5内容。

2.2.7 枚举映射

枚举映射的工作方式与字段映射相同。MapStruct会对具有相同名称的枚举进行映射。

public enum PayType {
    CASH,
    ALIPAY,
    WEPAY,
    DIGITAL_CASH,
    CARD_VISA,
    CARD_CREDIT;
}

public enum PayTypeNew {
    CASH,
    CARD_CREDIT;
}
@Mapper
public interface PayTypeMapper {
    PayTypeMapper INSTANCE = Mappers.getMapper(PayTypeMapper.class);

    PayTypeNew payTypeToPayTypeNew(PayType payType);
}
@Test
public void test2(){
    PayType p1 = PayType.ALIPAY;

    PayTypeNew p2 = PayTypeMapper.INSTANCE.payTypeToPayTypeNew(p1);
    // 结果为:ALIPAY
    System.out.println("结果为:" + p2);
}

但是在更多的场景下,源枚举和目标枚举并不是一一对应的,比如目标枚举如下:

public enum PayTypeNew {
    CASH,
    NETWORK,
    CARD;
}

此时,我们就需要手动指定源枚举和目标枚举之间的对应关系:

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

    @ValueMappings({
            @ValueMapping(source = "ALIPAY", target = "NETWORK"),
            @ValueMapping(source = "WEPAY",
            @ValueMapping(source = "DIGITAL_CASH", target = "CASH"),
            @ValueMapping(source = "CARD_VISA", target = "CARD"),
            @ValueMapping(source = "CARD_CREDIT", target = "CARD")
    })
    PayTypeNew payTypeToPayTypeNew(PayType payType);
}

如果对应CARD的场景比较多,手动一个个地对应会比较繁琐,因此还有一种方式能实现相同的效果,而且比较简洁:

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

    @ValueMappings({
            @ValueMapping(source = "ALIPAY",
            @ValueMapping(source = MappingConstants.ANY_REMAINING, target = "CARD")
    })
    PayTypeNew payTypeToPayTypeNew(PayType payType);
}

MappingConstants.ANY_REMAINING表示剩下其它的源枚举和目标枚举对应不上的全部映射为指定的枚举对象。

还有一种方式,使用MappingConstants.ANY_UNMAPPED表示所有未显示指定目标枚举的都会被映射为CARD:

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

    @ValueMappings({
            @ValueMapping(source = "ALIPAY",
            @ValueMapping(source = MappingConstants.ANY_UNMAPPED, target = "CARD")
    })
    PayTypeNew payTypeToPayTypeNew(PayType payType);
}

2.2.8 集合映射

如果源对象和目标对象都是集合,且对象中的属性都是基本数据类型,则映射方法和之前类似,映射接口改为如下即可:

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

    List<CarDTO> carToCarDTOList(List<Car> carList);

    Set<CarDTO> carToCarDTOSet(Set<Car> carSet);

    Map<String, CarDTO> carToCarDTOMap(Map<String, Car> carMap);
}

如果对象中属性不仅是基本数据类型,还有对象类型或对象类型的集合类型的话,mapstruct也是支持映射的,详情参考官网文档,此处不再赘述。

2.3 转换

2.3.1 类型转换

mapstruct提供了基本数据类型和包装数据类型、一些常见场景下的自动转换;

  • 基本类型及其对应的包装类型之间的转换;比如int和Integer、float和Float、long和Long、boolean和Boolean等;
  • 任意基本类型和任意包装类型之间的转换;比如int和long、byte和Integer等;
  • 任意基本类型、包转类型和String之间的转换;比如boolean和String、Integer和String;
  • 枚举和String;
  • 大数类型(BigInteger、BigDecimal)、基本类型、基本类型包装类型、String之间的相互转换;
  • 其它一些场景,参考MapStruct 1.4.2.Final Reference Guide;

2.3.2 格式转换

mapstruct可以对源对象的属性值进行格式化之后拷贝给目标对象的属性;

  • 日期格式转换
public class Car {
    private String brand;
    private Double price;
    private LocalDate marketDate;
    // setters + getters + toString
}

public class CarDTO {
    private String brand;
    private Double price;
    private String saleDate;
    // setters + getters + toString
}
@Mapper
public interface CarMapper {
    CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);

    @Mapping(source = "marketDate", target = "saleDate", dateFormat = "dd/MM/yyyy")
    CarDTO carToCarDTO(Car car);
}
@Test
public void test1(){
    Car wuling = new Car();
    wuling.setBrand("wuling");
    wuling.setPrice(6666.66);
    wuling.setMarketDate(LocalDate.now());

    // 转换前为:Car{brand='wuling',marketDate=2022-01-19}
    System.out.println("转换前为:" + wuling);
    CarDTO wulingDTO = CarMapper.INSTANCE.carToCarDTO(wuling);
    // 结果为:CarDTO{brand='wuling',saleDate='19/01/2022'}
    System.out.println("结果为:" + wulingDTO);
}
  • 数字格式转换
@Mapper
public interface CarMapper {
    CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);

    @Mapping(source = "price", target = "price", numberFormat = "$#.00")
    CarDTO carToCarDTO(Car car);
}

2.4 高级特性

2.4.1 依赖注入

在前面例子中的Mapper映射接口中,我们都需要CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);来创建一个实例,如果我们是Spring工程的话,就可以把这个实例托管给Spring进行管理。

@Mapper(componentModel = "spring")
public interface CarMapper {
    List<CarDTO> carToCarDTOList(List<Car> carList);
}

然后使用的时候,从Spring中自动注入接口对象即可:

@SpringBootTest
public class TestMapper1 {
    @Autowired
    private CarMapper carMapper;

    @Test
    public void test1(){
        Car wuling = new Car();
        wuling.setBrand("wuling");
        wuling.setPrice(6666.66);
        wuling.setMarketDate(LocalDate.now());
        Car changan = new Car();
        changan.setBrand("changan");
        changan.setPrice(7777.77);
        changan.setMarketDate(LocalDate.now());
        List<Car> carList = new ArrayList<>();
        carList.add(wuling);
        carList.add(changan);

        List<CarDTO> carDTOList = carMapper.carToCarDTOList(carList);
        System.out.println("结果为:" + carDTOList);
    }
}

2.4.2 设置默认值

  • 常量默认值

无论源对象的属性字段值是什么,目标对象的该字段都是给定的常量值。

@Mapper(componentModel = "spring")
public interface PersonMapper {
    @Mapping(target = "name", constant = "zhangsan")
    PersonDTO personToPersonDTO(Person person);
}
  • 空值默认值

如果源对象的属性字段值为空,那么就使用指定的默认值。

@Mapper(componentModel = "spring")
public interface PersonMapper {
    @Mapping(source = "name", target = "name", defaultValue = "unknown")
    PersonDTO personToPersonDTO(Person person);
}

2.4.3 使用表达式

mapstruct甚至允许在对象属性映射中使用java表达式:

@Mapper(componentModel = "spring", imports = {UUID.class, LocalDateTime.class})
public interface PersonMapper {

    @Mapping(target = "id", expression = "java(UUID.randomUUID().toString())")
    @Mapping(source = "birthdate", target = "birthdate", defaultExpression = "java(LocalDateTime.now())")
    PersonDTO personToPersonDTO(Person person);
}

或者等价写法为:

@Mapper(componentModel = "spring")
public interface PersonMapper {

    @Mapping(target = "id", expression = "java(java.util.UUID.randomUUID().toString())")
    @Mapping(source = "birthdate", defaultExpression = "java(java.time.LocalDateTime.now())")
    PersonDTO personToPersonDTO(Person person);
}

2.4.4 前置及后置方法

@Mapper(componentModel = "spring")
public abstract class PersonMapper {

    @BeforeMapping
    public void before(Person person){
        System.out.println("前置处理!!!");
        if(ObjectUtils.isEmpty(person.getName())){
            System.out.println("Person的name不能为空!");
            return;
        }
    }

    @Mapping(target = "id", defaultExpression = "java(java.time.LocalDateTime.now())")
    public abstract PersonDTO personToPersonDTO(Person person);

    @AfterMapping
    public void after(@MappingTarget PersonDTO personDTO){
        System.out.println("后置处理:" + personDTO.getName() + "!!!");
    }
}

三、参考文献

MapStruct 1.4.2.Final Reference Guide

MapStruct使用指南 - 知乎 (zhihu.com)

常见Bean拷贝框架使用姿势及性能对比 - 知乎 (zhihu.com)

四、补充填坑

在正文的实例中,我们对于Bean对象都是使用手动写getter、setter、toString方法的,但是在真实开发中,大家都是采用了Lombok插件,如果不做特殊配置,就会出现mapstruct运行时lombok不生效的问题,只需要在pom配置中增加如下内容:

<annotationProcessorPaths>
  <path>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>${lombok.version}</version>
   </path>                    
</annotationProcessorPaths>

具体原理和详情可以参考:当 Lombok 遇见了 MapStruct の「坑」 - 知乎 (zhihu.com)

相关文章

可以认为OpenFeign是Feign的增强版,不同的是OpenFeign支持S...
为进一步规范小程序交易生态、提升用户购物体验、满足用户在...
云原生之使用Docker部署Dashdot服务器仪表盘
本文主要描述TensorFlow之回归模型的基本原理
1.漏洞描述Apache Druid 是一个集时间序列数据库、数据仓库和...
内部类(当作类中的一个普通成员变量,只不过此成员变量是cl...