为什么阿里巴巴代码规约要求避免使用 Apache BeanUtils 进行属性的拷贝

6,060 阅读6分钟

声明:本文属原创文章,始发于公号:程序员自学之道,并同步发布于 juejin.cn/user/108157… 转载请注明出处!

缘起

有一次开发过程中,刚好看到一个小伙伴在调用 set 方法将一个数据库中查询出来的 PO 对象的 20 多个属性拷贝到 Vo 对象中,类似这样:

属性拷贝

可以看出,Po 和 Vo 两个类的字段绝大部分是一样的,我们一个个地调用 set 方法只是做了一些重复的冗长的操作。这种操作非常容易出错,因为对象的属性太多,有可能会漏掉一两个,而且肉眼很难察觉

类似这样的操作,我们可以很容易想到,可以通过反射来解决。其实,如此普遍通用的功能,一个 BeanUtils 工具类就可以搞定了。

于是我建议这位小伙伴使用了 Apache BeanUtils.copyProperties 进行属性拷贝,这为我们的程序挖了一个坑

阿里代码规约

当我们开启阿里代码扫描插件时,如果你使用了 Apache BeanUtils.copyProperties 进行属性拷贝,它会给你一个非常严重的警告。因为,Apache BeanUtils性能较差,可以使用 Spring BeanUtils 或者 Cglib BeanCopier 来代替

阿里规约警告

看到这样的警告,有点让人有点不爽。大名鼎鼎的 Apache 提供的包,居然会存在性能问题,以致于阿里给出了严重的警告。

那么,这个性能问题究竟是有多严重呢?毕竟,在我们的应用场景中,如果只是很微小的性能损耗,但是能带来非常大的便利性,还是可以接受的。

带着这个问题。我们来做一个实验,验证一下。

如果对具体的测试方式没有兴趣,可以跳过直接看结果哦~

测试方法接口和实现定义

首先,为了测试方便,让我们来定义一个接口,并将几种实现统一起来:

public interface PropertiesCopier {
    void copyProperties(Object source, Object target) throws Exception;
}
public class CglibBeanCopierPropertiesCopier implements PropertiesCopier {
    @Override
    public void copyProperties(Object source, Object target) throws Exception {
        BeanCopier copier = BeanCopier.create(source.getClass(), target.getClass(), false);
        copier.copy(source, target, null);
    }
}
// 全局静态 BeanCopier,避免每次都生成新的对象
public class StaticCglibBeanCopierPropertiesCopier implements PropertiesCopier {
    private static BeanCopier copier = BeanCopier.create(Account.class, Account.class, false);
    @Override
    public void copyProperties(Object source, Object target) throws Exception {
        copier.copy(source, target, null);
    }
}
public class SpringBeanUtilsPropertiesCopier implements PropertiesCopier {
    @Override
    public void copyProperties(Object source, Object target) throws Exception {
        org.springframework.beans.BeanUtils.copyProperties(source, target);
    }
}
public class CommonsBeanUtilsPropertiesCopier implements PropertiesCopier {
    @Override
    public void copyProperties(Object source, Object target) throws Exception {
        org.apache.commons.beanutils.BeanUtils.copyProperties(target, source);
    }
}
public class CommonsPropertyUtilsPropertiesCopier implements PropertiesCopier {
    @Override
    public void copyProperties(Object source, Object target) throws Exception {
        org.apache.commons.beanutils.PropertyUtils.copyProperties(target, source);
    }
}

单元测试

然后写一个参数化的单元测试:

@RunWith(Parameterized.class)
public class PropertiesCopierTest {
    @Parameterized.Parameter(0)
    public PropertiesCopier propertiesCopier;
    // 测试次数
    private static List<Integer> testTimes = Arrays.asList(100, 1000, 10_000, 100_000, 1_000_000);
    // 测试结果以 markdown 表格的形式输出
    private static StringBuilder resultBuilder = new StringBuilder("|实现|100|1,000|10,000|100,000|1,000,000|\n").append("|----|----|----|----|----|----|\n");

    @Parameterized.Parameters
    public static Collection<Object[]> data() {
        Collection<Object[]> params = new ArrayList<>();
        params.add(new Object[]{new StaticCglibBeanCopierPropertiesCopier()});
        params.add(new Object[]{new CglibBeanCopierPropertiesCopier()});
        params.add(new Object[]{new SpringBeanUtilsPropertiesCopier()});
        params.add(new Object[]{new CommonsPropertyUtilsPropertiesCopier()});
        params.add(new Object[]{new CommonsBeanUtilsPropertiesCopier()});
        return params;
    }

    @Before
    public void setUp() throws Exception {
        String name = propertiesCopier.getClass().getSimpleName().replace("PropertiesCopier", "");
        resultBuilder.append("|").append(name).append("|");
    }

    @Test
    public void copyProperties() throws Exception {
        Account source = new Account(1, "test1", 30D);
        Account target = new Account();
        // 预热一次
        propertiesCopier.copyProperties(source, target);
        for (Integer time : testTimes) {
            long start = System.nanoTime();
            for (int i = 0; i < time; i++) {
                propertiesCopier.copyProperties(source, target);
            }
            resultBuilder.append((System.nanoTime() - start) / 1_000_000D).append("|");
        }
        resultBuilder.append("\n");
    }

