几种Java对象拷贝工具

日常工作中时常需要做对象的转化和拷贝,例如将DTO转化为PO。如果采用简单粗暴的做法,直接使用getset方法直接对两个对象做转化,这样做可以最大程度上自定义转化的字段,同时不存在浅拷贝的问题。这样做的问题是十分繁琐,需要自己手写各个字段的转化,这个时候就需要使用工具来简化开发,常用的工具大概由以下几种:

  • Apache BeanUtils
  • Spring BeanUtils
  • cglib BeanCopier
  • Hutool BeanUtil
  • Mapstruct
  • Dozer

Apache BeanUtils

使用时需要引入以下依赖:

1
2
3
4
5
<dependency>
<groupId>commons-beanutils</groupId>
<artifactId>commons-beanutils</artifactId>
<version>1.9.3</version>
</dependency>

准备好需要进行转化的两个对象,要注意的是,id字段特地准备了基础类型和包装类型之间的转化:

1
2
3
4
5
6
@Data
public class PersonPo {
private Integer id;
private String name;
private List<String> phoneNumberList;
}
1
2
3
4
5
6
@Data
public class PersonDto {
private int id;
private String name;
private List<String> phoneNumberList;
}

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void main(String[] args) throws InvocationTargetException, IllegalAccessException {
PersonPo personPo = new PersonPo();
personPo.setId(1);
personPo.setName("张三");
List<String> phoneNumberList = new ArrayList<String>() {{
add("123456789");
add("987654321");
}};
personPo.setPhoneNumberList(phoneNumberList);

PersonDto personDto = new PersonDto();
// 右边是源数据,左边是目标数据
BeanUtils.copyProperties(personDto, personPo);

System.out.println(personPo);
System.out.println(personDto);
}

打印结果如下:

1
2
PersonPo(id=1, name=张三, phoneNumberList=[123456789, 987654321])
PersonDto(id=1, name=张三, phoneNumberList=[123456789, 987654321])

从结果可以看出来,使用该工具可以正常进行对象转化,即使是基础类型和包装类型之间也可以正常转化

那么对于这种拷贝工具来说,拷贝的结果是深拷贝还是浅拷贝呢,可以通过debug来看下两个对象:

从debug结果来看,phoneNumberList字段使用的是同一个对象,说明这种拷贝是浅拷贝,所以在拷贝完成后,修改了源对象的phoneNumberList字段,也会导致目标对象的该字段发生变动,这是使用的时候需要注意的一点:

1
2
3
phoneNumberList.add("135792468");
System.out.println(personPo);
System.out.println(personDto);
1
2
PersonPo(id=1, name=张三, phoneNumberList=[123456789, 987654321, 135792468])
PersonDto(id=1, name=张三, phoneNumberList=[123456789, 987654321, 135792468])

另外需要注意的是,BeanUtils.copyProperties方法,第一个参数是拷贝的目标对象,第二个参数是拷贝的源对象

Spring BeanUtils

如果当前项目是spring则不需要单独引入依赖,如果不是的话需要引入下面的依赖:

1
2
3
4
5
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>5.2.2.RELEASE</version>
</dependency>

调用BeanUtils.copyProperties方法可以实现对象的拷贝,要名称和Apache BeanUtils是一致的,使用的时候需要区分包名

使用过程和上面一样,此处不再赘述,要注意的是,方法的第一个参数是拷贝的源对象,第二个参数是拷贝的目标对象,和Apache BeanUtils是相反的

另外两种方法的区别在于,Spring BeanUtils还提供了另一个方法可以传入拷贝时忽略的字段参数列表,以可变参数的形式传入:

1
2
3
public static void copyProperties(Object source, Object target, String... ignoreProperties) throws BeansException {
copyProperties(source, target, null, ignoreProperties);
}

例如,在上例中拷贝忽略phoneNumberList字段:

1
BeanUtils.copyProperties(personPo, personDto, "phoneNumberList");
1
2
PersonPo(id=1, name=张三, phoneNumberList=[123456789, 987654321])
PersonDto(id=1, name=张三, phoneNumberList=null)

此外,在拷贝时,除了基本类型和包装类型可以正常转换,其他的情况下需要对象的对应字段类型相同,并且字段名相同且区分大小写

两种同名工具的选择

Apache BeanUtilsSpring BeanUtils使用方法类似,实现的功能也类似,不过在实际应用的过程中,更加推荐使用后者,在阿里巴巴Java开发规约手册中也提出了这一点

