AOP常见手段(二)

一、前言

上一期在AOP概念以及常见手段(一)中我们明确了下AOP的概念,并学习了APT、Transform的基本使用和原理,下面再看下编译器预处理的这两种方式:

  1. Transform + Javassit
  2. APT + AST

二、使用Javassist实现Transform

上一期介绍Transform时只了解了ASM方式,ASM功能强大,可以实现各种需求,不过它要求我们对字节码的规范有一定程度的了解,学习成本较高,这里我们再了解一下Javassist。

简单介绍

Javassist(java assist)也是一种修改字节码的工具,它不仅可以像ASM一样基于字节码修改,也可以在源代码级面进行修改,也就是说可以像平时写代码一样去修改原java类,而不需要熟悉字节码规范,这样就大大减少了我们的学习成本。

示例Demo

1. 注入ClassPath

Javassist允许我们绕开字节码直接修改源码,那它就必须读取原来的Class类文件,这样它才可以生成出最终的字节码。

ClassPool用于存储需要处理的类文件地址或ClassLoader,我们需要在transform处理每一个input时将路径注入到ClassPool中,否则修改时很可能会报错 NotFountException。

abstract class BaseTransform(private val extension: BaseExtension) : Transform() {
    protected val classPool: ClassPool = ClassPool.getDefault().apply {
        // 重点!! 注入android.jar
        appendClassPath(extension.bootClasspath[0].absolutePath)
    }
    
    @Throws(TransformException::class, InterruptedException::class, IOException::class)
    final override fun transform(transformInvocation: TransformInvocation) {
        ...
        transformInvocation.inputs.forEach { input ->
            input.jarInputs.forEach { jarInput ->
                // 注入jar地址
                classPool.appendClassPath(jarInput.file.absolutePath)
                ...
            }
            input.directoryInputs.forEach { directoryInput ->
                // 注入directory地址
                classPool.appendClassPath(directoryInput.file.absolutePath)
                ...
            }
        }
    }
}    
复制代码
2. 处理单个类

Transform中找到单个需要处理的类,然后通过className找到对应类进行修改

    /**
     * 处理完成后还是通过ByteArray写回transform的输出路径
     */
    override fun handleFileBytes(className: String): ByteArray {
        val targetClass = classPool.get(className)
        handleClass(targetClass)
        
        return targetClass.toBytecode()
    }
    
    override fun handleClass(targetClass: CtClass) {
        // 是否是Activity的子类
        if (!targetClass.subclassOf(classPool["android.app.Activity"])) {
            return
        }
        val onCreateMethods = targetClass.getDeclaredMethods("onCreate")
        for (onCreateMethod in onCreateMethods) {
            // 判断是否是onCreate生命周期方法
            val params = onCreateMethod.parameterTypes
            if (params.size != 1 || params[0] != classPool["android.os.Bundle"]) {
                continue
            }
            // 真正的处理
            try {
                case2(targetClass, onCreateMethod)
            } catch (e: CannotCompileException) {
                println("$name.handleClass CannotCompileException: $onCreateMethod")
            }
        }
    }
    
    /**
     * 插入到方法后面
     * 注意
     * 1. 不管当前类是否是kotlin,只能插入java代码
     * 2. 必须是可用的表达式, 不能是编译不过的代码, 比如单个括号
     */
    private fun case1(onCreateMethod: CtMethod) {
        classPool.importPackage("android.widget.Toast")
        onCreateMethod.insertAfter(
            """
                showJavassistToast();
                Toast.makeText(this, JAVASSIST_SINGE_MSG, Toast.LENGTH_LONG).show();
            """
        )
    }
    
    /**
     * 整体catch
     * 注意:addCatch 必须在最后补充return, 否则注入报错 no basic block;
     */
    private fun case2(targetClass: CtClass, onCreateMethod: CtMethod) {
        classPool.importPackage("android.util.Log")
        onCreateMethod.addCatch(
            """
                Log.e("${targetClass.name}", "空指针了我擦:\n" + Log.getStackTraceString(${'$'}e));
                return;
            """, classPool.get("java.lang.NullPointerException")
        )
    }
复制代码

以上代码实现了对所有Activity的onCreate方法添加try-catch。

可以用来做哪些处理

按之前的了解,Javassist存在一定的能力限制,所以我们更偏向ASM。不过通过对Javassist-API的简单梳理,发现它基本上也可以实现所有的需求:

  1. 修改语句:CtMethod.instrument(CodeConverter()),CtMethod.instrument(ExprEditor)
  2. 插入语句:CtMethod.insertBefore, CtMethod.insertAfter, CtMethod.insertAt
  3. 整体替换:CtMethod.setBody
  4. 整体catch:CtMethod.addCatch
  5. 修改修饰符:CtMethod/CtField/CtConstructor.setModifiers

更详细的API请见官方文档:github.com/jboss-javas…

还能运行时修改?

