本文动态增加字节码是直接使用的ASM,有关ASM的内容可以看下我之前的一篇文章:ASM 操作字节码初探
话不多说,先看本次想实现怎样的效果:
public static class Bazhang {
private long f(int n, String s, int[] arr) {
return 0;
}
private void hi(double a, List<String> b) {
}
public void newFunc(String str) {
System.out.println(str);
for (int i = 0; i < 100; i++) {
if (i % 10 == 0) {
System.out.println(i);
}
}
}
}
这是一个自定义的类,里面有三个方法,我需要在不改变原有写好的代码的基础上,往newFunc(String str)这个方法内收尾增加两个方法,打印输入start和end,也就是如下:
public static void newFunc(String str) {
System.out.println("========start=========");
System.out.println(str);
for (int i = 0; i < 100; i++) {
if (i % 10 == 0) {
System.out.println(i);
}
}
System.out.println("========end=========");
}
那么我们直接操作字节码,往方法体内首尾增加相应字节码就好了。
这里先安利一个IntelliJ的插件,叫做ASM Bytecode Outline,他可以直接显示java代码对应的字节码和ASM相应的操作代码,这个插件一定程度上帮助我们写接下来的代码。
下面进入正题,手摸手开始:
自定义一个ClassVisitor
static class TestClassVisitor extends ClassVisitor {
public TestClassVisitor(final ClassVisitor cv) {
super(Opcodes.ASM5, cv);
}
@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
if (cv != null) {
cv.visit(version, access, name, signature, superName, interfaces);
}
}
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
//如果methodName是newFunc,则返回我们自定义的TestMethodVisitor
if ("newFunc".equals(name)) {
MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
return new TestMethodVisitor(mv);
}
if (cv != null) {
return cv.visitMethod(access, name, desc, signature, exceptions);
}
return null;
}
}
确保只有newFunc方法才会走我们的套路。
自定义TestMethodVisitor
static class TestMethodVisitor extends MethodVisitor {
public TestMethodVisitor(MethodVisitor mv) {
super(Opcodes.ASM5, mv);
}
@Override
public void visitCode() {
//方法体内开始时调用
super.visitCode();
}
@Override
public void visitInsn(int opcode) {
//每执行一个指令都会调用
super.visitInsn(opcode);
}
}
我们注释了两个方法,一个是visitCode(),一个是visitInsn(int opcode),这两个方法一个为我们接下来插入start,一个插入end。
使用ASM Bytecode Outline插件做简单分析
这一步并不是必须的,如果你对字节码足够的熟练,完全可以随便撸。
先随便写个类,然后实现我们最后需要变成的newFunc(String str)方法,然后用插件查看ASMifield,可以得到如下片段:
// 1
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("========start=========");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
...
// 2
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitVarInsn(ALOAD, 0);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
...
// 3
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("========end=========");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
...
// 4
mv.visitInsn(RETURN);
我把中间很多代码给删掉了,这样结构就很清楚了,可以看到我留下了四个部分:
注释1:这是我们要插入的System.out.println(“========start=========”);转成相对应的ASM提供的方法,其中visitLdcInsn可以在JVM指令表中查到Ldc表示将int, float或String型常量值从常量池中推送至栈顶,那么其实ASM提供了方法让我们继续通过java代码转成相对应的字节码,那么注释1中的三行代码所对应的字节码就是:
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
LDC "========start========="
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
注释2:这是原方法内的代码System.out.println(str);,表明我们注释1确实是插在了newFunc方法体的最上方,其中mv.visitVarInsn(ALOAD, 0);的ALOAD对应JVM指令的意思是将指定的引用类型本地变量推送至栈顶,因为这个String是参数传过来的。
注释3:显而易见,转换前的java代码就是我们的System.out.println(“========end=========”);,这里不再赘述。
注释4:RETURN对应着从当前方法返回void
这样一来,ASM Bytecode Outline插件帮我们直接生成了相对应的ASM代码,那么我们接下来粘贴复制就行了。
插头
先从简单的开始,插头部。我们在之前的自定义TestMethodVisitor中已经复写了visitCode方法,那么我们就在代码注释的地方插入ASM代码:
@Override
public void visitCode() {
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("========start=========");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
super.visitCode();
}
插尾
这个比插头复杂点,但是也很简单,visitInsn方法会在每个指令被执行时都会调用,所以我们需要判断指令是否到了RETURN即可,在RETURN前插入我们的代码:
if (opcode == Opcodes.RETURN) {
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("========end=========");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
}
校验
这样一来,我们的头尾的插好了,校验一番:
public static void main(String[] args) throws Exception {
ClassReader cr = new ClassReader(Bazhang.class.getName());
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);
ClassVisitor cv = new TestClassVisitor(cw);
cr.accept(cv, Opcodes.ASM5);
// 获取生成的class文件对应的二进制流
byte[] code = cw.toByteArray();
//将二进制流写到out/下
FileOutputStream fos = new FileOutputStream("out/Bazhang223.class");
fos.write(code);
fos.close();
}
可以看到如下结果:
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
import java.util.List;
public class Test$Bazhang {
public Test$Bazhang() {
}
private long f(int n, String s, int[] arr) {
return 0L;
}
private void hi(double a, List<String> b) {
}
public void newFunc(String str) {
System.out.println("========start=========");
System.out.println(str);
for(int i = 0; i < 100; ++i) {
if(i % 10 == 0) {
System.out.println(i);
}
}
System.out.println("========end=========");
}
}
看来导出的.class是没问题了,那么利用反射来执行一下我们的修改类:
Test loader = new Test();
Class hw = loader.defineClass("Test$Bazhang", code, 0, code.length);
Object o = hw.newInstance();
Method method = o.getClass().getMethod("newFunc", String.class);
method.invoke(o, "巴掌菜比");
最后控制台打印出来的结果是:
========start=========
巴掌菜比
0
10
20
30
40
50
60
70
80
90
========end=========
尾语
这样一来我们的目的都达到了,之后便可以更加进一步做点有意思的事,比如统计方法耗时。
整的来说,修改字节码达到本次想要的效果是个很cool的方式,很多时候我们是可以通过hook或者动态代理来做一些类似本文的操作,那么就要结合实际情况进行选择了。