暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Spring的BeanUtils.copyProperties的这个坑你踩过吗

天凉好个秋 好个秋 2020-07-28
3681

背景

最近,组里的一个同学碰到了一个奇怪的Java运行时的问题,找我一块排查下。

排查之后发现是踩了Spring提供的BeanUtils.copyProperties()
方法的一个坑,记录一下。

每个用户都有个若干个收货地址,领域对象User
定义如下:

public class User {

    /**
     * 收货地址
     */

    private List<Address> receiptAddress;

    private Long userId;

}


Address
类定义为:

public class Address {

    private String street;

    private String country;

}


同时有个UserDTO
类和AddressDTO
类,定义基本上等同于User
类和AddressDTO
类:

public class UserDTO {

    /**
     * 收货地址
     */

    private List<AddressDTO> receiptAddress;

    private Long userId;

}


Address
类定义为:

public class AddressDTO {

    private String street;

    private String country;

}

业务场景中使用如下:

public void test(User user) {
    // 把领域对象转换成DTO对象
    UserDTO userDTO = UserTransfer.toDTO(user);
    AddressDTO addressDTO = userDTO.getReceiptAddress().get(0);
    doSomething(addressDTO); // 做一些地址相关的处理
}

public static UserDTO toDTO(User user) {
    if(null == user) {
        return null;
    }
    UserDTO userDTO = new UserDTO();
    BeanUtils.copyProperties(user,userDTO);
    return userDTO;
}

结果执行过程中报错

java.lang.ClassCastException: com.baicizhan.youziya.kids.account.domain.model.Address cannot be cast to com.baicizhan.youziya.kids.account.common.module.dto.AddressDTO

发生报错的代码是

AddressDTO addressDTO = userDTO.getReceiptAddress().get(0);

报错原因为Address
对象不能被转换成AddressDTO
对象。

排查

这个报错初看很奇怪,UserDTO
里面定义的receiptAddress
明明就是AddressDTO
类型的列表,从列表中获取第一个元素引用赋值给AddressDTO
类型的对象,为什么会出现类型转换呢?感觉有点不科学。

但是凡事肯定都是有原因的,一步一步debug看看。

1.png

BeanUtils.copyProperties()
方法的本意是把两个对象中属性名相同的属性进行复制,但是现在这现象明显已经偏离了最初的目的。

debug过程中发现在调用BeanUtils.copyProperties()
之后,receiptAddress
引用中指向的列表中保存的居然是Address
对象,到这里问题就已经发生了。

为什么会出现这种情况呢?去看看copyProperties()
到底怎么实现的。

/**
 * Copy the property values of the given source bean into the target bean.
 * <p>Note: The source and target classes do not have to match or even be derived
 * from each other, as long as the properties match. Any bean properties that the
 * source bean exposes but the target bean does not will silently be ignored.
 * <p>This is just a convenience method. For more complex transfer needs,
 * consider using a full BeanWrapper.
 * @param source the source bean
 * @param target the target bean
 * @throws BeansException if the copying failed
 * @see BeanWrapper
 */

public static void copyProperties(Object source, Object target) throws BeansException {
 copyProperties(source, target, null, (String[]) null);
}

从注释中可以看出,copyProperties()
方法会把source对象中的属性值复制给target对象,source和target所属的类不需要有任何关系,只要属性名一致即可。