原因主要是,Apache BeanUtils在拷贝对象时,加入了很多的校验,校验了转化的类型,以及可访问性等,造成了拷贝时性能变差。相比较下,Spring BeanUtils主要是对两个对象中同名属性进行get/set,仅仅检查了可访问性,性能较优

cglib BeanCopier

同样,如果是spring项目,则无需单独引入,如果是普通项目,需要额外引入依赖:

1
2
3
4
5
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib</artifactId>
<version>3.3.0</version>
</dependency>

使用该工具进行对象拷贝的时候,案例如下:

1
2
BeanCopier beanCopier = BeanCopier.create(personPo.getClass(), personDto.getClass(), false);
beanCopier.copy(personPo, personDto, null);

拷贝结果如下:

1
2
PersonPo(id=1, name=张三, phoneNumberList=[123456789, 987654321])
PersonDto(id=0, name=张三, phoneNumberList=[123456789, 987654321])

由结果可以看出来,这种工具对于基本类型和包装类型是无法正常拷贝的,需要改成相同的类型

深拷贝还是浅拷贝的问题,可以看如下:

1
phoneNumberList.add("135792468");
1
2
PersonPo(id=1, name=张三, phoneNumberList=[123456789, 987654321, 135792468])
PersonDto(id=0, name=张三, phoneNumberList=[123456789, 987654321, 135792468])

从结果可以看出来该工具也是浅拷贝

深入分析创建拷贝器

在创建拷贝对象,编写BeanCopier.create时,可以看到除了传入源对象和目标对象外,还传入的第三个参数,是一个布尔值:boolean useConverter

该参数表示是否使用自定义的转换器,如果传入false,那么在拷贝的时候会严格按照源对象和目标对象的类型进行拷贝,如果类型不一致,则字段不会拷贝成功。如果传入true,那么在编写beanCopier.copy时,第三个参数不能传入null,需要传入自定义的转换器,实现不同类型的同名字段拷贝

自定义转换器

将待转换案例两个对象中的PersonDto类的id字段类型改为String

自定义一个转换器,实现遇到Integer类型时,将其转为String类型,从而实现id字段的正常转换

首先,没有设置自定义转换器之前,执行转换的案例如下:

1
2
3
4
5
6
@Data
public class PersonDto {
private String id;
private String name;
private List<String> phoneNumberList;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
PersonPo personPo = new PersonPo();
personPo.setId(1);
personPo.setName("张三");
List<String> phoneNumberList = new ArrayList<String>() {{
add("123456789");
add("987654321");
}};
personPo.setPhoneNumberList(phoneNumberList);

PersonDto personDto = new PersonDto();
BeanCopier beanCopier = BeanCopier.create(personPo.getClass(), personDto.getClass(), false);
beanCopier.copy(personPo, personDto, null);

System.out.println(personPo);
System.out.println(personDto);
}

执行结果如下:

1
2
PersonPo(id=1, name=张三, phoneNumberList=[123456789, 987654321])
PersonDto(id=null, name=张三, phoneNumberList=[123456789, 987654321])

可以看到,id字段没有正常被转换,因为字段类型不一致

这个时候我们设置一个自定义的转换器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import org.springframework.cglib.core.Converter;

public class ConverterSetting implements Converter {

/**
* 自定义类型转换器
*
* @param source 源类的值
* @param target 目标类类型
* @param setFunc 源类的set方法
* @return
*/
@Override
public Object convert(Object source, Class target, Object setFunc) {
if (source instanceof Integer) {
return source.toString();
} else {
return source;
}
}
}

该类实现了Converter接口,重写了convert方法,在这个方法中自定义了类型转换,如果字段类型是Integer,则将其转为String后返回,如果不是则直接返回,目的主要是针对id字段做特殊的转换处理

这个时候再将案例中创建拷贝器的过程修改下:

1
2
BeanCopier beanCopier = BeanCopier.create(personPo.getClass(), personDto.getClass(), true);
beanCopier.copy(personPo, personDto, new ConverterSetting());

useConverter参数设置为true,表示使用自定义转换器,然后再在beanCopier.copy将前面编写的自定义转换器作为参数传递进去

这个时候再执行一次,结果如下:

1
2
PersonPo(id=1, name=张三, phoneNumberList=[123456789, 987654321])
PersonDto(id=1, name=张三, phoneNumberList=[123456789, 987654321])

