掘金 后端 ( ) • 2024-04-23 11:02

背景

我们经常会用到各种工具包提供的 copyProperties 能力,但大多数都是使用反射机制去复制属性,我们都知道反射会影响代码运行效率,因此有这个需求时我们会尽可能去找最合适的方案,Mapstruct 就是一个,它在编译时就将copy相关代码生成了,确实是速度最快的方案了。

其实 cglib 也提供了类似的能力:BeanCopier ,我们知道,cglib 是一个动态代理库,所以它的原理就是动态生成复制代码。

可惜的是 cglib 已经不再维护了:

image.png

虽然 spring boot 还在使用 cglib,但看其源码就知道,它是将cglib嵌入到spring里的:

image.png

因此在9之后的高版本JDK中,使用cglib会遇到各种奇奇怪怪的问题,最多的是如下模块化引起的问题:

image.png 其实这个问题就是工具类所使用的类加载器访问不了新生成的类引起的,打开 BeanCopier相关源码,发现其实它并不复杂,于是准备研究一下,看看能不能挖出一些工具类。

使用

编写测试代码

public static void main(String[] args) {
    TestBean bean = new TestBean();
    bean.setCol1("abc");
    bean.setCol2("abc");
    bean.setCol3("abc");
    bean.setCol4("abc");
    bean.setCol5("abc");
    bean.setCol6("abc");
    TestBeanTarget target = new TestBeanTarget();
    BeanCopier.Generator generator = new BeanCopier.Generator();
    generator.setSource(TestBean.class);
    generator.setTarget(TestBeanTarget.class);
    BeanCopier copier = generator.create();
    copier.copy(bean, target, null);
    System.out.println(target);
}

运行报错:

Exception in thread "main" org.springframework.cglib.core.CodeGenerationException: java.lang.reflect.InaccessibleObjectException-->Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(java.lang.String,byte[],int,int,java.security.ProtectionDomain) throws java.lang.ClassFormatError accessible: module java.base does not "opens java.lang" to unnamed module @6c629d6e
	at org.springframework.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:547)
	at org.springframework.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:371)
	at org.springframework.cglib.core.AbstractClassGenerator$ClassLoaderData.lambda$new$1(AbstractClassGenerator.java:107)
	at org.springframework.cglib.core.internal.LoadingCache.lambda$createEntry$1(LoadingCache.java:52)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
	at org.springframework.cglib.core.internal.LoadingCache.createEntry(LoadingCache.java:57)
	at org.springframework.cglib.core.internal.LoadingCache.get(LoadingCache.java:34)
	at org.springframework.cglib.core.AbstractClassGenerator$ClassLoaderData.get(AbstractClassGenerator.java:130)
	at org.springframework.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:317)
	at org.springframework.cglib.beans.BeanCopier$Generator.create(BeanCopier.java:113)
	at com.bby.tools.util.CopyUtil.main(CopyUtil.java:26)

解决问题

我们先通过断点找到异常抛出点:

image.png

可以看见,这里生成的copier类的包名和所给的包名匹配不上,这是什么原因呢?

看看这两个包名的来源:

image.png

继续找 lookupClass 我们看到,这个变量居然是我给的复制目标类!

image.png

那么,为什么jdk会拿生成的copier类去和我们的目标类的包名去做比较呢?拉高视角,我们看到,这一步动作的目的是根据生成的字节码加载到Class对象,它在调用ReflectUtils.defineClass方法时,将目的类作为 contextClass 也传了进去:

image.png

让GPT解释一下这个 contextClass

image.png

好吧,看样子这个校验还是有必要的,那我们手动指定contextClass和copier生成的包名应该就可以了:

generator.setContextClass(CopyObj.class);
generator.setNamingPolicy(new NamingPolicy() {
    /**
     * This allows to test collisions of {@code key.hashCode()}.
     */
    private final static boolean STRESS_HASH_CODE = Boolean.getBoolean("org.springframework.cglib.test.stressHashCodes");
    @Override
    public String getClassName(String prefix, String source, Object key, Predicate names) {
        if (prefix == null) {
            prefix = CopyObj.class.getCanonicalName();
        } else if (prefix.startsWith("java")) {
            prefix = "$" + prefix;
        }
        String base =
                prefix + "$$" +
                        source.substring(source.lastIndexOf('.') + 1) +
                        getTag() + "$$" +
                        Integer.toHexString(STRESS_HASH_CODE ? 0 : key.hashCode());
        String attempt = base;
        int index = 2;
        while (names.evaluate(attempt)) {
            attempt = base + "_" + index++;
        }
        return attempt;
    }

    private String getTag() {
        return "BBY";
    }
});

运行一下:

image.png

完美!

封装工具类

直接就这么使用,看起来还是很复杂的,我们可以将之包装一下,提供一个"Fast"的工具类,代码如下:

public class FastBeanUtil {
    private static final NamingPolicy NAMING_POLICY = new NamingPolicy() {
        /**
         * This allows to test collisions of {@code key.hashCode()}.
         */
        private final static boolean STRESS_HASH_CODE = Boolean.getBoolean("org.springframework.cglib.test.stressHashCodes");

        @Override
        public String getClassName(String prefix, String source, Object key, Predicate names) {
            if (prefix == null) {
                prefix = FastBeanUtil.class.getCanonicalName();
            } else if (prefix.startsWith("java")) {
                prefix = "$" + prefix;
            }
            String base = prefix + "$$" + getTag() + "$$" + Integer.toHexString(STRESS_HASH_CODE ? 0 : key.hashCode());
            String attempt = base;
            int index = 2;
            while (names.evaluate(attempt)) {
                attempt = base + "_" + index++;
            }
            return attempt;
        }

        private String getTag() {
            return "Copy";
        }
    };

    /**
     * 复制属性
     *
     * @param source 源对象
     * @param target 目标对象
     * @return 目标对象
     */
    public static <T> T copyProperties(Object source, T target) {
        createCopier(source.getClass(), target.getClass())
                .copy(source, target, null);
        return target;
    }

    /**
     * 创建 BeanCopier
     * @param sourceClass 源对象类型
     * @param targetClass 目标对象类型
     * @return BeanCopier
     */
    public static BeanCopier createCopier(Class<?> sourceClass, Class<?> targetClass) {
        BeanCopier.Generator generator = new BeanCopier.Generator();
        generator.setSource(sourceClass);
        generator.setTarget(targetClass);
        generator.setContextClass(FastBeanUtil.class);
        generator.setNamingPolicy(NAMING_POLICY);
        return generator.create();
    }
}

作为一个工具类,效率是必须考虑的,我们拿hutoolspringBeanUtil对比测试一下(别问我为什么不测apache的):

public static void main(String[] args) {
    final int cnt = 100 * 10000;
    TestBean bean = new TestBean();
    bean.setCol1("abc");
    bean.setCol2("abc");
    bean.setCol3("abc");
    bean.setCol4("abc");
    bean.setCol5("abc");
    bean.setCol6("abc");
    DurationUtil.duration(cn.hutool.core.bean.BeanUtil.class.getCanonicalName(), () -> {
        for (int i = 0; i < cnt; i++) {
            cn.hutool.core.bean.BeanUtil.copyProperties(bean, new TestBean());
        }
    });
    System.out.println("=======================");
    DurationUtil.duration(org.springframework.beans.BeanUtils.class.getCanonicalName(), () -> {
        for (int i = 0; i < cnt; i++) {
            org.springframework.beans.BeanUtils.copyProperties(bean, new TestBean());
        }
    });
    System.out.println("=======================");
    DurationUtil.duration(FastBeanUtil.class.getCanonicalName(), () -> {
        for (int i = 0; i < cnt; i++) {
            FastBeanUtil.copyProperties(bean, new TestBean());
        }
    });
    System.out.println("=======================");
    BeanCopier copier = FastBeanUtil.createCopier(TestBean.class, TestBean.class);
    DurationUtil.duration(BeanCopier.class.getCanonicalName(), () -> {
        for (int i = 0; i < cnt; i++) {
            copier.copy(bean, new TestBean(), null);
        }
    });
}