    @AfterClass
    public static void tearDown() throws Exception {
        System.out.println("测试结果:");
        System.out.println(resultBuilder);
    }
}

测试结果

时间单位毫秒

实现 100次 1,000次 10,000次 100,000次 1,000,000次
StaticCglibBeanCopier 0.055022 0.541029 0.999478 2.754824 9.88556
CglibBeanCopier 5.320798 11.086323 61.037446 72.484607 333.384007
SpringBeanUtils 5.180483 21.328542 30.021662 103.266375 966.439272
CommonsPropertyUtils 9.729159 42.927356 74.063789 386.127787 1955.5437
CommonsBeanUtils 24.99513 170.728558 572.335327 2970.3068 27563.3459

结果表明,Cglib 的 BeanCopier 的拷贝速度是最快的,即使是百万次的拷贝也只需要 10 毫秒! 相比而言,最差的是 Commons 包的 BeanUtils.copyProperties 方法,100 次拷贝测试与表现最好的 Cglib 相差 400 倍之多。百万次拷贝更是出现了 2800 倍的性能差异!

结果真是让人大跌眼镜。

但是它们为什么会有这么大的差异呢?

原因分析

查看源码,我们会发现 CommonsBeanUtils 主要有以下几个耗时的地方:

  • 输出了大量的日志调试信息
  • 重复的对象类型检查
  • 类型转换
  public void copyProperties(final Object dest, final Object orig)
        throws IllegalAccessException, InvocationTargetException {
        // 类型检查 
        if (orig instanceof DynaBean) {
            ...
        } else if (orig instanceof Map) {
           ...
        } else {
            final PropertyDescriptor[] origDescriptors = ...
            for (PropertyDescriptor origDescriptor : origDescriptors) {
                ...
                // 这里每个属性都调一次 copyProperty
                copyProperty(dest, name, value);
            }
        }
    }

    public void copyProperty(final Object bean, String name, Object value)
        throws IllegalAccessException, InvocationTargetException {
        ...
        // 这里又进行一次类型检查
        if (target instanceof DynaBean) {
            ...
        }
        ...
        // 需要将属性转换为目标类型
        value = convertForCopy(value, type);
        ...
    }
    // 而这个 convert 方法在日志级别为 debug 的时候有很多的字符串拼接
    public <T> T convert(final Class<T> type, Object value) {
        if (log().isDebugEnabled()) {
            log().debug("Converting" + (value == null ? "" : " '" + toString(sourceType) + "'") + " value '" + value + "' to type '" + toString(targetType) + "'");
        }
        ...
        if (targetType.equals(String.class)) {
            return targetType.cast(convertToString(value));
        } else if (targetType.equals(sourceType)) {
            if (log().isDebugEnabled()) {
                log().debug("No conversion required, value is already a " + toString(targetType));
            }
            return targetType.cast(value);
        } else {
            // 这个 convertToType 方法里也需要做类型检查
            final Object result = convertToType(targetType, value);
            if (log().isDebugEnabled()) {
                log().debug("Converted to " + toString(targetType) + " value '" + result + "'");
            }
            return targetType.cast(result);
        }
    }

具体的性能和源码分析,可以参考这几篇文章:

One more thing

除了性能问题之外,在使用 CommonsBeanUtils 时还有其他的坑需要特别小心!

包装类默认值

在进行属性拷贝时,虽然 CommonsBeanUtils 默认不会给原始包装类赋默认值的,但是在使用低版本(1.8.0及以下)的时候,如果你的类有 Date 类型属性,而且来源对象中该属性值为 null 的话,就会发生异常:

org.apache.commons.beanutils.ConversionException: No value specified for 'Date'

解决这个问题的办法是注册一个 DateConverter:

ConvertUtils.register(new DateConverter(null), java.util.Date.class);

然而这个语句,会导致包装类型会被赋予原始类型的默认值,如 Integer 属性默认赋值为 0,尽管你的来源对象该字段的值为 null。

在高版本(1.9.3)中,日期 null 值的问题和包装类赋默认值的问题都被修复了。

这个在我们的包装类属性为 null 值时有特殊含义的场景,非常容易踩坑!例如搜索条件对象,一般 null 值表示该字段不做限制,而 0 表示该字段的值必须为0。

改用其他工具时

当我们看到阿里的提示,或者你看了这篇文章之后,知道了 CommonsBeanUtils 的性能问题,想要改用 Spring 的 BeanUtils 时,要小心:

org.apache.commons.beanutils.BeanUtils.copyProperties(Object target, Object source);
org.springframework.beans.BeanUtils.copyProperties(Object source, Object target);

从方法签名上可以看出,这两个工具类的名称相同,方法名也相同,甚至连参数个数、类型、名称都相同。但是参数的位置是相反的。因此,如果你想更改的时候,千万要记得,将 target 和 source 两个参数也调换过来!

另外,可能由于种种原因,你获取的堆栈信息不完整找不到问题在哪,所以这里顺便提醒一下:

如果你遇到 java.lang.IllegalArgumentException: Source must not be null或者 java.lang.IllegalArgumentException: Target must not be null 这样的异常信息却到处找不到原因时,不用找了,这是由于你在 copyProperties 的时候传了 null 值导致的。