Bytecode & ASM - AOP Implementation

A very simple example of AOP is involved in the previous article. As a member of one of the commonly used frameworks in JAVA, AOP will be exposed to the concept of AOP whether it is engaged in the back-end or Android. As users, do we have any thoughts? How is technology like AOP implemented? If you have never thought about it, this article will give a detailed implementation of a set of AOP libraries, including common AOP implementations such as pre, post, surround, normal exit, and abnormal exit. Let's start with the simplest consideration, how the prerequisites are implemented, and give yourself some time to think first. I think the easiest and quickest idea is to record the current class and method information, which can be achieved by invoking the relevant method calls through ASM before the execution of the method that needs to be cut in. Substitute other functions into thinking according to the same method, it is not difficult to find that this method seems to be quite suitable, but the surround does not seem to work. Because of the particularity of the surround, do we need to find a new way? Give you some implementation thinking, and then come back and give the answer. The solution to the appeal is to implement the cut-in logic through method invocation, so can we record the operation code and operand corresponding to the method and insert it in the appropriate actual state? The answer is of course yes. We need to collect all the opcodes and operands for pre-position, post-position, normal exit, and abnormal exit, but the wrapping seems to be different, so both this solution and the above solution seem to need to solve the problem of wrapping. Let's not talk about the relevant solutions here, let's directly start implementing other functions, and finally we will give the answer when we implement the wraparound. Of course, you may already have the answer if you are smart.

Annotation Definition

First of all, we should define the annotations related to pre, post, surround, normal exit, and abnormal exit, and the annotation needs to carry the data of the class and method that needs to be matched

front

@Retention(RetentionPolicy.CLASS)
@Target(ElementType.METHOD)
public @interface Before {
    String clzName();
    String methodDesc();
}
复制代码

rear

@Retention(RetentionPolicy.CLASS)
@Target(ElementType.METHOD)
public @interface After {
    String clzName();
    String methodDesc();
}
复制代码

surround

@Retention(RetentionPolicy.CLASS)
@Target(ElementType.METHOD)
public @interface Around {
    String clzName();
    String methodDesc();
}
复制代码

Exit normally

@Retention(RetentionPolicy.CLASS)
@Target(ElementType.METHOD)
public @interface AfterReturning {
    String clzName();
    String methodDesc();
}
复制代码

abnormal

@Retention(RetentionPolicy.CLASS)
@Target(ElementType.METHOD)
public @interface AfterThrowing {
    String clzName();
    String methodDesc();
}
复制代码

The above annotations all contain clzNameand methodDescare used to match classes and methods. Here we are based on the basic type description, which clzNameshould match the fully qualified name of the class, and methodDescmatch the method name and method signature. In addition, we need an annotation tag class to facilitate us to collect the above annotations

@Retention(RetentionPolicy.CLASS)
@Target(ElementType.TYPE)
public @interface Aop{}
复制代码

通过以上的注解定义已经形成AOP库的核心类了,在使用上我们需要在类头通过@Aop标记,内部方法通过@Before,@After,@Around,@AfterReturning,@AfterThrowing标记,例如:

@Aop
public class TestAOP {
    @Around(clzName = "com/river/easyaop/Test", methodDesc = "say()")
    public void say() {
        System.out.println("hello2");
    }

    @Before(clzName = "com/river/easyaop/Test", methodDesc = "hello()")
    public void hello() {
        System.out.println("before");
    }

    @After(clzName = "com/river/easyaop/Test", methodDesc = "run()")
    public void run() {
        System.out.println("after");
    }

    @AfterReturning(clzName = "com/river/easyaop/Test", methodDesc = "ret()")
    public void ret() {
        System.out.println("ret");
    }

    @AfterThrowing(clzName = "com/river/easyaop/Test", methodDesc = "ret()")
    public void ret2() {
        System.out.println("error");
    }

    @Before(clzName = "com/river/easyaop/Test", methodDesc = "callback()")
    public void callback() {
        System.out.println("callback before");
    }

    @After(clzName = "com/river/easyaop/Test", methodDesc = "onCreate()")
    public void onCreate() {
        System.out.println("onCreate");
    }
}
复制代码

通过观察我们发现@Around好像哪里不对,环绕注解我们需要在原函数的前后都炽入代码,而现在这个样子并不能控制对原函数的调用。由此我们新增一个接口表示对原函数的调用