运行结果:

image.png

使用 BeanCopier 快了不是一点半点,而封装的FastBeanUtil虽然内部使用了 BeanCopiercglib也提供了缓存机制,但是计算copier的类名也是耗时间的。

扩展:beanToMap

我在想既然生成的代码进行copy这么快,那么,beanToMap()是否也能这样呢?我先尝试写一个硬编码的看看。 参考一下copier的生成,我们添加系统参数,可以将生成的文件放在磁盘里:

System.setProperty(DebuggingClassWriter.DEBUG_LOCATION_PROPERTY, "D:\class");
copyProperties(new TestBean(), new TestBean());

这样在磁盘目录 D:\\class 里就有了 class 文件,我们用IDEA直接打开它:

image.png

代码很简单,我们仿照着写一份 beanToMap:

public class BeanToMapTest implements BeanToMap {
    @Override
    public Map<String, Object> toMap(Object bean) {
        TestBean obj = (TestBean) bean;
        Map<String, Object> map = new HashMap<>();
        map.put("col1", obj.getCol1());
        map.put("col2", obj.getCol2());
        map.put("col3", obj.getCol3());
        return map;
    }
}

貌似有一定的可行性,我们只需将类型和map的put的键值替换掉即可。

观看 copier 生成源码,有点汇编的意思,应该是在操作字节码:

image.png

这方面我知识面不多,但是我们可以将做个模板类编译,然后用 javap 查看其字节码,再抄回来啊!到 target 对于目录,执行 javap -c BeanToMapTest.class,得到结果如下:


Compiled from "BeanToMapTest.java"
public class com.bby.tools.test.BeanToMapTest {
  public com.bby.tools.test.BeanToMapTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public java.util.Map<java.lang.String, java.lang.Object> toMap(java.lang.Object);
    Code:
       0: aload_1
       1: checkcast     #7                  // class com/bby/tools/model/TestBean
       4: astore_2
       5: new           #9                  // class java/util/HashMap
       8: dup
       9: invokespecial #11                 // Method java/util/HashMap."<init>":()V
      46: invokeinterface #18,  3           // InterfaceMethod java/util/Map.put:(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
      51: pop
      52: aload_3
      53: areturn
}