可以看出来id字段正常拷贝了

使用自定义转换器的时候,需要注意一点:一旦使用了自定义转换器,那么在拷贝的时候将完全按照自定义转换器中设置的规则来进行拷贝属性,所以需要考虑所有可能的情况

提高转换性能

一般来说,使用cglib BeanCopier的时候,拷贝的速度是很快的,特别是针对大量数据的时候。但是在针对多种不同的对象进行拷贝时,存在一个性能瓶颈:由于每次在循环中都需要执行一次create,导致性能下降,这个时候可以通过使用Map做缓存来提高性能

创建自定义的拷贝工具类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class CachedBeanCopier {

private static final Map<String, BeanCopier> BEAN_COPIER_MAP = new HashMap<>();

public static void copy(Object srcObject, Object targetObject) {
String key = generateKey(srcObject.getClass(), targetObject.getClass());

BeanCopier beanCopier;
if (!BEAN_COPIER_MAP.containsKey(key)) {
beanCopier = BeanCopier.create(srcObject.getClass(), targetObject.getClass(), false);
BEAN_COPIER_MAP.put(key, beanCopier);
} else {
beanCopier = BEAN_COPIER_MAP.get(key);
}

beanCopier.copy(srcObject, targetObject, null);
}

private static String generateKey(Class<?> srcClass, Class<?> targetClass) {
return srcClass.getName() + targetClass.getName();
}
}

将创建过的BeanCopier实例存入静态常量中,这样如果有相同的两种对象进行转化,可以直接获取,提高性能

Hutool BeanUtil

Hutool是一个功能很全的工具包,包含了开发时可能会用的很多功能,其中也包括了本次提到的对象拷贝

引入依赖如下:

1
2
3
4
5
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.1.0</version>
</dependency>

使用方法和Spring BeanUtils一致,同时也存在自定义拷贝忽略的参数列表,而且此方法也是浅拷贝

关于该Hutool这个工具类,可以参考其官方文档,里面详细介绍:Hutool

Mapstruct

上面提到的几种拷贝都是在代码的运行阶段执行的,cglib是基于字节码进行操作的,而其它三种都是基于反射操作的。Mapstruct最大的不同之处就是该方法是在编译期自动生成对象拷贝的代码,性能很高

引入依赖如下:

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-jdk8</artifactId>
<version>1.3.0.Final</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.3.0.Final</version>
</dependency>

首先需要创建一个接口来定义要执行转换的方法:

1
2
3
4
5
6
7
8
import org.example.dto.PersonDto;
import org.example.po.PersonPo;
import org.mapstruct.Mapper;

@Mapper
public interface PersonConvertMapper {
PersonDto poToDto(PersonPo personPo);
}

这里要注意的是,@Mapper注解对应引入的是org.mapstruct.Mapper,不要和Mybatis的注解混淆

然后转换的过程案例如下:

1
PersonDto personDto = Mappers.getMapper(PersonConvertMapper.class).poToDto(personPo);

运行结果如下:

1
2
PersonPo(id=1, name=张三, phoneNumberList=[123456789, 987654321])
PersonDto(id=1, name=张三, phoneNumberList=[123456789, 987654321])

可以到target中找到对应生成的实现类:

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
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2023-05-31T22:37:42+0800",
comments = "version: 1.3.0.Final, compiler: javac, environment: Java 1.8.0_362 (Amazon.com Inc.)"
)
@Component
public class PersonConvertMapperImpl implements PersonConvertMapper {

@Override
public PersonDto poToDto(PersonPo personPo) {
if ( personPo == null ) {
return null;
}

PersonDto personDto = new PersonDto();

if ( personPo.getId() != null ) {
personDto.setId( personPo.getId() );
}
personDto.setName( personPo.getName() );
List<String> list = personPo.getPhoneNumberList();
if ( list != null ) {
personDto.setPhoneNumberList( new ArrayList<String>( list ) );
}

return personDto;
}
}

可以看出来,实现类方法中,为每个字段都生成了set方法执行拷贝,所以这种拷贝方法是深拷贝

也可以通过下面案例看出来:

1
2
3
phoneNumberList.add("135792468");
System.out.println(personPo);
System.out.println(personDto);
1
2
PersonPo(id=1, name=张三, phoneNumberList=[123456789, 987654321, 135792468])
PersonDto(id=1, name=张三, phoneNumberList=[123456789, 987654321])