使用ASM实现简单的AOP

前言

之前一直使用greys及其内部升级二次开发版来排查问题。最近周末刚好事情不多,作为一名程序员本能地想要弄懂这么神奇的greys到底是怎么实现的?周末从github上拉了代码仔细读了读,其基本技术框架是JVM attach + Instrumentation + asm实现的。关于JVM attach和Instrumentation的功能,下次再写文章介绍,本文着重于greys中非常神奇的一个类AdviceWeaver,该类使用asm代码实现了简单的aop功能,本文的实现方式基本参考该类,具体的代码放在了scrat-profiler模块中。下文将结合asm的使用方法讲解如何实现简单的aop功能。

asm简介

什么是asm?ASM是一个java字节码操纵框架,它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为(摘自网友翻译)。asm的文档请参考asm文档,文档写的比较全。主要几个重要的类为ClassReader、ClassWriter、ClassVisitor、MethodVisitor等。

ClassVisitor、MethodVisitor与AdviceAdapter

在使用ASM操作字节码之前,我们需要稍微了解下ClassVisitor,ClassVisitor用来generating and transforming compiled classes,ClassVisitor中的每个方法都对应了class file的相关部分,ClassVisitor里方法的执行顺序应该如下:

visit visitSource? visitOuterClass? ( visitAnnotation | visitAttribute )*
( visitInnerClass | visitField | visitMethod )*
visitEnd

由于本文主要讲的是enhance class,所以着重讨论下ClassVisitor Transforming classes。Transforming classes通常需要借助于两个类ClassReader与ClassWriter,ClassWriter是ClassVisitor子类,与ClassReader或者其他ClassVisitor子类一起使用来generate a modified class from one or more existing Java classes。典型的用法如下:

byte[] b1 = ...;
ClassWriter cw = new ClassWriter(0);
ClassReader cr = new ClassReader(b1);
cr.accept(cw, 0);
byte[] b2 = cw.toByteArray();

与其他ClassVisitor一起使用的典型用法如下:

byte[] b1 = ...;
ClassWriter cw = new ClassWriter(0);
// cv forwards all events to cw
ClassVisitor cv = new ClassVisitor(ASM4, cw) { };
ClassReader cr = new ClassReader(b1);
cr.accept(cv, 0);
byte[] b2 = cw.toByteArray();

由于我想实现的是方法级别的AOP增强,所以我更加关注与ClassVisitor的visitMethod方法:

public MethodVisitor visitMethod(int access, String name, String desc,
            String signature, String[] exceptions) {
        if (cv != null) {
            return cv.visitMethod(access, name, desc, signature, exceptions);
        }
        return null;
    }

可以看到的是该方法返回的是MethodVisitor,当我们继承ClassVisitor并且复写visitMethod返回自定义的MethodVisitor时,我们可以实现对method的字节码进行替换增强。首先我们来研究下MethodVisitor如何使用。
MethodVisitor接口中的方法调用必须按照以下的顺序:

visitAnnotationDefault?
( visitAnnotation | visitParameterAnnotation | visitAttribute )*
( visitCode
( visitTryCatchBlock | visitLabel | visitFrame | visitXxxInsn |
visitLocalVariable | visitLineNumber )*
visitMaxs )?
visitEnd

摘取ASM user guide中比较重要的几句话:

This means that annotations and attributes, if any, must be visited first, followed
by the method’s bytecode, for non abstract methods. For these methods
the code must be visited in sequential order, between exactly one call to
visitCode and exactly one call to visitMaxs.

The visitCode and visitMaxs methods can therefore be used to detect the
start and end of the method’s bytecode in a sequence of events. Like for
classes, the visitEnd method must be called last, and is used to detect the end of a method in a sequence of events

从中可知,visitCode和visitMaxs成对出现在method字节码前后(如果存在字节码的话),visitEnd出现在最后。

在user guide中还给出了一个非常重要的工具类AdviceAdapter,按照user guide里面的说法:

This method adapter is an abstract class that can be used to insert code at
the beginning of a method and just before any RETURN or ATHROW
instruction.
Its main advantage is that it also works for constructors, where code must not
be inserted just at the beginning of the constructor, but after the call to the
super constructor

看起来非常适合我们用来实现AOP(在method字节码前后插入我们的代码)

字节码与指令

由于ASM操作比较底层,所以我们进行字节码增强的时候需要了解字节码与相关指令。Java bytecode instruction listings 里面非常详尽的介绍了字节码指令以及字节码指令对栈帧的影响(这个特别重要)!在此说明一下,目前网络上很多博客写的字节码指令的中文解释非常不全面而且存在非常明显的错误(比如对于dup2_x1的解释网上中文博客基本找不到正确的),所以大家最好还是以该英文维基百科为准。

具体实现

以下实现代码均在scrat-profiler中,首先声明,该代码基本全部参考的greys的相关代码,且为玩票性质,不能用于生产环境。

AOP通知方法定义

本文不纠结与AOP的专业定义,例如通知、切片等。只求以通俗易理解的语言说明。AOP无非是要在方法前后运行自定义的增强代码。此‘增强代码’可定义为如下接口。