public interface Method {
    void invoke();
}
复制代码

通过该接口进一步修改上面被@Around标记的方法

@Aop
public class TestAOP {
    @Around(clzName = "com/river/easyaop/Test", methodDesc = "say()")
    public void say(Method method) {
        System.out.println("hello1");
        method.invoke();
        System.out.println("hello2");
    }
}
复制代码

目前为止我们已经写好了给使用者的相关代码了。我们再次需要思考,我们应该如何处理这些注解并且如何实现AOP呢?这里呢我给出三个步骤,第一收集注解相关的信息,第二应对收集的注解信息去匹配需要处理的类的方法,第三就是将匹配成功的方法进行处理,将需要炽入的代码在合适的时机进行炽入。有了三个步骤那么我们就一一去实现它。

收集注解

首先我们需要对类进行分析,该类是否被@Aop标记,如果标记我们需要进一步去分析类的方法,将类的方法逐一分析方法是否被我们的注解标记,如果标记那么我们需要将注解信息收集。这里我们关心的数据为切入类的全限定名、切入方法的方法名及方法描述、待炽入的操作码及操作数和切入注解类

@Data
public class MethodPointCut {
    //切入类全限定名
    private String clzName;
    //切入方法名及描述
    private String methodDesc;
    //前置炽入操作码及操作数
    private List<Insn> beforeInstructions;
    //后置炽入操作码及操作数
    private List<Insn> afterInstructions;
    //注解类
    private Class<?> annoClass;
}
复制代码

这个类将是我们这个AOP库的核心类,维护这核心数据,而这个核心类中最为重要的便是携带操作码及操作数的两个集合beforeInstructionsafterInstructions,这里需要两个集合是因为考虑到了环绕注解需要我们分别存储方法执行前及执行后的操作码及操作数。

public abstract class Insn {
	private int opcode;
    
    public Insn(int opcode) {
    	this.opcode = opcode;
    }
    
    public int getOpcode() {
    	return opcode;
    }
    
    public abstract void accept(MethodVisitor methodVisitor);
}
复制代码

Insn抽象类表示不同的操作码,而不同的操作码有不同的子类具体实现细节。accept方法用于将该操作码及操作数炽入到对应的方法体内。介绍完我们收集的存储结构后我们正式开始收集相关的代码吧。

public class CollectAnnoClassVisitor extends ClassVisitor {
    private String clzName;
    private boolean needReduce;
    private ArrayList<MethodPointCut> methodPointCuts = new ArrayList<>();

    public CollectAnnoClassVisitor(ClassVisitor classVisitor) {
        super(Opcodes.ASM6, classVisitor);
    }

    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        super.visit(version, access, name, signature, superName, interfaces);
        clzName = name;
    }


    @Override
    public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
        needReduce = descriptor.equals(Type.getDescriptor(Aop.class));
        return super.visitAnnotation(descriptor, visible);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
        if (needReduce) {
            mv = new InstructionsCollectMethodNode(this, access, name, descriptor, signature, exceptions);
        }
        return mv;
    }

    public ArrayList<MethodPointCut> getMethodPointCuts() {
        return methodPointCuts;
    }

    public void setMethodPointCuts(ArrayList<MethodPointCut> methodPointCuts) {
        this.methodPointCuts = methodPointCuts;
    }
}
复制代码

通过visitAnnotation方法我们判断注解的类型描述是否和Aop相同,相同代表这个类被@Aop标记,那么我们需要对方法进行进一步的处理。这里的代码不难发现有一处BUG,就是如果我们的类被多个注解标记按目前的写法只要不是最后访问的注解为@Aop就会出现Bug,所以如果需要严谨些你可以自行修改。 对于方法操作码操作数的收集是核心逻辑,我们知道ASM通过MethodVisitor的各种visitXXX方法描述了不同操作码及操作数。我们便可以依据此来实现不同的Insn实现类来收集操作码操作数。比如方法MethodVisitor#visitVarInsn用于访问方法局部变量表,我们根据此方法抽象出对应的访问局部变量表的VarInsn类用于存储同样的功能,在ASM调用该方法时我们只需创建一个VarInsn的实例并添加进集合。

public class VarInsn extends Insn {
    private int var;

    public VarInsn(int opcode, int var) {
        super(opcode);
        this.var = var;
    }

