上一篇文章中涉及到一个非常简单的AOP例子,而AOP作为JAVA中常用框架之一的一员,无论是从事后端还是安卓都会接触到AOP的概念,而作为使用者的我们是否有想过AOP这样的技术是如何实现的呢?如果你从来没有想过那么这篇文章将详细给出实现一套AOP库,包含前置、后置、环绕、正常退出、异常退出这些常见的AOP实现。 让我们先从最简单的考虑,前置是如何实现的,给你自己一些时间先思考思考。我想最简单最快捷的思路就是记录当前类及方法信息,在需要切入的方法执行前通过ASM炽入相关方法调用即可实现。按照相同的方法将其它功能代入思考,我们不难发现这个方法好像蛮合适的,但是环绕却好像行不通,因为环绕的特殊性所以我们需要另辟新径么?再给你一些实现思考下,回头再给出答案。 上诉的方案是通过方法调用实现切入逻辑,那么我们是否可以通过将方法对应的操作码及操作数记录并在合适的实际炽入呢?答案当然是可以的。前置、后置、正常 退出、异常退出我们需要收集全部的操作码及操作数,但是环绕好像又不太一样,所以无论这种方案还是上面的方案好像都需要解决环绕的难题。这里就先不说相关解决方案了,让我们直接开始实现其他功能的是实现吧,最后我们在实现环绕时再给出答案吧,当然聪明的你也许已经有了答案。
注解定义
首先我们应该先将前置、后置、环绕、正常退出、异常退出相关的注解定义出来,而该注解需要携带需要匹配的类及方法的数据
前置
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.METHOD)
public @interface Before {
String clzName();
String methodDesc();
}
复制代码
后置
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.METHOD)
public @interface After {
String clzName();
String methodDesc();
}
复制代码
环绕
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.METHOD)
public @interface Around {
String clzName();
String methodDesc();
}
复制代码
正常退出
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.METHOD)
public @interface AfterReturning {
String clzName();
String methodDesc();
}
复制代码
异常
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.METHOD)
public @interface AfterThrowing {
String clzName();
String methodDesc();
}
复制代码
以上注解中都包含了clzName
和methodDesc
用于匹配类及方法,这里我们基础类型描述为基础,clzName
应该匹配类的全限定名,methodDesc
匹配方法名及方法签名。另外我们需要一个注解标记类方便我们收集以上注解
@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库的核心类,维护这核心数据,而这个核心类中最为重要的便是携带操作码及操作数的两个集合beforeInstructions
和afterInstructions
,这里需要两个集合是因为考虑到了环绕注解需要我们分别存储方法执行前及执行后的操作码及操作数。
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…