你还在用BeanUtils进行对象属性拷贝?

在做业务的时候,为了隔离变化,我们会将 DAO
查询出来的 DO
和对前端提供的 DTO
隔离开来。大概 90%
的时候,它们的结构都是类似的;但是我们很不喜欢写很多冗长的 b.setF1(a.getF1())
这样的代码,于是我们需要简化对象拷贝方式。

大多时候时候使用的是 Apache
Spring
`BeanUtils ,今天,我们来看一下一个更高效的属性拷贝方式:
BeanCopier`。

一、背景

1.1 对象拷贝概念

Java
中,数据类型分为值类型(基本数据类型)和引用类型,值类型包括 int
double
byte
boolean
char
等简单数据类型,引用类型包括类、接口、数组等复杂类型。

对象拷贝分为 浅拷贝(浅克隆)
深拷贝(深克隆)

  • 浅拷贝与深拷贝差异
分类 浅拷贝 深拷贝
区别 创建一个新对象,然后将当前对象的非静态字段复制到该新对象,如果字段是值类型的,那么对该字段执行复制;如果该字段是引用类型的话,则复制 引用但不复制引用的对象
。因此,原始对象及其副本引用同一个对象。
创建一个新对象,然后将当前对象的非静态字段复制到该新对象,无论该字段是值类型的还是引用类型,都 复制独立的一份
。当你修改其中一个对象的任何内容时,都不会影响另一个对象的内容。

参考文章

1.2 示例前准备

  • 源对象属性类 UserDO.class
    (以下示例,源对象都用这个)
@Data
public class UserDO {

    private int id;
    private String userName;
    /**
     * 以下两个字段用户模拟自定义转换
     */
    private LocalDateTime gmtBroth;
    private BigDecimal balance;

    public UserDO(Integer id, String userName, LocalDateTime gmtBroth, BigDecimal balance) {
        this.id = id;
        this.userName = userName;
        this.gmtBroth = gmtBroth;
        this.balance = balance;
    }
}
  • 造数据工具类 DataUtil.class
public class DataUtil {

    /**
     * 模拟查询出一条数据
     * @return
     */
    public static UserDO createData() {
        return new UserDO(1, "Van", LocalDateTime.now(),new BigDecimal(100L));
    }

    /**
     * 模拟查询出多条数据
     * @param num 数量
     * @return
     */
    public static List createDataList(int num) {
        List userDOS = new ArrayList();
        for (int i = 0; i < num; i++) {
            UserDO userDO = new UserDO(i+1, "Van", LocalDateTime.now(),new BigDecimal(100L));
            userDOS.add(userDO);
        }
        return userDOS;
    }
}

二、对象拷贝之BeanUtils

Apache
Spring
均有 BeanUtils
工具类, Apache
BeanUtils
稳定性与效率都不行; Spring
BeanUtils
比较稳定,不会因为量大了,耗时明显增加,故一般都使用 Spring
BeanUtils

2.1 源码解读

Spring
中的 BeanUtils
,其中实现的方式很简单,就是对两个对象中相同名字的属性进行简单 get/set
,仅检查属性的可访问性。

BeanUtils 源码

可以看到, 成员变量赋值是基于目标对象的成员列表, 并且会跳过 ignore
的以及在源对象中不存在的, 所以这个方法是安全的, 不会因为两个对象之间的结构差异导致错误, 但是必须保证同名的两个成员变量类型相同。

2.2 示例

@Slf4j
public class BeanUtilsDemo {

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        UserDO userDO = DataUtil.createData();
        log.info("拷贝前,userDO:{}", userDO);
        UserDTO userDTO = new UserDTO();
        BeanUtils.copyProperties(userDO,userDTO);
        log.info("拷贝后,userDO:{}", userDO);
    }
}
  • 结果
18:12:11.734 [main] INFO cn.van.parameter.bean.copy.demo.BeanUtilsDemo - 拷贝前,userDO:UserDO(id=1, userName=Van, gmtBroth=2019-11-02T18:12:11.730, balance=100)
18:12:11.917 [main] INFO cn.van.parameter.bean.copy.demo.BeanUtilsDemo - 拷贝后,userDO:UserDO(id=1, userName=Van, gmtBroth=2019-11-02T18:12:11.730, balance=100)

三、对象拷贝之BeanCopier

BeanCopier
是用于在两个 bean
之间进行属性拷贝的。 BeanCopier
支持两种方式:

  1. 一种是不使用 Converter
    的方式,仅对两个 bean
    间属性名和类型完全相同的变量进行拷贝;
  2. 另一种则引入 Converter
    ,可以对某些特定属性值进行特殊操作。

3.1 基本使用

  • 依赖
    cglib
    cglib-nodep
    3.3.0

注意:该依赖非必须,因为 Spring
中已经集成了 cglib
,博主使用的就是 org.springframework.cglib.beans.BeanCopier

3.1.1 属性名称、类型都相同

  • 目标对象属性类
@Data
public class UserDTO {
    private int id;
    private String userName;
}
  • 测试方法
/**
 * 属性名称、类型都相同(部分属性不拷贝)
 */
