上篇文章《开发服务提供者框架的代码模块架构方式》,说到使用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
9import org.apache.ibatis.annotations.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
public Symbol {
}需要知道的执行流程
spring启动时扫描所有的class文件,找出标记了Dao注解的Dao接口,即找出标记了
@Symbol
的MapperDao
接口通过Dao接口生成mybatis mapper代理类
现假设系统开发时并不确定使用的Dao注解是什么,即不知道是@Symbol
,而是通过外部配置类名online.dinghuiye.asm.Symbol
的方式指定Dao注解。由于一般情况下类的注解解析是在编译期完成的,运行期无法常规方法修改注解,也就是说,如果通过外部配置指定注解类名,那么肯定要先启动spring后才能获取到类名,而这个时候又不能通过常规方法将Dao接口的Dao注解修改为指定的类名接口。此时就需要用到动态生成或修改class字节码文件的技术,因此采用以下思路方法:
spring启动时先获取到外部配置的注解类名
spring启动class扫描前,通过ASM创建Dao接口的子Dao接口class字节码
将第2步中的class字节码表示的接口标记上第1步中获取到的Dao注解
生成class文件
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接口需要标注@Symbol
Dao注解)。如果自行扫描时,扫描到的Dao接口就是ASM生成的子Dao接口,那么跳过。方案:除了对子接口标记指定注解
@Symbol
,还需要标记一个指示注解,案例设定为online.dinghuiye.asm.AutoGeneratedMapper
,如果Dao接口有该注解同样不生成子Dao接口。1
2
3
4
public 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的包了。
创建希望生成的Java类示例,即Dao接口的子Dao接口,并且拥有注解
@Symbol
,即类中包含希望拥有的信息。如下代码,当前希望生成SymbolDao
接口,继承MapperDao
,并且拥有@Mapper
、@Symbol
、@AutoGeneratedMapper
注解。1
2
3
4
5
public interface SymbolDao extends MapperDao {
}调用ASM的代码生成器工具类
ASMifier
生成代码。ASMifier
提供main
方法执行,可以使用java命令调用,也可以在IDE中直接调用main方法,参数传入1中创建的示例类的类名。1
2
3
4
5
6
7import jdk.internal.org.objectweb.asm.util.ASMifier;
// 其余代码名略
public void testGenModle() throws Exception {
ASMifier.main(new String[]{"online.dinghuiye.asm.SymbolDao"});
}执行完毕后,会在控制台打印出生成的代码,复制粘贴到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
32import 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
对象cw
,cw
提供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对象或类型对象就会报错。
整理生成的代码,封装为生成class文件的方法。将该删除的变量、行删除,该写明确的代码写明确,尽量使用常量和封装方法,而不是字符串。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24import 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
7Enumeration<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
9JarURLConnection 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; }
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生成的时候并没有做检查,就不一定正确了。
后来猜测ClassWriter
的visit
方法中,最后一个参数interfaces
的元素会不会是Descriptor
,而不是InternalName
,于是更改,发现问题解决了。
但是,ASMifier
生成的代码明明是InternalName
,于是又改回来再次测试,然而又不报错了… 感觉被耍了… 不知道问题何在,问题也不再复现… 也许和某一个不确定的行为或者jvm或者生成的class有关吧…