ASM 插桩采集方法入参,出参及耗时信息

前言

ASM字节码插桩技术在Android开发中有着广泛的应用,但相信很多人会不知道怎么上手,不知道该拿ASM来做点什么。

学习一门技术最好的方法就是动手实践,本文主要通过ASM插桩采集方法的入参,出参及耗时信息并打印,通过一个不大不小的例子快速上手ASM插桩开发。

技术目标

我们先看下最终的效果

插桩前代码

首先来看下插桩前代码,就是一个sum方法

    private fun sum(i: Int, j: Int): Int {
        return i + j
    }
复制代码

插桩后代码

接下来看下插桩后的代码

    private final int sum(int i, int j) {
        ArrayList arrayList = new ArrayList();
        arrayList.add(Integer.valueOf(i));
        arrayList.add(Integer.valueOf(j));
        MethodRecorder.onMethodEnter("com.zj.android_asm.MainActivity", "sum", arrayList);
        int i2 = i + j;
        MethodRecorder.onMethodExit(Integer.valueOf(i2), "com.zj.android_asm.MainActivity", "sum", "I,I", "I");
        return i2;
    }
复制代码

可以看出,方法所有参数都被添加到了一个arrayList中,并且调用了MethodRecorder.onMethodEnter方法
而在结果返回之前,则会调用MethodRecorder.onMethodExit方法,并将返回值,参数类型,返回值类型等作为参数传递过支。

日志输出

在调用了onMethodExit之后,会计算出方法耗时并输出日志,如下所示

类名:com.zj.android_asm.MainActivity 
方法名:sum 
参数类型:[I,I] 
入参:[1,2] 
返回类型:I 
返回值:3 
耗时:0 ms 
复制代码

技术实现

上面我们介绍了最后要实现的效果,下面就来看下怎么一步一步实现,主要分为以下3步:

  1. 在方法开始时采集方法参数
  2. 在方法结束时采集返回值
  3. 调用帮助类计算耗时及打印结果

ASM采集方法参数

采集方法参数的方法也很简单,主要就是读取出所有参数的值并存储在一个List中,主要问题在于我们需要用字节码来实现这些逻辑.

override fun onMethodEnter() {
    // 方法开始
    if (isNeedVisiMethod() && descriptor != null) {
        val parametersIdentifier = MethodRecordUtil.newParameterArrayList(mv, this)   //1. new一个List
        MethodRecordUtil.fillParameterArray(methodDesc, mv, parametersIdentifier, access) //2. 填充列表
		MethodRecordUtil.onMethodEnter(mv, className, name, parametersIdentifier) //3. 调用帮助类
    }
    super.onMethodEnter()
}
复制代码

如上所示,采集方法参数也分为3步,接下来我来一步步看下代码

ASM创建列表

    fun newParameterArrayList(mv: MethodVisitor, localVariablesSorter: LocalVariablesSorter): Int {
    	// new一个ArrayList
        mv.visitTypeInsn(AdviceAdapter.NEW, "java/util/ArrayList")
        mv.visitInsn(AdviceAdapter.DUP)
        mv.visitMethodInsn(
            AdviceAdapter.INVOKESPECIAL,
            "java/util/ArrayList",
            "<init>",
            "()V",
            false
        )
        // 存储new出来的List
        val parametersIdentifier = localVariablesSorter.newLocal(Type.getType(List::class.java))
        mv.visitVarInsn(AdviceAdapter.ASTORE, parametersIdentifier)
        // 返回parametersIdentifier,方便后续访问这个列表
        return parametersIdentifier
    }
复制代码

逻辑其实很简单,主要问题在于需要用ASM代码写,需要掌握一些字节码指令相关知识。不过我们也可以用asm-bytecode-outline来自动生成这段代码,这样难度可以降低不少。关于代码中各个指令的具体含义,可查阅Java虚拟机(JVM)字节码指令表

ASM填充列表

接下来要做的就是读出所有的参数并填充到上面创建的列表中

    fun fillParameterArray(
        methodDesc: String,
        mv: MethodVisitor,
        parametersIdentifier: Int,
        access: Int
    ) {
    	// 判断是不是静态函数
        val isStatic = (access and Opcodes.ACC_STATIC) != 0
        // 静态函数与普通函数的cursor不同
        var cursor = if (isStatic) 0 else 1
        val methodType = Type.getMethodType(methodDesc)
        // 获取参数列表
        methodType.argumentTypes.forEach {
        	// 读取列表
            mv.visitVarInsn(AdviceAdapter.ALOAD, parametersIdentifier)
            // 根据不同类型获取不同的指令,比如int是iload, long是lload
            val opcode = it.getOpcode(Opcodes.ILOAD)
            // 通过指令与cursor读取参数的值
            mv.visitVarInsn(opcode, cursor)
            if (it.sort >= Type.BOOLEAN && it.sort <= Type.DOUBLE) {
            	// 基本类型转换为包装类型
                typeCastToObject(mv, it)
            }
            // 更新cursor
            cursor += it.size
            // 添加到列表中
            mv.visitMethodInsn(
                AdviceAdapter.INVOKEINTERFACE,
                "java/util/List",
                "add",
                "(Ljava/lang/Object;)Z",
                true
            )
            mv.visitInsn(AdviceAdapter.POP)
        }
    }