private static void copyProperties(Object source, Object target, @Nullable Class<?> editable,
   @Nullable String... ignoreProperties)
 throws BeansException 
{

 Assert.notNull(source, "Source must not be null");
 Assert.notNull(target, "Target must not be null");

 Class<?> actualEditable = target.getClass();

 // ... 一些省略代码
  // 获取到target对象的所有的属性描述符
 PropertyDescriptor[] targetPds = getPropertyDescriptors(actualEditable);

  // 依次处理目标对象的所有需要写入的属性
 for (PropertyDescriptor targetPd : targetPds) {
    // 找到对应属性的set方法
  Method writeMethod = targetPd.getWriteMethod();
  if (writeMethod != null && (ignoreList == null || !ignoreList.contains(targetPd.getName()))) {
      // 从source对象中找到对应属性名的属性描述符
   PropertyDescriptor sourcePd = getPropertyDescriptor(source.getClass(), targetPd.getName());
   if (sourcePd != null) {
      // 如果存在对应的属性,且原始对象中对应的属性值的类型能够被分配给目标对象的目标属性
      // 则读取原始中对应属性的值,保存在Object对象中,然后调用目标对象的目标属性的
      // set方法,把对应的值写入到目标对象的目标属性中
      // 则直接调用
   Method readMethod = sourcePd.getReadMethod();
   if (readMethod != null &&
     ClassUtils.isAssignable(writeMethod.getParameterTypes()[0], readMethod.getReturnType())) {
    try {
     if (!Modifier.isPublic(readMethod.getDeclaringClass().getModifiers())) {
      readMethod.setAccessible(true);
     }
     Object value = readMethod.invoke(source);
     if (!Modifier.isPublic(writeMethod.getDeclaringClass().getModifiers())) {
      writeMethod.setAccessible(true);
     }
     writeMethod.invoke(target, value);
    }
    catch (Throwable ex) {
     throw new FatalBeanException(
       "Could not copy property '" + targetPd.getName() + "' from source to target", ex);
    }
    }
   }
  }
 }
}

copyProperties()
方法会

  1. 找到target对象的所有可访问属性,然后依次遍历找到的属性
  2. 判断source对象中是否存在对应的属性,且source对象中对应的属性值的类型能够被分配给target对象的目标属性(即类型兼容的),
  3. 如果存在则读取source对象中对应属性的值,保存在Object对象中,然后调用target对象的目标属性的set方法,把对应的值写入到目标对象的目标属性中。

PropertyDescriptor
类用来描述Java Bean
对象的属性(属性名、读写方法、属性类型)。

按照以上分析,来看看对receiptAddress
对象复制时的情况:

2.png

可以看到,在处理receiptAddress
属性的时候,jdk保存的属性类型(target对象和source对象都是)实际上是java.util.List
类型,因为JDK底层已经把具体类型给擦除了(泛型擦除),



copyProperties()

发现两者类型一致(都是List),可直接复制,而value指向的列表中的元素的实际类型为Address
也被直接复制过去了。


所以在BeanUtils.copyProperties()
执行完之后,UserDTO
对象的receiptAddress
属性实际上指向的是Address
类型的列表的引用。(因为列表底层类型已经被擦除了,所以不会报错)

在执行AddressDTO addressDTO = userDTO.getReceiptAddress().get(0);
这行代码的时候,JDK发现从receiptAddress
列表中读取Address
类型的对象之后发现是赋值给AddressDTO
类型,则会做一个类型转换,然后就会转换失败报错了。

解决

总结一下:由于JDK底层实现的泛型擦除机制,导致在使用BeanUtils.copyProperties()
方法复制属性时会出现集合类型的属性的集合元素类型与实际定义类型不一致的情况。

解法:对于集合类型的属性,显示复制。

public static UserDTO toDTO(User user) {
    if(null == user) {
        return null;
    }
    UserDTO userDTO = new UserDTO();
    BeanUtils.copyProperties(user,userDTO);
    if(CollectionUtils.isNotEmpty(user.getReceiptAddress())) {
        List<AddressDTO> receiptAddress = user.getReceiptAddress().stream()
                .map(a -> {
                    AddressDTO addressDTO = new AddressDTO();
                    BeanUtils.copyProperties(a,addressDTO);
                    return  addressDTO;
                })
                .collect(Collectors.toList());
        userDTO.setReceiptAddress(receiptAddress);
    }


    return userDTO;
}


文章转载自天凉好个秋 好个秋,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论