工程笔记:ASM在运行时生成mybatis的mapper(字节码工具)

上篇文章《开发服务提供者框架的代码模块架构方式》,说到使用ASM动态生成接口的class文件,来实现spring环境下动态生成拥有Dao接口注解(spring-mybatis扫描Dao接口的注解,下文统称Dao注解)的mybatis Dao接口(下文统称Dao接口),这篇文章记录如何实现。

需要准备的class信息

  • 定义Dao接口,案例使用online.dinghuiye.asm.MapperDao

    1
    2
    3
    4
    5
    6
    7
    8
    9
    import org.apache.ibatis.annotations.Mapper;

    @Mapper
    public interface MapperDao {
    List<?> find();
    void insert(Object obj);
    void update(Object obj);
    void delete(Object id);
    }
  • 定义Dao注解,案例使用online.dinghuiye.asm.Symbol

    1
    2
    3
    4
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    public @interface Symbol {
    }

    需要知道的执行流程

  1. spring启动时扫描所有的class文件,找出标记了Dao注解的Dao接口,即找出标记了@SymbolMapperDao接口

  2. 通过Dao接口生成mybatis mapper代理类

现假设系统开发时并不确定使用的Dao注解是什么,即不知道是@Symbol,而是通过外部配置类名online.dinghuiye.asm.Symbol的方式指定Dao注解。由于一般情况下类的注解解析是在编译期完成的,运行期无法常规方法修改注解,也就是说,如果通过外部配置指定注解类名,那么肯定要先启动spring后才能获取到类名,而这个时候又不能通过常规方法将Dao接口的Dao注解修改为指定的类名接口。此时就需要用到动态生成或修改class字节码文件的技术,因此采用以下思路方法:

  1. spring启动时先获取到外部配置的注解类名

  2. spring启动class扫描前,通过ASM创建Dao接口的子Dao接口class字节码

  3. 将第2步中的class字节码表示的接口标记上第1步中获取到的Dao注解

  4. 生成class文件

  5. spring扫描第4步生成的class文件生成mapper

第2步中创建Dao接口的子接口是为了保持原始信息,第5步中spring只会将有指定Dao注解的class生成mapper,因此原始的Dao接口是不会生成mapper的

一些细节问题

  • 在spring扫描组件前就需要自行扫描Dao接口,判断是否需要生成子Dao接口。方案:自行实现对base package进行class文件扫描,通过ASM分析Dao接口的Dao注解情况,如果接口拥有注解@Mapper并且没有注解@Symbol,那么需要生成子Dao接口(@Mapper可以是mybatis3.4.0中提供的Dao接口,也可以自行定义,主要是用于标记该Dao接口需要标注@SymbolDao注解)。

  • 如果自行扫描时,扫描到的Dao接口就是ASM生成的子Dao接口,那么跳过。方案:除了对子接口标记指定注解@Symbol,还需要标记一个指示注解,案例设定为online.dinghuiye.asm.AutoGeneratedMapper,如果Dao接口有该注解同样不生成子Dao接口。

    1
    2
    3
    4
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    public @interface AutoGeneratedMapper {
    }
  • 程序停止时应该要清除生成的子Dao接口class文件。方案:缓存生成的class文件的File对象列表,监听服务状态,服务停止前执行清理工作。

详细案列