复制代码

主要代码如上所示,代码中都有注释,主要需要注意以下几点:

  1. 静态函数与普通函数的初始cursor不同,因此需要区分开来
  2. 不同类型的参数加载的指令也不同,因此需要通过Type.getOpcode获取具体指令
  3. 为了将参数放在一个列表中,需要将基本类型转换为包装类型,比如int转换为Integer

ASM调用帮助类

    fun onMethodEnter(
        mv: MethodVisitor,
        className: String,
        name: String?,
        parametersIdentifier: Int
    ) {
        mv.visitLdcInsn(className)
        mv.visitLdcInsn(name)
        mv.visitVarInsn(AdviceAdapter.ALOAD, parametersIdentifier)
        mv.visitMethodInsn(
            AdviceAdapter.INVOKESTATIC, "com/zj/android_asm/MethodRecorder", "onMethodEnter",
            "(Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)V", false
        )
    }
复制代码

这个比较简单,主要就是通过ASM调用MethodRecorder.onMethodEnter方法

ASM采集返回值

override fun onMethodExit(opcode: Int) {
    // 方法结束
    if (isNeedVisiMethod()) {
        if ((opcode in IRETURN..RETURN) || opcode == ATHROW) {
            when (opcode) {
            	// 基本类型返回
                in IRETURN..DRETURN -> {
                	// 读取返回值
                    MethodRecordUtil.loadReturnData(mv, methodDesc)
                    MethodRecordUtil.onMethodExit(mv, className, name, methodDesc)
                }
                // 对象返回
                ARETURN -> {
                	// 读取返回值
                    mv.visitInsn(DUP)
                    MethodRecordUtil.onMethodExit(mv, className, name, methodDesc)
                }
                // 空返回
                RETURN -> {
                    mv.visitLdcInsn("void")
                    MethodRecordUtil.onMethodExit(mv, className, name, methodDesc)
                }
            }
        }
    }
    super.onMethodExit(opcode);
}
复制代码

采集返回值的逻辑也很简单,主要分为以下几步

  1. 判断当前指令,并且根据不同类型的返回添加不同的逻辑
  2. 通过DUP指令复制栈顶数值并将复制值压入栈顶,以读取返回值
  3. 读取方法参数类型与返回值类型,并调用MethodRecorder.onMexthodExit方法

帮助类实现

由于ASM需要直接操作字节码,写起来终究不太方便,因此我们尽可能把代码转移到帮助类中,然后通过在ASM中调用帮助类来简化开发,帮助类的代码如下所示:

object MethodRecorder {
    private val mMethodRecordMap = HashMap<String, MethodRecordItem>()

    @JvmStatic
    fun onMethodEnter(className: String, methodName: String, parameterList: List<Any?>?) {
        val key = "${className},${methodName}"
        val startTime = System.currentTimeMillis()
        val list = parameterList?.filterNotNull() ?: emptyList()
        mMethodRecordMap[key] = MethodRecordItem(startTime, list)
    }

    @JvmStatic
    fun onMethodExit(
        response: Any? = null,
        className: String,
        methodName: String,
        parameterTypes: String,
        returnType: String
    ) {
        val key = "${className},${methodName}"
        mMethodRecordMap[key]?.let {
            val parameters = it.parameterList.joinToString(",")
            val duration = System.currentTimeMillis() - it.startTime
            val result = "类名:$className \n方法名:$methodName \n参数类型:[$parameterTypes] \n入参:[$parameters] \n返回类型:$returnType \n返回值:$response \n耗时:$duration ms \n"
            Log.i("methodRecord", result)
        }
    }
}
复制代码

代码其实也很简单,主要逻辑如下:

  1. 方法开始时调用onMethodEnter方法,传入参数列表,并记录下方法开始时间
  2. 方法结束时调用onMethodExit方法,传入返回值,计算方法耗时并打印结果

总结

通过上述步骤,我们就把ASM插桩实现记录方法入参,返回值以及方法耗时的功能完成了,通过插桩可以在方法执行的时候输出我们需要的信息。而这些信息的价值就是可以很好的让我们做一些程序的全链路监控以及工程质量验证。

总得来说,逻辑上其实并不复杂,主要问题可能在于需要熟悉如何直接操作字节码,我们可以通过asm-bytecode-outline等工具自动生成代码来简化开发,同时也可以通过尽量把逻辑迁移到帮助类中的方式来减少直接操作字节码的工作。

示例代码

本文所有源码可见:github.com/shenzhen201…

参考资料

Java虚拟机(JVM)字节码指令表
ASM字节码编程 | JavaAgent+ASM字节码插桩采集方法名称以及入参和出参结果并记录方法耗时
Java ASM系列:(025)修改已有的方法(添加-进入和退出-打印方法参数和返回值)

猜你喜欢

转载自juejin.im/post/7108526362087915534
ASM