背景
ASM是java语言中最为广泛使用的插装框架,其优点在于可以动态地在运行时改变java系统的行为,加入我们自己的逻辑。在软件测试领域应用广泛。但是其使用难度很高,一方面使用asm框架需要对java底层知识有较高的了解,另一方面网上关于asm的资料较少出现问题经常难以搜索到解决方案。参考资料[1]-[3]提供了一些关于asm的基础介绍。
使用ASM时一个非常大的问题在于我们往往需要将自己的少量逻辑插入到复杂的目标系统中进行测试,而我们对目标系统却没有很深的理解。这样当由于目标系统一些特性使得我们的插装代码出错时,我们就很容易处于一种无处下手的状态。
我在使用ASM插装一个不算太复杂的框架raft-java时,创建ClassVisitor总是会在构造函数出爆出IllegalArgumentException,这个问题困扰了我两天,最后发现是由于系统中多次引用asm后声明版本号不一致导致。在此记录下来以供其他的asm初学者进行参考。
问题描述
使用ASM插装raft-java时,遇到问题如下:
raft-java插装点:ServerMain类的main方法的server.start()语句前。
在server.start()执行前插入我们自己的函数逻辑。
插装逻辑如下图。
ClassReader cr = new ClassReader(input);
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);
RaftDownCLassVIsitor rcv = new RaftDownCLassVIsitor(ASM9, cw);
cr.accept(rcv, 0);
byte[] contents = cw.toByteArray();
其中RaftDownClassVisitor是我自己写的插装类,这里只摘录其构造函数和visitMethod函数。
public RaftDownCLassVIsitor(int api, ClassVisitor classVisitor) {
super(api, classVisitor);
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
logger.info("visitMethod" + name);
MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
if (className.equals("com/github/wenweihu86/raft/example/server/ServerMain")) {
logger.info("visit com/github/wenweihu86/raft/example/server/ServerMain");
RaftDownMethodVisitor rmv = new RaftDownMethodVisitor(ASM9, mv);
rmv.setIndex(10);
return rmv;
}
return mv;
}
其中RaftDownMethodVisitor类是我们自己写的MethodVisitor插装类。上面的visitMethod方法意思为当程序执行到ServerMain类的函数时,其返回的函数会是我们的RaftDownMethodVisitor提供的修改过的函数。
RaftDownMethodVisitor中则进一步判断,当调用server.start()语句时,先执行一些我们的逻辑。RaftDownMethodVisitor和本篇博客所阐释的问题关系不大,这里不再展开。
使用上述代码进行实验时,系统报错:
解决过程
首先根据堆栈信息,查看ClassVisitor的构造函数。
public ClassVisitor(final int api, final ClassVisitor classVisitor) {
if (api != Opcodes.ASM9
&& api != Opcodes.ASM8
&& api != Opcodes.ASM7
&& api != Opcodes.ASM6
&& api != Opcodes.ASM5
&& api != Opcodes.ASM4
&& api != Opcodes.ASM10_EXPERIMENTAL) {
throw new IllegalArgumentException("Unsupported api " + api);
}
if (api == Opcodes.ASM10_EXPERIMENTAL) {
Constants.checkAsmExperimental(this);
}
this.api = api;
this.cv = classVisitor;
}
可以看到构造函数确实可以抛出IllegalArgumentException。但是从源码来看我们的输入并不应该触发这个异常才对。除此之外,从源码来看,抛出的IllegalArgumentException应该有“Unsupported api”的信息才对,但是我却打印不出来该信息。
于是上网上寻找资料(参考资料[4]-[7])。在过程中,发现一些人在使用asm插装springboot, gwt等项目时也产生过类似问题。解决方案则大多时将目标系统或者asm调整至固定版本。这引发人怀疑,会不会我们也是asm的版本出了问题呢?
于是我使用mvn dependency:tree命令查看了raft-java的依赖。
果然找到了raft-java自身依赖的asm,而且还是间接依赖,这样的依赖单纯看源码几乎不可能看出。raft-java本身使用了5.2版本的asm,而我们的插装代码则使用了9.1版本的asm并在创建RaftDownClassVisitor和RaftDownMethodVisitor时将asm版本指定成了9.(RaftDownCLassVIsitor rcv = new RaftDownCLassVIsitor(ASM9, cw);
)。将这里传入的参数改为ASM5即可消除错误。
解决方法
使用mvn dependency:tree
命令检查目标系统有没有使用asm依赖。若有,则在创建ClassVisitor和MethodVisitor实例时需要注意传入的ASM版本号参数要和目标系统依赖的asm版本一致。
RaftDownCLassVIsitor rcv = new RaftDownCLassVIsitor(ASM5, cw);
RaftDownMethodVisitor rmv = new RaftDownMethodVisitor(ASM5, mv);
一些经验教训
使用try…catch…捕捉异常
一些目标系统会使用全局性的try…catch…机制,导致即使代码在某一步出错,但是系统不会在这里报错。继续运行的系统可能会产生错误的行为,但是让人难以和出错的代码联系起来。
在本次debug过程中,ClassVisitor的构造函数的异常只有在我将插装代码改为如下形式后才会被打印出来。
ClassReader cr = new ClassReader(input);
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);
try {
RaftDownCLassVIsitor rcv = new RaftDownCLassVIsitor(ASM9, cw);
// RaftDownCLassVIsitor rcv = new RaftDownCLassVIsitor(ASM5, cw);
cr.accept(rcv, 0);
} catch (Exception e) {
logger.info(e.getMessage());
e.printStackTrace();
}
byte[] contents = cw.toByteArray();
因此,当我们怀疑某一步出了问题时,应注意主动加上try…catch…使得异常可以被我们自己捕获。
注意vscode和mvn dependency:tree展示的依赖的差别
使用vscode的java插件,我们也可以看到一个项目的依赖
但是可以发现,vscode中展示出来的目标系统依赖的asm的版本(6.0_alphe)和mvn dependency:tree
展示出来的asm的版本(5.2)并不一致。
在本文所展示的例子中,只有使用mvn dependency:tree
展示出来的asm-5.2版本才能阻止错误发生。
讨论
在本文所展示的例子中,目标系统raft-java是maven项目,而我所写的插装代码则是gradle项目。我不清楚目标项目和插装代码的构建工具不同是否会导致依赖混淆。换言之,我不确定假如我使用maven作为插装代码的构建工具文中所提到的bug还会不会产生。此问题留待以后研究。