private static void normalCopy() {
    // 模拟查询出数据
    UserDO userDO = DataUtil.createData();
    log.info("拷贝前:userDO:{}", userDO);
    // 第一个参数:源对象, 第二个参数:目标对象,第三个参数:是否使用自定义转换器(下面会介绍),下同
    BeanCopier b = BeanCopier.create(UserDO.class, UserDTO.class, false);
    UserDTO userDTO = new UserDTO();
    b.copy(userDO, userDTO, null);
    log.info("拷贝后:userDTO:{}", userDTO);
}
  • 结果:拷贝成功
18:24:24.080 [main] INFO cn.van.parameter.bean.copy.demo.BeanCopierDemo - 拷贝前:userDO:UserDO(id=1, userName=Van, gmtBroth=2019-11-02T18:24:24.077, balance=100)
18:24:24.200 [main] INFO cn.van.parameter.bean.copy.demo.BeanCopierDemo - 拷贝后:userDTO:UserDTO(id=1, userName=Van)

3.1.2 属性名称相同、类型不同

  • 目标对象属性类
@Data
public class UserEntity {
    private Integer id;
    private String userName;
}
  • 测试方法
/**
 * 属性名称相同、类型不同
 */
private static void sameNameDifferentType() {
    // 模拟查询出数据
    UserDO userDO = DataUtil.createData();
    log.info("拷贝前:userDO:{}", userDO);

    BeanCopier b = BeanCopier.create(UserDO.class, UserEntity.class, false);
    UserEntity userEntity = new UserEntity();
    b.copy(userDO, userEntity, null);
    log.info("拷贝后:userEntity:{}", userEntity);
}
  • 结果
19:43:31.645 [main] INFO cn.van.parameter.bean.copy.demo.BeanCopierDemo - 拷贝前:userDO:UserDO(id=1, userName=Van, gmtBroth=2019-11-02T19:43:31.642, balance=100)
19:43:31.748 [main] INFO cn.van.parameter.bean.copy.demo.BeanCopierDemo - 拷贝后:userEntity:UserEntity(id=null, userName=Van)
  • 分析

通过日志可以发现: UserDO
int
类型的 id
无法拷贝到 UserEntity
Integer
id

3.1.3 小节

BeanCopier
只拷贝名称和类型都相同的属性。

即使源类型是原始类型( int
, short
char
等),目标类型是其包装类型( Integer
, Short
Character
等),或反之:都不会被拷贝。

3.2 自定义转换器

通过 3.1.2
可知,当源和目标类的属性类型不同时,不能拷贝该属性,此时我们可以通过实现 Converter
接口来自定义转换器

3.2.1 准备

  • 目标对象属性类
@Data
public class UserDomain {
    private Integer id;
    private String userName;
    
    /**
     * 以下两个字段用户模拟自定义转换
     */
    private String gmtBroth;
    private String balance;
}

3.2.2 不使用 Converter

  • 测试方法
/**
 * 类型不同,不使用Converter
 */
public static void noConverterTest() {
    // 模拟查询出数据
    UserDO userDO = DataUtil.createData();
    log.info("拷贝前:userDO:{}", userDO);
    BeanCopier copier = BeanCopier.create(UserDO.class, UserDomain.class, false);
    UserDomain userDomain = new UserDomain();
    copier.copy(userDO, userDomain, null);
    log.info("拷贝后:userDomain:{}", userDomain);
}
  • 结果
19:49:19.294 [main] INFO cn.van.parameter.bean.copy.demo.BeanCopierDemo - 拷贝前:userDO:UserDO(id=1, userName=Van, gmtBroth=2019-11-02T19:49:19.290, balance=100)
19:49:19.394 [main] INFO cn.van.parameter.bean.copy.demo.BeanCopierDemo - 拷贝后:userDomain:UserDomain(id=null, userName=Van, gmtBroth=null, balance=null)
  • 分析

通过打印日志的前后对比,属性类型不同的字段 id
, gmtBroth
, balance
未拷贝。

3.2.3 使用 Converter

  • 实现 Converter
    接口来自定义属性转换
public  class UserConverter implements Converter {

    /**
     * 时间转换的格式
     */
    DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    /**
     * 自定义属性转换
     * @param value 源对象属性类
     * @param target 目标对象里属性对应set方法名,eg.setId
     * @param context 目标对象属性类
     * @return
     */
    @Override
    public Object convert(Object value, Class target, Object context) {
        if (value instanceof Integer) {
            return value;
        } else if (value instanceof LocalDateTime) {
            LocalDateTime date = (LocalDateTime) value;
            return dtf.format(date);
        } else if (value instanceof BigDecimal) {
            BigDecimal bd = (BigDecimal) value;
            return bd.toPlainString();
        }
        // 更多类型转换请自定义
        return value;
    }
}
  • 测试方法
/**
 * 类型不同,使用Converter
 */
