都说了别用BeanUtils.copyProperties,这不翻车了吧

分享是最有效的学习方式。

博客:https://blog.ktdaddy.com/

故事

新年新气象,小猫也是踏上了新年新征程,自从小猫按照老猫给的建议【系统梳理大法】完完整整地梳理完毕系统之后,小猫对整个系统的把控可谓又是上到可一个新的高度。开工一周,事情还不是很多,寥寥几个需求,小猫分分钟搞定。

类似于开放平台的老六接到客户的需求,需要在查询订单新增一个下单时间的返回值,然后这就需要提供底层服务的小猫在接口层给出这个字段,然后老六通过包装之后给客户。由于需求比较简单,所以加完字段之后,老六和小猫也就直接上线了。

上线之后事儿来了,对面客户研发一直询问为什么还是没有下单时间,总是空的。老六于是直接找到了小猫,可是小猫经过了一些列的自测发现返回值都是有的,后来排查到在老六封装之后值不见了。经过仔细排查,终于找到了问题,虽然没有造成太大的影响,但是总归给客户研发的心里留下了一个不好的印象。

虽然下单时间老六和小猫定义的都是orderTime这样一个字段,但是字段类型小猫用的是Date类型而老六用的是LocalDate,恰巧老六在进行对象赋值的时候偷了个懒直接用了spring的BeanUtils.copyProperties工具类,于是导致日期类型的值并没有被赋值过去,踩坑了。

老六这才回想起前段时间架构师在群里@ALL的一段话,“大家用BeanUtils拷贝对象的时候注意点,有坑啊,大家尽量用get,set方法啊”。当时的老六不以为意,想着,“切,这得多麻烦,一个个set不花时间啊,有工具类不用”。现在想来看来是真踩到BeanUtils的坑了。

老六一边改着代码一边叨叨:“这也没说坑在哪里啊......”

盘点BeanUtils.copyProperties坑点

相信很多小伙伴在日常开发的过程中都用过BeanUtils.copyProperties。因为我们日常开发中,经常涉及到DO、DTO、VO对象属性拷贝赋值。很多开发为了省去繁琐而又无聊的set方法往往都会用到这样的工具类进行值拷贝,但是看似简单的拷贝程序,其实往往暗藏坑点,这不上面的老六就踩雷了么。

下面咱们一起来盘点一下这个拷贝存在哪些坑点吧。见下图。

目标赋值对象属性非预期

这里主要说的是从老对象进行属性拷贝到新对象之后,新对象的属性值不是所期待的。这里分为两种。

  1. 两对象属性命名一致,但是类型不一致(即老六遇到的坑点)。
  2. 由于开发编写没有核对好,两个对象属性值不一致,却采用了拷贝,导致异常。
  3. loombook+Boolean类型数据+is属性开头的坑。
  4. 不同内部类,相同属性,目标对象赋值有问题。

类型不匹配

我们来重放一下老六和小猫遇到坑。代码如下:

/**
 * 公众号:程序员老猫 
 **/
public class BeanCopyHelper {
    public static void main(String[] args) {
        Origin a = new Origin();
        a.setOrderTime(new Date());

        Target b = new Target();
        BeanUtils.copyProperties(a,b);
        System.out.println(a.getOrderTime());
        System.out.println(b.getOrderTime());
    }
}
@Data
class Origin {
    private Date orderTime;
}
@Data
class Target {
    private LocalDate orderTime;
}

输出结果:

Sun Feb 25 21:52:22 CST 2024
null

我看看到两个对象的命名虽然是一致的,但是一个是Date另外一个是LocaDate,这样导致值并没有被赋值过去。

两对象属性命名差异导致赋值不成功

这种拷贝不成功的原因很多时候是由于研发人员粗心,没有校对好导致的。例如下面两个类:

@Data
class Origin {
    private Date ordertime;
}
@Data
class Target {
    private Date orderTime;
}

这种显而易见是无法赋值成功的,因为仔细看来两个属性名称不一致。当然不会赋值成功了。

loombook+Boolean类型数据+is属性开头的坑

这种情况是比较极端的,在用loombook和不用loombook的情况下是不一样的。我们看一下下面例子。
当我们不用loombook的时候,如下代码:

public class BeanCopyHelper {
    public static void main(String[] args) {
        Origin origin = new Origin();
        origin.setOrderTime(true);
        Target target = new Target();
        BeanUtils.copyProperties(origin,target);
        System.out.println(origin.getOrderTime());
        System.out.println(target.isOrderTime());
    }
}

class Origin {
    private Boolean isOrderTime;
    public Boolean getOrderTime() {
        return isOrderTime;
    }
    public void setOrderTime(Boolean orderTime) {
        isOrderTime = orderTime;
    }
}
class Target {
    private boolean isOrderTime;
    public boolean isOrderTime() {
        return isOrderTime;
    }
    public void setOrderTime(boolean orderTime) {
        isOrderTime = orderTime;
    }
}