在看文档的时候发现了彩蛋,javassist竟然还可以实现运行时动态修改代码,而且有两种方式:

  1. CtClass同时提供了Class<?> toClass(ClassLoader loader),这样我们可以直接写业务代码时修改任意代码,然后通过toClass构建出修改后对象。不过注意,这里不是AOP类型的对原类文件做修改,而是随用随构建,只对当前有效。

  2. 动态代理 ProxyFactory,突破JDK提供的Proxy和InvocationHandler,可以处理动态代理类。但测试发现白高兴一场?,对于android如果使用ProxyFactory的话会崩溃,原因在于Android修改了JDK代码,SecurityManager.getClassContext必定返回null导致空指针。

    val proxyFactory = ProxyFactory().apply {
        superclass = ProxyTest::class.java
        setFilter { it.name == "test" }
    }
    val proxyClass = proxyFactory.createClass() // 空指针崩溃
    (proxyClass as Proxy).setHandler { self, thisMethod, _, args ->
        val result = thisMethod.invoke(self, args) as String
        return@setHandler result + result
    }
    val proxyTest = proxyClass.newInstance() as ProxyTest
    复制代码

Javassist处理原理

WX20210628-180227@2x.png

总结一下

Javassist与ASM相比学习成本比较低,而且基本可以满足所有的需求,后续有Transform可以考虑优先采用Javassist。

另外Javassist可以运行时动态修改类文件,修改三方库bug时也可以考虑使用Javassist处理。

三、抽象语法树AST

AST(Abstract syntax tree)的核心就是把Code转化为一种描述(对象、方法、运算符、流程控制语句、声明/赋值、内部类等),让我们可以在运行时比较方便的查看、修改Code,其中转化过程可以认为一种运行时编译。

作用方式

image.png

身边的应用

IDE里面的Lombok插件、语法高亮、格式化代码、自动补全、代码混淆压缩都是利用了AST。

线上体验AST:astexplorer.net/

利用AST扩展APT

按照我们之前的了解,注解处理器APT只能用来生成代码,无法修改代码,但有了AST就不一样了。
IDE里面的Lombok插件的原理就是利用APT动态修改AST,给现有类增加了新的逻辑。

原理我们现在都明白了,下面直接写个Hello World试试水。

示例Demo

  1. javasdk/Contents/Home/lib/tools.jar中提供了AST相关的API,我们需要引用这个库

  2. 生成AST

    class ASTProcessor : AbstractProcessor() {
    
        // 生成的AST
        private lateinit var trees: Trees
        // 用于生成新代码
        private lateinit var treeMaker: TreeMaker
        // 用于构建命名
        private lateinit var names: Names
    
        override fun init(processingEnv: ProcessingEnvironment?) {
            super.init(processingEnv)
            if (processingEnv is JavacProcessingEnvironment) {
                trees = Trees.instance(processingEnv)
                treeMaker = TreeMaker.instance(processingEnv.context)
                names = Names.instance(processingEnv.context)
            }
        }
    
    }    
    复制代码
  3. 修改AST

    override fun process(typeElementSet: MutableSet, roundEnvironment: RoundEnvironment): Boolean {
        for (typeElement in typeElementSet) {
            val elements = roundEnvironment.getElementsAnnotatedWith(typeElement)
            for (element in elements) {
                // 找到Element对应的子树
                val jcTree = trees.getTree(element) as JCTree
                jcTree.accept(myVisitor)
            }
        }
        return false
    }
    
    private val myVisitor = object : TreeTranslator() {
        /**
         * 访问者模式里的类定义visit
         */
        override fun visitClassDef(tree: JCClassDecl) {
            super.visitClassDef(tree)
            // defs指定义的内容,包含方法、参数、内部类等
            for (jcTree in tree.defs) {
                // 如果是参数
                if (jcTree is JCVariableDecl) {
                    tree.defs.append(makeGetterMethod(jcTree))
                }
            }
        }
    }
    
    /**
     * 构建get方法
     */
    private fun makeGetterMethod(variable: JCVariableDecl): JCMethodDecl? {
        // this
        val ident = treeMaker.Ident(names.fromString("this"))
        // this.xx
        val select = treeMaker.Select(ident, variable.name)
        // return this.xxx
        val jcStatement: JCStatement = treeMaker.Return(select)
        // 把整个表达式塞到代码块里
        val jcBlock = treeMaker.Block(0, List.nil<JCStatement?>().append(jcStatement))
    
        return treeMaker.MethodDef(
            treeMaker.Modifiers(Flags.PUBLIC.toLong()), //public
            getterMethodName(variable),   // getXxx
            variable.vartype,             // return 类型
            List.nil<JCTypeParameter>(),  // 泛型参数列表
            List.nil<JCVariableDecl>(),   // 参数列表
            List.nil<JCExpression>(),     // 异常抛出列表
            jcBlock,                      // 代码块
            null
        )
    }
    复制代码

效果图

image.png

AST+APT实现AOP的限制

经过测试发现,AST操作只可以应用在annotationProcessor,kapt不支持:修改AST后不能生效。
所以在当前我们大规模使用kotlin的现状下,使用AST做AOP的处理还是不可行的。

思考:虽然AST当前不能结合APT做AOP的处理,不过这种修改语法树的方式提供了新的思路,也许可以在其他领域应用。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享