    @Override
    public void accept(MethodVisitor methodVisitor) {
        methodVisitor.visitVarInsn(getOpcode(), var);
    }
}
复制代码

MethodVisitor#visitXX的其他方法如法炮制创建对应的XXInsn。幸运的是ASM框架提供了类似的功能,无需我们一个一个去实现。然而更令人惊喜的是收集操作数及操作码的功能我们也可以通过ASM提供的类。惊不惊喜?意不意外?这里我们就直接通过使用ASM提供的类来实现我们的功能了。MethodNode是ASM中树API的实现的一员,树API是ASM基于ASM核心API上层封装的一层。读到这里建议打开IDE阅读下MethodNode的源码,源码不多,逻辑大致类似。通过MethodNode我们可以轻松的获取我们需要的操作码及操作数。通过阅读源码我们发现改类仅仅提供了方法体所有的操作码及操作数,那么好像不加特殊的处理我们好像实现不了相应的环绕逻辑呀!遇到问题我们不妨再先思考下应如何处理呢?聪明的你不知是否发现我们加入的Method#invoke这时就是这个问题的钥匙,在ASM访问方法调用的时候我们通过拦截判断是否为调用Method#invoke,以这个节点为时间点是不是就区分出来了相应的前置和后置操作码操作数了呀?答案无疑是肯定的。

public class InstructionsCollectMethodNode extends MethodNode {
    private CollectAnnoClassVisitor cv;
    private boolean isMatchAnno = false;
    private MethodPointCut methodPointCut = new MethodPointCut();

    public InstructionsCollectMethodNode(CollectAnnoClassVisitor cv, int access, String name, String descriptor, String signature, String[] exceptions) {
        super(Opcodes.ASM6, access, name, descriptor, signature, exceptions);
        this.cv = cv;
    }

    @Override
    public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
        isMatchAnno = Util.matcherAnno(descriptor);

        AnnotationVisitor av = super.visitAnnotation(descriptor, visible);

        if (isMatchAnno) {
            methodPointCut.setAnnoClass(Util.matcherAnnoClass(descriptor));
            av = new AnnotationVisitor(Opcodes.ASM6, av) {
                @Override
                public void visit(String name, Object value) {
                    super.visit(name, value);
                    try {
                        name = "set" + name.substring(0, 1).toUpperCase() + name.substring(1);
                        java.lang.reflect.Method setField = methodPointCut.getClass().getDeclaredMethod(name, String.class);
                        setField.invoke(methodPointCut, value);
                    } catch (NoSuchMethodException e) {
                        e.printStackTrace();
                    } catch (IllegalAccessException e) {
                        e.printStackTrace();
                    } catch (InvocationTargetException e) {
                        e.printStackTrace();
                    }
                }
            };
        }

        return av;
    }

    @Override
    public void visitMethodInsn(int opcodeAndSource, String owner, String name, String descriptor, boolean isInterface) {
        if (isMatchAnno && opcodeAndSource == Opcodes.INVOKEINTERFACE && owner.equals(Type.getInternalName(Method.class)) && name.equals("invoke") && descriptor.equals("()V") && isInterface) {
            instructions.remove(instructions.getLast());
            InsnList insnList = new InsnList();
            insnList.add(instructions);
            methodPointCut.setBeforeInstructions(insnList);
            instructions.clear();
            return;
        }
        super.visitMethodInsn(opcodeAndSource, owner, name, descriptor, isInterface);
    }


    @Override
    public void visitEnd() {
        super.visitEnd();
        if (isMatchAnno) {
            InsnList insnList = new InsnList();
            instructions.remove(instructions.getLast());
            insnList.add(instructions);

            if (methodPointCut.getAnnoClass() == Before.class) {
                methodPointCut.setBeforeInstructions(insnList);
            } else {
                methodPointCut.setAfterInstructions(insnList);
            }

            cv.getMethodPointCuts().add(methodPointCut);
        }
    }
}
复制代码

现在我们已经完成了AOP三部曲中的第一步,通过扫描依次收集后我们现在手里就有了所有的数据

匹配方法

在我们掌握到数据后我们需要再次开启扫描去将方法和收集到的信息依次匹配,如果匹配成功我们再次通过MethodVisitor将方法进一步处理。