ASM 4.0及以上版本提供了一个工具类org.objectweb.asm.util.ASMifier,该工具类可以生成创建class文件的代码。因为JDK8中在jdk.internal.org.objectweb.asm包中集成了ASM,所以案例使用JDK8自带的ASM组件jdk.internal.org.objectweb.asm.util.ASMifier,就不用引入额外的ASM的包了。

  1. 创建希望生成的Java类示例,即Dao接口的子Dao接口,并且拥有注解@Symbol,即类中包含希望拥有的信息。如下代码,当前希望生成SymbolDao接口,继承MapperDao,并且拥有@Mapper@Symbol@AutoGeneratedMapper注解。

    1
    2
    3
    4
    5
    @Mapper
    @Symbol
    @AutoGeneratedMapper
    public interface SymbolDao extends MapperDao {
    }
  2. 调用ASM的代码生成器工具类ASMifier生成代码。ASMifier提供main方法执行,可以使用java命令调用,也可以在IDE中直接调用main方法,参数传入1中创建的示例类的类名。

    1
    2
    3
    4
    5
    6
    7
    import jdk.internal.org.objectweb.asm.util.ASMifier;

    // 其余代码名略

    public void testGenModle() throws Exception {
    ASMifier.main(new String[]{"online.dinghuiye.asm.SymbolDao"});
    }
  3. 执行完毕后,会在控制台打印出生成的代码,复制粘贴到IDE中,并整理格式,生成的代码如下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    import jdk.internal.org.objectweb.asm.*;

    // 其余代码名略

    public class SymbolDaoDump implements Opcodes {

    public static byte[] dumpInit() throws Exception {

    ClassWriter cw = new ClassWriter(0); // 创建cw对象
    FieldVisitor fv;
    MethodVisitor mv;
    AnnotationVisitor av0;

    cw.visit(52, ACC_PUBLIC + ACC_ABSTRACT + ACC_INTERFACE, "online/dinghuiye/asm/SymbolDao", null, "java/lang/Object", new String[]{"online/dinghuiye/asm/MapperDao"});

    {
    av0 = cw.visitAnnotation("Lorg/apache/ibatis/annotations/Mapper;", true);
    av0.visitEnd();
    }
    {
    av0 = cw.visitAnnotation("Lonline/dinghuiye/asm/Symbol;", true);
    av0.visitEnd();
    }
    {
    av0 = cw.visitAnnotation("Lonline/dinghuiye/asm/AutoGeneratedMapper;", true);
    av0.visitEnd();
    }
    cw.visitEnd();

    return cw.toByteArray();
    }
    }

    不熟悉ASM会不理解生成的代码的含义,可以先了解一下ASM的代码原理。这里简要介绍下用到的方法,ASM使用的访问者模式,因此会有很多类似visitXXX的方法来操作类和类信息对象。

    ASM创建类时,先创建ClassWriter对象cwcw提供visit方法可以访问类信息,cw提供visitAnnotation可以访问注解信息。

  • 创建cw对象

    1
    ClassWriter cw = new ClassWriter(0); // 参数取0即可
  • visit方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /**
    * @param version java版本号,使用的常量表示,52代表1.8,常量在类org.objectweb.asm.Opcodes中
    * @param access 类修饰,同样以Opcodes常量表示
    * @param name 生成的类的类名
    * @param signature 泛型签名,没有泛型就为null
    * @param superName 超类类型的InternalName,一般为java/lang/Object,如果name是java.lang.Object就为null
    * @param interfaces 继承或实现的接口的InternalName的数组,没有则为null
    */
    public void visit(int version, int access, String name, String signature,
    String superName, String[] interfaces)

    获取InternalName的方法为jdk.internal.org.objectweb.asm.Type.getInternalName(Class<?> clazz)

  • visitAnnotation方法

    1
    2
    3
    4
    5
    /**
    * @param desc 注解类的Descriptor名称,比如@Symbol就是Lonline/dinghuiye/asm/Symbol;
    * @param visible 是否运行时可见,一般为true
    */
    public AnnotationVisitor visitAnnotation(String desc, boolean visible)

    获取Descriptor名称的方法为jdk.internal.org.objectweb.asm.Type.getDescriptor(Class<?> clazz)

    当创建接口时,接口的修饰为public abstract interface,使用Opcodes提供的常量ACC_PUBLIC + ACC_ABSTRACT + ACC_INTERFACE即可表示。

    值得注意的是,建议把ASM的源码下载到本地,因为源码才有注释,并且方法签名中的参数名具有相当重要的指导意义,比如上述visit中要求传入的参数名为name,就是要求传入InternalName,而visitAnnotation中要求传入的参数名为desc,就是要求传入Descriptor,如果传入错误的名称,那么将会得到莫名其妙的错误,更甚者,代码生成不会报错,生成class文件也不会报错,但是一旦使用class创建Class对象或类型对象就会报错。

  1. 整理生成的代码,封装为生成class文件的方法。将该删除的变量、行删除,该写明确的代码写明确,尽量使用常量和封装方法,而不是字符串。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    import jdk.internal.org.objectweb.asm.*;

    // 其余代码略

    public static void genClassToFile(File out) throws Exception {

    ClassWriter cw = new ClassWriter(0);
    cw.visit(
    Opcodes.V1_6,
    Opcodes.ACC_PUBLIC + Opcodes.ACC_ABSTRACT + Opcodes.ACC_INTERFACE,
    "online/dinghuiye/asm/SymbolDao",
    null,
    Type.getInternalName(Object.class),
    new String[]{Type.getInternalName(MapperDao.class)});

    cw.visitAnnotation(Type.getDescriptor(Mapper.class), true).visitEnd();
    cw.visitAnnotation(Type.getDescriptor(Symbol.class), true).visitEnd();
    cw.visitAnnotation(Type.getDescriptor(AutoGeneratedMapper.class), true).visitEnd();
    cw.visitEnd();

    try (FileOutputStream fos = new FileOutputStream(out);) {
    fos.write(cw.toByteArray());
    }
    }

    ASM生成class文件就完成了,其他步骤,如自行扫描class文件,判断class是否为需要生成子接口的Dao接口,以及spring启动前操作等就不赘述编码过程了,只将要点代码列出。

  • 扫描class文件需要用ClassLoader.getResource(basePackage)方法,该方法可以扫描所有的class文件,包括不同的包以及jar包中的class。要点代码如下。

    1
    2
    3
    4
    5
    6
    7
    Enumeration<URL> urlEnumeration = Thread.currentThread().getContextClassLoader()
    .getResources(basePackage.replace(".", "/"));
    while (urlEnumeration.hasMoreElements()) {
    URL url = urlEnumeration.nextElement();
    String protocol = url.getProtocol();
    // 判断protocol为file或jar,再分别进行深层扫描,file扫描比较常规,不赘述
    }

    对jar包进行扫描

    1
    2
    3
    4
    5
    6
    7
    8
    9
    JarURLConnection connection = (JarURLConnection) url.openConnection();
    Assert.notNull(connection);
    JarFile jarFile = connection.getJarFile();
    Assert.notNull(jarFile);
    Enumeration<JarEntry> jarEntryEnumeration = jarFile.entries();
    while (jarEntryEnumeration.hasMoreElements()) {
    JarEntry entry = jarEntryEnumeration.nextElement();
    // 判断entry是否为要求的文件,代码略
    }
  • ASM读取class的注解信息,方案为自定义ClassVisitor,并重写visitAnnotation,如果读取到的注解为要求注解就记录,最后判断class是否符合要求。

    自定义ClassVisitor

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // asm读取注解的visitor
    private static class MyClassVisitor extends ClassVisitor {

    // 标记是否为满足要求的class
    private int flag = 0;

    // api是ASM的版本,案例用的v5
    public MyClassVisitor(int api) { super(api); }

    public void mapper() { flag |= 0x0001; }
    public void autoGen() { flag |= 0x0010; }
    public void requireAnno() { flag |= 0x0100; }
    public boolean satisfyToGen() { return flag == 0X0001; }

    @Override
    public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
    if (desc.equals(Type.getDescriptor(Mapper.class))) mapper();
    if (desc.equals(Type.getDescriptor(AutoGeneratedMapper.class))) autoGen();
    if (desc.equals(Type.getDescriptor(ParamConfig.getMapperAnnoClass()))) requireAnno();
    return super.visitAnnotation(desc, visible);
    }
    }

    判断class是否符合要求

    1
    2
    3
    4
    5
    6
    7
    // 检测mapper接口是否需要创建子接口
    private static boolean mapperSatisfyToGen(String className) {
    ClassReader cr = new ClassReader(className);
    MyClassVisitor mcv = new MyClassVisitor(Opcodes.ASM5); // asm v5
    cr.accept(mcv, ClassReader.SKIP_DEBUG);
    return mcv.satisfyToGen();
    }
  • spring启动前操作,使用spring提供的org.springframework.context.ApplicationContextInitializer接口就可以完成,该接口提供方法initialize可以在spring扫描组件前执行,此时就可以生成Dao接口的子接口。比较简单,不赘述。

莫名其妙的坑

在spring扫描组件后,生成mapper时报错,报错信息是无法找到生成的子Dao接口的父接口(Dao接口),即NoClassDefFoundError,意思是说生成的Dao接口编译时父接口存在,但是在运行时父接口不存在。

而实际上父接口又的确自始至终是存在的,分析原因,子Dao接口是ASM生成的,而jvm能加载时应该能说明是编译通过了的,但是jvm又找不到它的父接口,那么有可能是生成子Dao接口的class字节码时将父接口定义错了(比如类名什么的)。如果是java编译是会做检查的,就不存在类名错误的情况,而用ASM生成的时候并没有做检查,就不一定正确了。

后来猜测ClassWritervisit方法中,最后一个参数interfaces的元素会不会是Descriptor,而不是InternalName,于是更改,发现问题解决了。

但是,ASMifier生成的代码明明是InternalName,于是又改回来再次测试,然而又不报错了… 感觉被耍了… 不知道问题何在,问题也不再复现… 也许和某一个不确定的行为或者jvm或者生成的class有关吧…