上面的代码中,我们看到基础属性的类型分别是包装类还有一个是非包装类,属性的命名都是一致的。其最终的输出结果,我们看到两者是一致的:

true
true

当如果我们使用loombook的时候,问题就来了,我们看一下loombook改造之后的代码:

public class BeanCopyHelper {
    public static void main(String[] args) {
        Origin origin = new Origin();
        origin.setIsOrderTime(true);
        Target target = new Target();
        BeanUtils.copyProperties(origin,target);
        System.out.println(origin.getIsOrderTime());
        System.out.println(target.isOrderTime());
    }
}

@Data
class Origin {
    private Boolean isOrderTime;
}
@Data
class Target {
    private boolean isOrderTime;
}

最后的输出结果为:

true
false

那么这是为什么呢?老猫在这里简单分享一下,BeanUtils.copyProperties用户在两个对象之间进行属性的复制,底层基于JavaBean的内省机制,通过内省得到拷贝源对象和目的对象属性的读方法和写方法,然后调用对应的方法进行属性的复制。

所以在进行拷贝时,如果手动生成get和set那么方法分别为:getOrderTime()以及setOrderTime()。我们再来看一下如果采用LoomBook的时候,那么对应的get和set的方法分别为:getIsOrderTime()以及setOrderTime(),抛开set和get本身关键字不看,那么后面的肯定是对应不起来了。

这里我们再发散一下,如果说对应的两个类其属性压根连get和set方法都没有设置,那么两个对象能够被拷贝成功吗?答案是显而易见的,无法被拷贝成功。所以这里也是用这个拷贝方法的时候的一个坑点。

不同内部类,相同属性,目标对象赋值有问题。

看标题还是比较抽象的,我们一起来看一下下面的代码实现:

public class BeanCopyHelper {
    public static void main(String[] args) {
        Origin test1 = new Origin();
        test1.outerName = "程序员老猫";
        Origin.InnerClass innerClass = new Origin.InnerClass();
        innerClass.InnerName = "程序员老猫 内部类";
        test1.innerClass = innerClass;
        System.out.println(test1);
        Target test2 = new Target();
        BeanUtils.copyProperties(test1, test2);
        System.out.println(test2);
    }
}
@Data
class Origin {
    public String outerName;
    public Origin.InnerClass innerClass;

    @Data
    public static class InnerClass {
        public String InnerName;
    }
}
@Data
class Target {
    public String outerName;
    public Target.InnerClass innerClass;

    @Data
    public static class InnerClass {
        public String InnerName;
    }
}

输出最终结果如下:

Origin(outerName=程序员老猫, innerClass=Origin.InnerClass(InnerName=程序员老猫 内部类))
Target(outerName=程序员老猫, innerClass=null)

最终我们发现其内部内的属性并没有被赋值过去。

引包冲突导致问题

BeanUtils.copyProperties其实同命名的方法存在于两个不同的包中,一个是spring的另外一个是apache的,如果不注意的话,很容易就会有问题。如下代码:

//org.springframework.beans.BeanUtils(源对象在左边,目标对象在右边)
public static void copyProperties(Object source, Object target) throws BeansException 
//org.apache.commons.beanutils.BeanUtils(源对象在右边,目标对象在左边)
public static void copyProperties(Object dest, Object orig) throws IllegalAccessException, InvocationTargetException

位于org.springframework.beans包下。
其copyProperties方法实现原理和Apache BeanUtils.copyProperties原理类似,默认实现浅拷贝
区别在于对PropertyDescriptor(内省机制相关)的处理结果做了缓存来提升性能。这里大家有兴趣可以自行去查阅一下源代码。

查找字段引用困难

当我们在排查问题的时候,或者在熟悉业务的过程中,常常会想要看一个整个属性值的调用链路,从而来跟踪其设值源头。如果我想看当前的这个属性是什么时候被设值值的时候,老猫的做法通常是找到当前的那个属性的set方法,然后使用idea中的“Find Usages”或者快捷键ALT+F7。得到需要属性值被设置的地方。如下图,就能清晰看到在哪里设值了。

但是,如果用了工具类进行拷贝的话,那么在代码复杂的情况下,我们就很难定位其在什么时候被调用的了。

BeanUtils.copyProperties是浅拷贝

在这里,咱们要回忆一下什么时候浅拷贝,什么是深拷贝。
浅拷贝:浅拷贝是指创建一个新对象,然后将原始对象的内容逐个复制到新对象中。在浅拷贝中,只有最外层对象被复制,而内部的嵌套对象只是引用而已,没有被递归复制。这意味着原始对象和浅拷贝对象之间共享内部对象,修改其中一个对象的内部对象会影响到另一个对象。如下示意图:

深拷贝:深拷贝是指在进行复制操作时,创建一个完全独立的新对象,并递归地复制原始对象及其所有子对象。换句话说,深拷贝会复制对象的所有层级,包括对象的属性、嵌套对象、引用等。因此,原始对象和复制对象是完全独立的,修改其中一个对象不会影响另一个对象。

根据上面的描述,我们通过代码来重现一下坑点,具体如下:

public class Address {
    private String city;
    ...
}
public class Person {
    private String name;
    private Address address;
    ...
}
public class TestMain {
    public static void main(String[] args) {
        Person sourcePerson = new Person();
        sourcePerson.setName("老六");
        Address address = new Address();
        address.setCity("上海 徐汇");
        sourcePerson.setAddress(address);
        Person targetPerson = new Person();
        BeanUtils.copyProperties(sourcePerson, targetPerson);
        System.out.println(targetPerson.getAddress().getCity());
        sourcePerson.getAddress().setCity("上海 黄埔");
        System.out.println(targetPerson.getAddress().getCity());
    }
}

输出结果为:

上海 徐汇
上海 黄埔

我们很明显地看到操作原始属性的地址,直接影响到了新对象的属性的地址。所以这个坑大家也要当心。当然由于浅拷贝的原因导致拷贝出现问题还涉及集合类进行拷贝。例如我们需要对List或者Map进行拷贝的时候也不能直接去拷贝list以及map。

性能问题

由于BeanUtils.copyProperties其实底层是通过反射实现的,所以其程序执行的效率还是比较低的。我们看一下下面的对比代码:

public class BeanCopyHelper {
    public static void main(String[] args) {
        Origin test1 = new Origin();
        test1.outerName = "公众号:程序员老猫";
        Target test2 = new Target();
        long beginTime = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++) {  //循环10万次
            test2.setOuterName(test1.getOuterName());
        }
        System.out.println(test2);
        System.out.println("common setter time:" + (System.currentTimeMillis() - beginTime));
        long beginTime2 = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++) {  //循环10万次
            BeanUtils.copyProperties(test1, test2);
        }
        System.out.println(test2);
        System.out.println("common setter time:" + (System.currentTimeMillis() - beginTime2));
    }
}

@Data
class Origin {
    public String outerName;
}
@Data
class Target {
    public String outerName;
}

输出结果如下:

Target(outerName=公众号:程序员老猫)
common setter time:14
Target(outerName=公众号:程序员老猫)
common setter time:291

上述结果,很好地证明了这个结论。有小伙伴肯定会说,这种场景应该比较少吧,太极端了。那么极端吗?大家回忆一下上面老猫提到了,如果用这个工具复制List或者Map这种集合的时候,其实如果把List和Map当做整个对象来复制往往是失败的。相信如果不是小白的话一般都会知道这个坑点,为了解决这个问题,很多小伙伴可能会选择在List或者Map等集合内部进行循环一一遍历去进行单个对象的拷贝赋值,那么这样的场景下,性能是不是就受到了影响呢?

替换方案

既然说了bean拷贝工具类这么多的坏话,那么我们如何去替换这种写法呢?
第一种:当然是直接采用原始的get以及set方法了。这种方式好像除了代码长了一些之外好像也没有什么缺点了。有小伙伴可能会跳出来说,这不撸起来麻烦么。不着急,idea这款强大的工具不是已经给我们提供插件了么。如下图:

第二种:使用映射工具库,如MapStruct、ModelMapper等,它们可以自动生成属性映射的代码。这些工具库可以减少手动编写setter方法的工作量,并提供更好的性能。
如下使用代码:

    /**
     * 公众号:程序员老猫
     **/
    @Mapper  
    public interface SourceTargetMapper {  
    SourceTargetMapper INSTANCE = Mappers.getMapper(SourceTargetMapper.class);  

    @Mapping(source = "name", target = "name")  
    @Mapping(source = "age", target = "age")  
    Target mapToTarget(Source source);  
    }  

    //使用
    Target target = SourceTargetMapper.INSTANCE.mapToTarget(source);

上述这两种替换方案,说真的作为开发者而言,老猫更喜欢第一种,简单方便,而且不需要依赖第三方maven依赖。第二种个人感觉用起来反而比较繁琐,上述当然纯属个人偏好。

总结

上述小猫和老六的案例中,其实存在的问题需要我们思考的。

即使再小再简单的需求,作为研发开发完毕之后,我们可以直接上线么?其实很多时候事故往往就是由于“不以为意”发生的。事故的发生往往也遵循“墨菲定律”,这就要求我们更要敬畏线上,再小的需求点都需要经过严格的测试验证才能上线。

说了那么多BeanUtils.copyProperties的坏话,那么这种拷贝方式是不是真的就一无是处呢?其实不是的,所谓存在即合理。很多时候使用的时候踩坑说白了我们没有理解好这个拷贝工具的特性。很多时候大家在使用使用一个技术的时候都是囫囵吞枣,为了使用而去使用,压根就没有深入了解这个技术的特性以及使用注意点。所以在我们使用第三方工具的时候,我们需要更好地了解其特性,知其所以然才能更好更正确的使用。小伙伴们你们觉得呢?

如果还有需要补充的点,也欢迎小伙伴们留言。

热门相关:甜蜜隐婚:老公大人,宠上瘾   花开春暖   盛华   楚汉争鼎   第一强者