public interface AdviceListener {

    /**
     * 方法前置通知
     *
     * @param classLoader target类加载器
     * @param className   类名
     * @param methodName  增强的方法名
     * @param methodDesc  增强的方法描述
     * @param target      目标类实例对象,如果目标为静态方法,则为null
     * @param args        增强的方法参数
     * @throws Throwable 通知执行过程中的异常
     */
    void before(ClassLoader classLoader, String className, String methodName, String methodDesc, Object target,
                Object[] args) throws Throwable;

    /**
     * 方法正常返回后的通知
     *
     * @param classLoader target类加载器
     * @param className   类名
     * @param methodName  方法名
     * @param methodDesc  方法描述
     * @param target      目标类实例对象,如果目标为静态方法,则为null
     * @param args        增强的方法参数
     * @param returnObj   目标方法返回值
     * @throws Throwable 通知执行过程中的异常
     */
    void afterReturning(ClassLoader classLoader, String className, String methodName, String methodDesc, Object target,
                        Object[] args, Object returnObj) throws Throwable;

    /**
     * 方法抛出异常后的通知
     *
     * @param loader     target类加载器
     * @param className  类名
     * @param methodName 方法名
     * @param methodDesc 方法描述
     * @param target     目标类实例对象,如果目标为静态方法,则为null
     * @param args       增强的方法参数
     * @param throwable  目标方法返回值
     * @throws Throwable 通知执行过程中的异常
     */
    void afterThrowing(ClassLoader loader, String className, String methodName, String methodDesc, Object target,
                       Object[] args, Throwable throwable) throws Throwable;
}

该方法基本copy于greys,定义了target方法运行前的增强代码、方法正常返回后的增强代码以及方法运行过程中抛出异常的增强代码。该接口基本满足了我们日常的AOP增强需求。

使用AdviceAdapter植入增强代码

如前所述,ASM提供了AdviceAdapter工具类用于在method字节码中插入增强代码,onMethodEnter、onMethodExit、visitMaxs成为比较好的切入点。

字节码操作

让我们首先学习下常用的字节码操作。

  • invokestatic

首先看下比较简单的,如何用字节码操作调用某个class的static方法。
参考Java bytecode instruction listings的说明:

invokestatic    b8  1011 1000   2: indexbyte1, indexbyte2   [arg1, arg2, ...] → result  invoke a static method and puts the result on the stack (might be void); the method is identified by method reference index in constant pool (indexbyte1 << 8 + indexbyte2)

也就是说在使用invokestatic之前,需要再栈顶依次push该static方法的入参,在调用完成后会将方法的返回结果留在栈顶。例如如果我们想调用Class.forName(String className),我们首先需要将className push到执行栈中,使用

ldc "some.Person"

由于只有一个方法参数,所以接下来我们使用

invokestatic 

此时的栈顶上就是"some.Person"的这个类的Class类实例。

  • invokevirtual

接下来我们看看如何调用某类的实例方法,最典型的就是调用System.out.println(String str),让我们试着用字节码完成这个操作。
让我们先看看invokevirtual的字节码说明:

invokevirtual   b6  1011 0110   2: indexbyte1, indexbyte2   objectref, [arg1, arg2, ...] → result   invoke virtual method on object objectref and puts the result on the stack (might be void); the method is identified by method reference index in constant pool (indexbyte1 << 8 + indexbyte2)

如上所示,在使用invokevirtual之前,我们需要先将method所在的Object ref压入堆栈,然后将方法的参数一次压入堆栈,然后使用invokevirtual,然后该方法的返回值会被存在栈顶。下面演示下如何调用System.out.println(String str)
首先需要将Object ref压入堆栈(使用ASM取得Ojbect ref通常不是一件容易的事情),我们使用getstatic将System的out filed压入栈顶。

visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");

然后将入参string压入栈顶

ldc string

然后使用invokevirtual调用

visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

织入增强代码

如前所述,基本使用方法:

        ClassReader cr = new ClassReader(targetClass.getName());
        
        // 字节码增强
        final ClassWriter cw = new ClassWriter(cr, COMPUTE_FRAMES | COMPUTE_MAXS);

        cr.accept(new AdviceWeaver(adviceId, adviceListener, genTransferClassName(),        targetClass, cw),
            ClassReader.EXPAND_FRAMES);

        byte[] enhanced = cw.toByteArray();

AdviceWeaver继承ClassVisitor,并且复写了

public MethodVisitor visitMethod(int access, final String name, final String desc, String signature,String[] exceptions)

返回了一个AdviceAdapter的子类,定制了onMethodEnter、onMethodExit、visitMaxs方法。

前置增强onMethodEnter