public class ImplantCodeClassVisitor extends ClassVisitor {
    private List<MethodPointCut> methodPointCuts;
    private String clzName;

    public ImplantCodeClassVisitor(ClassVisitor classVisitor, List<MethodPointCut> methodPointCuts) {
        super(Opcodes.ASM6, classVisitor);
        this.methodPointCuts = methodPointCuts;
    }

    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        super.visit(version, access, name, signature, superName, interfaces);
        clzName = name;
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
        List<MethodPointCut> methodPointCuts = Util.matcherMethodPointCuts(this.methodPointCuts, clzName, name, descriptor);

        if (mv != null && !methodPointCuts.isEmpty()) {
            mv = new ImplantCodeMethodVisitor(mv, access, name, descriptor, methodPointCuts);
        }
        return mv;
    }
}
复制代码

核心的逻辑就是Util#matcherMethodPointCuts方法将收集的数据和现在访问的方法进行对比过滤出符合条件的数据,如果数据不为空意味着该方法需要进一步处理。匹配方法的复杂度意味着AOP库的通用性,所以作为例子我们这里直接比较类全限定名、方法名、方法签名是否相同作为判断条件。

炽入代码

炽入代码就非常简单了,我们通过将收集的数据在合适时机炽入就好了。

public class ImplantCodeMethodVisitor extends AdviceAdapter {
    private List<MethodPointCut> methodPointCuts;
    private Label handler;

    protected ImplantCodeMethodVisitor(MethodVisitor methodVisitor, int access, String name, String descriptor, List<MethodPointCut> methodPointCuts) {
        super(Opcodes.ASM6, methodVisitor, access, name, descriptor);
        this.methodPointCuts = methodPointCuts;
    }

    @Override
    protected void onMethodEnter() {
        for (MethodPointCut methodPointCut : methodPointCuts) {
            implantBeforeCode(methodPointCut);
        }
        super.onMethodEnter();
    }

    @Override
    public void visitTryCatchBlock(Label start, Label end, Label handler, String type) {
        super.visitTryCatchBlock(start, end, handler, type);
        this.handler = handler;
    }

    @Override
    public void visitLabel(Label label) {
        super.visitLabel(label);
        if (label == handler) {
            for (MethodPointCut methodPointCut : methodPointCuts) {
                if (methodPointCut.getAnnoClass() == AfterThrowing.class) {
                    implantAfterCode(methodPointCut);
                }
            }
        }
    }

    @Override
    protected void onMethodExit(int opcode) {
        for (MethodPointCut methodPointCut : methodPointCuts) {
            if (methodPointCut.getAnnoClass() != AfterThrowing.class) {
                implantAfterCode(methodPointCut);
            }
        }
        super.onMethodExit(opcode);
    }

    private void implantBeforeCode(MethodPointCut methodPointCut) {
        if (methodPointCut.getBeforeInstructions() != null) {
            methodPointCut.getBeforeInstructions().accept(this);
        }
    }


    private void implantAfterCode(MethodPointCut methodPointCut) {
        if (methodPointCut.getAfterInstructions() != null) {
            methodPointCut.getAfterInstructions().accept(this);
        }
    }
}
复制代码

至此AOP的核心代码已经全部完成了。不知是否你已经发现了我们这种实现的缺点,给你一些时间思考下。这种方案通过炽入大量相同的操作码操作数会使编译后的文件大小增大,而这个一般不是我们想要看到的,而炽入所有操作码操作数相对来说代码编译时效率也是一个问题。所以不妨回头想想我们的第一个方案,不难发现现在的缺点都将不复存在。这里主要需要思考的是对环绕处理的解决方案,第一种呢我们通过拆分原方法将代码一份为二,在方法进入和退出分别调用拆分的方法,不难发现这个方案中我们需要解决的是局部变量作用域由原来的相同作用域到不同作用域的改变带来的问题,虽说技术上可以实现,但是还是容易出问题的。第二种我们通过MethodHandle方法句柄调用原方法。首先我们将原方法改名内容不变,其次我们需要克隆原方法同名的方法,并且内部仅仅通过调用@Around标记的方法,传入的Method我们通过实现其子类通过invoke间接通过MethodHandle调用到改名后的方法。

最后贴一个Android端对应的实现github.com/lyqiai/easy…

Guess you like

Origin juejin.im/post/7083805782264774693