public static void converterTest() {
    // 模拟查询出数据
    UserDO userDO = DataUtil.createData();
    log.info("拷贝前:userDO:{}", userDO);
    BeanCopier copier = BeanCopier.create(UserDO.class, UserDomain.class, true);
    UserConverter converter = new UserConverter();
    UserDomain userDomain = new UserDomain();
    copier.copy(userDO, userDomain, converter);
    log.info("拷贝后:userDomain:{}", userDomain);
}
  • 结果:全部拷贝
19:51:11.989 [main] INFO cn.van.parameter.bean.copy.demo.BeanCopierDemo - 拷贝前:userDO:UserDO(id=1, userName=Van, gmtBroth=2019-11-02T19:51:11.985, balance=100)
19:51:12.096 [main] INFO cn.van.parameter.bean.copy.demo.BeanCopierDemo - 拷贝后:userDomain:UserDomain(id=1, userName=Van, gmtBroth=2019-11-02 19:51:11, balance=100)

3.2.4 小节

  1. 一旦使用 Converter
    BeanCopier
    只使用 Converter
    定义的规则去拷贝属性,所以在 convert()
    方法中要考虑所有的属性。
  2. 但,使用 Converter
    会使对象拷贝速度变慢。

3.3 BeanCopier
总结

  1. 当源类和目标类的属性名称、类型都相同,拷贝没问题。
  2. 当源对象和目标对象的属性名称相同、类型不同,那么名称相同而类型不同的属性不会被拷贝。注意,原始类型( int
    short
    char
    )和 他们的包装类型,在这里都被当成了不同类型,因此不会被拷贝。
  3. 源类或目标类的 setter
    getter
    少,拷贝没问题,此时 setter
    多余,但是不会报错。
  4. 源类和目标类有相同的属性(两者的 getter
    都存在),但是目标类的 setter
    不存在,此时会抛出 NullPointerException

四、BeanUtils与BeanCopier速度对比

废话不多说,我这里直接演示两种工具 10000
条数据拷贝的耗时对比

4.1 BeanUtils

  • 测试代码
private static void beanUtil() {
    List list = DataUtil.createDataList(10000);
    long start = System.currentTimeMillis();
    List dtoList = new ArrayList();
    list.forEach(userDO -> {
        UserDTO userDTO = new UserDTO();
        BeanUtils.copyProperties(userDO,userDTO);
        dtoList.add(userDTO);
    });
    log.info("BeanUtils cotTime: {}ms", System.currentTimeMillis() - start);
}
  • 结果(耗时: 232ms
    )
20:14:24.380 [main] INFO cn.van.parameter.bean.copy.demo.BeanCopyComparedDemo - BeanUtils cotTime: 232ms

4.2 BeanCopier

  • 测试代码
private static void beanCopier() {
    // 工具类生成10w条数据
    List doList = DataUtil.createDataList(10000);
    long start = System.currentTimeMillis();
    List dtoList = new ArrayList();
    doList.forEach(userDO -> {
        BeanCopier b = BeanCopier.create(UserDO.class, UserDTO.class, false);
        UserDTO userDTO = new UserDTO();
        b.copy(userDO, userDTO, null);
        dtoList.add(userDTO);
    });
    log.info("BeanCopier costTime: {}ms", System.currentTimeMillis() - start);
}
  • 结果(耗时: 116ms
    )
20:15:24.380 [main] INFO cn.van.parameter.bean.copy.demo.BeanCopyComparedDemo - BeanCopier costTime: 116ms

4.3 缓存 BeanCopier
实例提升性能

BeanCopier
拷贝速度快,性能瓶颈出现在创建 BeanCopier
实例的过程中。 所以,把创建过的 BeanCopier
实例放到缓存中,下次可以直接获取,提升性能。

  • 测试代码
private static void beanCopierWithCache() {

    List userDOList = DataUtil.createDataList(10000);
    long start = System.currentTimeMillis();
    List userDTOS = new ArrayList();
    userDOList.forEach(userDO -> {
        UserDTO userDTO = new UserDTO();
        copy(userDO, userDTO);
        userDTOS.add(userDTO);
    });
    log.info("BeanCopier 加缓存后 costTime: {}ms", System.currentTimeMillis() - start);

}

public static void copy(Object srcObj, Object destObj) {
    String key = genKey(srcObj.getClass(), destObj.getClass());
    BeanCopier copier = null;
    if (!BEAN_COPIERS.containsKey(key)) {
        copier = BeanCopier.create(srcObj.getClass(), destObj.getClass(), false);
        BEAN_COPIERS.put(key, copier);
    } else {
        copier = BEAN_COPIERS.get(key);
    }
    copier.copy(srcObj, destObj, null);

}
private static String genKey(Class srcClazz, Class destClazz) {
    return srcClazz.getName() + destClazz.getName();
}
  • 结果(耗时: 6ms
    )
20:32:31.405 [main] INFO cn.van.parameter.bean.copy.demo.BeanCopyComparedDemo - BeanCopier 加缓存后 costTime: 6ms

五、总结及源码

场景 耗时(10000次调用) 原理
BeanUtils 232ms 反射
BeanCopier 116ms 修改字节码
BeanCopier(加缓存) 6ms 修改字节码

Github 示例代码

推荐: BeanCopier 源码分析

技术交流