在onMethodEnter中,主要增加了调用AdviceWeaver#methodOnBegin的逻辑,methodOnBegin的方法体如下:

    public static void methodOnBegin(int adviceId, ClassLoader loader, String className,
                                     String methodName, String methodDesc, Object target,       Object[] args) {

        if (selfCalled.get()) {
            return;
        } else {
            selfCalled.set(true);
        }
        try {
            AdviceListener listener = LISTENER_MAP.get(adviceId);
            if (listener == null) {
                throw new RuntimeException("no listener for:" + adviceId);
            }
            //为了方便returning/throwing方法,先保护现场入栈
            Stack<Object> beginMethodFrame = new Stack<>();
            beginMethodFrame.push(listener);
            beginMethodFrame.push(loader);
            beginMethodFrame.push(className);
            beginMethodFrame.push(methodName);
            beginMethodFrame.push(methodDesc);
            beginMethodFrame.push(target);
            beginMethodFrame.push(args);

            threadFrameStack.get().push(beginMethodFrame);

            //执行before listener
            doBefore(listener, loader, className, methodName, methodDesc, target, args);
        } finally {
            selfCalled.set(false);
        }
    }

基本逻辑很简单,就是从map中取出对应的listener,然后调用listener的前置通知方法。而onMethodEnter方法如下:

            @Override
            protected void onMethodEnter() {
                //前置增強
                //push adviceId
                push(adviceId);
                box(Type.getType(Integer.class));
                //push classloader
                loadClassLoader();
                //push className
                push(className);
                //push methodName
                push(name);
                //push methodDesc
                push(desc);
                //push target
                loadThisOrNullIfStatic();
                //push method args
                loadArgArray();
                //调用methodOnBegin方法
                invokeStatic(ADVICE_WEAVER_TYPE, AdviceWeaver_methodOnBegin);

                //标记method begin,用于throwing的try-catch-finally block
                mark(beginLabel);
            }

基本上也就是将methodOnBegin方法的入参依次压入堆栈,然后invokeStatic。这其实有几个比较有意思的点,第一个就是为啥要压入adviceId而不是压入adviceListener呢,主要是在AdviceAdapter的上下文中,使用ASM很难获取到adviceListener的实例变量。转而使用adviceId进行标识然后从静态Map中获取。

后置增强onMethodExit

在本例中,后置增强只处理正常返回值,代码如下

   protected void onMethodExit(int opcode) {
                //判断不是以异常结束
                if (ATHROW != opcode) {
                    //加载正常的返回值
                    loadReturn(opcode);
                    //只有一个参数就是返回值
                    invokeStatic(ADVICE_WEAVER_TYPE, AdviceWeaver_methodOnReturning);
                }
            }
            
            
   private void loadReturn(int opcode) {
                switch (opcode) {

                    case RETURN: {
                        push((Type)null);
                        break;
                    }

                    case ARETURN: {
                        dup();
                        break;
                    }

                    case LRETURN:
                    case DRETURN: {
                        dup2();
                        box(Type.getReturnType(methodDesc));
                        break;
                    }

                    default: {
                        dup();
                        box(Type.getReturnType(methodDesc));
                        break;
                    }

                }
            }

dup相当于复制栈顶元素然后入栈该元素,为啥要进行一次dup呢,因为其中一个栈顶元素(当前返回值)需要作为AdviceWeaver_methodOnReturning的方法参数被消耗掉,为了不侵入所以先复制一份。

异常返回增强visitMaxs

前文提到,

The visitCode and visitMaxs methods can therefore be used to detect the
start and end of the method’s bytecode in a sequence of events. Like for
classes, the visitEnd method must be called last, and is used to detect the end of a method in a sequence of events

可知,visitMaxs可以detect end of the method’s bytecode。

我们看下visitMaxs如何将method’s bytecode包括在try-catch block中。


public void visitMaxs(int maxStack, int maxLocals) {
                //每个方法最后调用一次,在visitEnd之前
                mark(endLabel);
                //在beginLabel和endLabel之间使用try-catch block,在这之后需要紧跟exception的处理逻辑code
                catchException(beginLabel, endLabel, THROWABLE_TYPE);
                //从栈顶加载异常(复制一份给onThrowing当参数用)
                dup();
                invokeStatic(ADVICE_WEAVER_TYPE, AdviceWeaver_methodOnThrowing);
                //将原有的异常抛出(不破坏原有异常逻辑)
                throwException();

                super.visitMaxs(maxStack, maxLocals);
            }

需要注意的是,在onMethodEnter的最后我们进行了mark(beginLabel),也就是标记了method bytecode的开始,在visitMaxs开始的时候mark(endLabel),然后我们使用了catchException(beginLabel, endLabel, THROWABLE_TYPE)这个语句相当于说,在beginLabel和endLabel之间使用try catch block,并且catch的类型为THROWABLE_TYPE。由于java没有异常处理语句,字节码执行过程中异常的跳转完全靠异常表完成,那么这句话的意思也可以理解为向异常表中添加一种异常 handler,该handler的起始部分为beginLabel ~ endLabel。catchException语句后面跟着的部分就是handler语句,即此处的:

            //从栈顶加载异常(复制一份给onThrowing当参数用)
                dup();
                invokeStatic(ADVICE_WEAVER_TYPE, AdviceWeaver_methodOnThrowing);
                //将原有的异常抛出(不破坏原有异常逻辑)
                throwException();

结合注释会比较好理解。

总结

以上基本介绍了比较关键的点,大家可以参考github里给出的代码。后续可能会需要再讲解下关于JVM Agent的知识。本文的代码基本参考与greys,感谢!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容