我一向是喜欢偷懒的,使用将以上信息,和源码一起丢给GPT,让它给我写一下,虽然不可能完全正确,但也能免去很多麻烦,我再修改一下即可,最后的产出如下:

    public static byte[] generateClass(Class<?> beanClass) {
        // 生成类的简单名称加上ToMap后缀作为新类的名称
        String className = beanClass.getSimpleName() + "ToMap";
        // 生成类的完整内部名称(包括包名)
        String internalClassName = pkg + className;
        // 创建一个ClassWriter实例,用于生成字节码
        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);

        // 访问类头部,并设置访问标志、名称、父类、接口等信息
        cw.visit(Opcodes.V17, // java17
                Opcodes.ACC_PUBLIC + Opcodes.ACC_SUPER,
                internalClassName,
                null,
                "java/lang/Object",
                new String[]{"com/bby/tools/test/BeanToMap"});

        // 生成构造函数
        MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
        mv.visitCode();
        // 加载当前实例的引用(this)
        mv.visitVarInsn(Opcodes.ALOAD, 0);
        // 调用父类(Object)的构造函数
        mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V");
        // 返回,结束构造函数
        mv.visitInsn(Opcodes.RETURN);
        mv.visitMaxs(1, 1);
        mv.visitEnd();

        // 生成toMap方法
        MethodVisitor toMapMethodVisitor = cw.visitMethod(Opcodes.ACC_PUBLIC, "toMap", "(Ljava/lang/Object;)Ljava/util/Map;", null, null);
        GeneratorAdapter ga = new GeneratorAdapter(toMapMethodVisitor, Opcodes.ACC_PUBLIC, "toMap", "(Ljava/lang/Object;)Ljava/util/Map;");

        // 加载并转型传入的bean对象
        ga.loadArg(0);
        ga.checkCast(Type.getType(beanClass));

        // 创建一个本地变量来存储bean的引用
        int beanLocalVarIndex = ga.newLocal(Type.getType(beanClass));
        ga.storeLocal(beanLocalVarIndex); // 将bean引用存储在局部变量中

        // 创建并初始化一个HashMap实例
        ga.newInstance(Type.getType(HashMap.class));
        ga.dup();
        ga.invokeConstructor(Type.getType(HashMap.class), Method.getMethod("void <init> ()"));
        int mapLocal = ga.newLocal(Type.getType(HashMap.class));
        ga.storeLocal(mapLocal); // 将HashMap引用存储在局部变量中

        for (PropertyDescriptor descriptor : BeanUtils.getPropertyDescriptors(beanClass)) {
            if ("class".equals(descriptor.getName())) {
                continue; // 忽略class属性
            }
            ga.loadLocal(mapLocal); // 加载HashMap引用
            ga.push(descriptor.getName()); // 将字段名作为map的键压入栈
            ga.loadLocal(beanLocalVarIndex); // 加载bean引用
            ga.invokeVirtual(Type.getType(beanClass), Method.getMethod(descriptor.getReadMethod())); // 从bean中获取字段值
            if (descriptor.getPropertyType().isPrimitive()) {
                ga.box(Type.getType(descriptor.getPropertyType())); // 如果字段是基本类型,则进行装箱操作
            }
            // 调用Map的put方法将键值对放入map中
            ga.invokeVirtual(Type.getType(HashMap.class), Method.getMethod("Object put (Object, Object)"));
            ga.pop(); // 弹出put方法的返回值(我们不需要它)
        }
        // 加载并返回HashMap引用
        ga.loadLocal(mapLocal);
        ga.returnValue(); // 使用returnValue方法返回结果
        ga.endMethod();
        toMapMethodVisitor.visitEnd();

        // 结束类的生成
        cw.visitEnd();

        // 返回生成的字节码
        return cw.toByteArray();
    }

这里我暂时只想产出一个复制类,其它的功能适配先不考虑,因此写死了一些常量。

将构建出的字节码写入文件,即可用 IDEA 打开:

byte[] bytes = generateClass(TestBean.class);
FileUtil.writeBytes(bytes, "D:\class\TestBeanToMap.class");
System.out.println("D:\class\TestBeanToMap.class");

我们看一看结果:

image.png

非常nice!

急不可耐的去实例化:

ReflectUtils.defineClass("com.bby.tools.test.TestBeanToMap", bytes, ClassLoaderUtil.getClassLoader());

于是接收到了来自java9的嘲笑: image.png 可能是这个工具类内部访问了某些非公开的API,我们项目没做模块化,我也不想做,所以我们绕开它吧,自定义一个类加载器去加载:

    public static void main(String[] args) {
        byte[] bytes = generateClass(TestBean.class);
        FileUtil.writeBytes(bytes, "D:\\class\\TestBeanToMap.class");
        System.out.println("D:\\class\\TestBeanToMap.class");
        new ClassLoader() {
            {
                Class<?> aClass = defineClass("com.bby.tools.test.TestBeanToMap",
                        bytes, 0, bytes.length);
                BeanToMap beanToMap = (BeanToMap) ReflectUtil.newInstance(aClass);
                TestBean bean = new TestBean();
                bean.setAnInt(100);
                bean.setCol1("ccccc");
                Map<String, Object> map = beanToMap.toMap(bean);
                System.out.println(map);
            }
        };
    }

运行:

image.png

完美!这一步打通了,那将它封装成工具类就不再困难了。