关于AspectJ,你需要知道的一切

前言

本文将介绍与AspectJ以及与其相关的内容,各方面内容的侧重点会有所不同,AspectJ将会进行详细的介绍,而其他方面的内容则重点在于辅助理解AspectJ的原理,便于更好地掌握AspectJ。

在Android中你要使用AspectJ,那么你就要懂得注解,要理解AspectJ的实现你就要懂得APT与动态代理,为什么要使用AspectJ你就要理解AOP与OOP的区别,这些不仅仅是学习AspectJ的基础,同时也是深入理解AspectJ必须掌握的要点。同时AspectJ只是一个框架,一个帮助你更好地实现AOP的框架,所以重点不仅在于学习框架的语法和应用,更应该包括框架中所蕴含的思想。

注解

概述

由于本文重点在于介绍AspectJ,所以这里就只着重讲解注解在AspectJ中应用。关于注解的详细介绍,我有写了另一篇文章Java注解的基础与高级应用

在开发Java程序,尤其是Java EE应用的时候,总是免不了与各种配置文件打交道。以Java EE中典型的S(pring)S(truts)H(ibernate)架构来说,Spring、Struts和Hibernate这三个框架都有自己的XML格式的配置文件。这些配置文件需要与Java源代码保持同步,否则的话就可能出现错误。而且这些错误有可能到了运行时刻才被发现。把同一份信息保存在两个地方,总是个坏主意。理想的情况是在一个地方维护这些信息就好了。其它部分所需的信息则通过自动的方式来生成。JDK 5中引入了源代码中的注解(annotation)这一机制。注解使得Java源代码中不但可以包含功能性的实现代码,还可以添加元数据。注解的功能类似于代码中的注释,所不同的是注解不是提供代码功能的说明,而是实现程序功能的重要组成部分。Java注解已经在很多框架中得到了广泛的使用,用来简化程序中的配置。

因为注解大多都有自己的配置参数,而配置参数以名值对的方式出现,所以从某种角度来说,可以把注解看成是一个XML元素,该元素可以有不同的预定义的属性。而属性的值是可以在声明该元素的时候自行指定的。在代码中使用注解,就相当于把一部分元数据从XML文件移到了代码本身之中,在一个地方管理和维护。

上面两段话其实已经阐述了java注解的主要作用之一,就是跟踪代码依赖性,实现替代配置文件功能。比较常见的是Spring等框架中的基于注解配置。现在的框架很多都使用了这种方式来减少配置文件的数量。基本上秉持着这么一个原则,与具体场景相关的配置应该使用注解的方式与数据关联,与具体场景无关的配置放于配置文件中,从而实现在同一个地方进行管理和维护。在另一方面我们还可以在通过设置注解的@Retention 级别在运行时使用反射对不同的注解进行处理。

注解还有其他两个作用,一是生成文档,这也是很常见的,也是Java 最早提供的注解。常用的有@see @param @return 等;另一个是在编译时进行格式检查。如@override 放在方法前,如果你这个方法并不是覆盖了超类方法,则编译时就能检查出。

注解处理器

如果没有用来读取注解的方法和工作,那么注解也就不会比注释更有用处了。使用注解的过程中,很重要的一部分就是创建于使用注解处理器。Java SE5扩展了反射机制的API,以帮助程序员快速的构造自定义注解处理器。

注解处理器类库

Java使用Annotation接口来代表程序元素前面的注解,该接口是所有Annotation类型的父接口。除此之外,Java在java.lang.reflect 包下新增了AnnotatedElement接口,该接口代表程序中可以接受注解的程序元素。

java.lang.reflect 包下主要包含一些实现反射功能的工具类,实际上,java.lang.reflect 包所有提供的反射API扩充了读取运行时Annotation信息的能力。当一个Annotation类型被定义为运行时的Annotation后,该注解才能是运行时可见,当class文件被装载时被保存在class文件中的Annotation才会被虚拟机读取。

AnnotatedElement 接口是所有程序元素(Class、Method和Constructor)的父接口,所以程序通过反射获取了某个类的AnnotatedElement对象之后,程序就可以调用该对象的方法来访问Annotation信息。

关于注解处理器更详尽地介绍与demo可以在Java注解的基础与高级应用中查看,这里不再赘述。

高级应用

APT

APT(Annotation Processing Tool)是一个命令行工具,它对源代码文件进行检测找出其中的annotation后,使用annotation processors来处理annotation。

在程序中添加的注解,可以在编译时刻或是运行时刻来进行处理。在编译时刻处理的时候,是分成多趟来进行的。如果在某趟处理中产生了新的Java源文件,那么就需要另外一趟处理来处理新生成的源文件。如此往复,直到没有新文件被生成为止。在完成处理之后,再对Java代码进行编译。JDK 5中提供了apt工具用来对注解进行处理。apt是一个命令行工具,与之配套的还有一套用来描述程序语义结构的Mirror API。Mirror API(com.sun.mirror.*)描述的是程序在编译时刻的静态结构。通过Mirror API可以获取到被注解的Java类型元素的信息,从而提供相应的处理逻辑。具体的处理工作交给apt工具来完成。编写注解处理器的核心是AnnotationProcessorFactory和AnnotationProcessor两个接口。后者表示的是注解处理器,而前者则是为某些注解类型创建注解处理器的工厂。

使用APT主要目的是简化开发者的工作量,因为APT可以在编译程序源代码的同时,生成一些附属文件(比如源文件、类文件、程序发布描述文字等),这些附属文件的内容也都是与源代码相关的。换句话说,使用APT就是代替了传统的对代码信息和附属文件的维护工作。使用过hibernate或者beehive(这个beehive不是阿里开源的组件化方案BeeHive,而是Apache开源的Beehive)等框架的朋友可能深有体会。APT可以在编译生成代码类的同时将相关的文件写好,比如在使用beehive时,在代码中使用annotation声明了许多struct要用到的配置信息,而在编译后,这些信息会被APT以struct配置文件的方式存放。

应用

由于在Java注解的基础与高级应用中对注解的高级应用也有讲述,所以这里只简单介绍下。

如果我们希望在编译期间通过APT来做一些我们想做的事情,那么我们需要实现一个自定义的注解处理工厂类,这个类要实现AnnotationProcessorFactory接口。AnnotationProcessorFactory接口有三个方法:getProcessorFor是根据注解的类型来返回特定的注解处理器;supportedAnnotationTypes是返回该工厂生成的注解处理器所能支持的注解类型;supportedOptions用来表示所支持的附加选项。在运行apt命令行工具的时候,可以通过-A来传递额外的参数给注解处理器,如-Averbose=true。当工厂通过 supportedOptions方法声明了所能识别的附加选项之后,注解处理器就可以在运行时刻通过AnnotationProcessorEnvironment的getOptions方法获取到选项的实际值。

而getProcessorFor方法返回的自定义注解处理器需要实现AnnotationProcessor接口,这个接口的处理逻辑都在其process方法中完成。通过一个声明(Declaration)的getAnnotationMirrors方法就可以获取到该声明上所添加的注解的实际值。得到这些值之后,处理起来就不难了。

在创建好注解处理器之后,就可以通过apt命令行工具来对源代码中的注解进行处理。 命令的运行格式是apt -classpath bin -factory annotation.apt.AssignmentApf src/annotation/work/.java*,即通过-factory来指定注解处理器工厂类的名称。实际上,apt工具在完成处理之后,会自动调用javac来编译处理完成后的源代码。

向APT提供工厂类有两个方式:一种是上述的通过APT的“-factory”命令行参数提供,或者让工厂类在APT的发现过程中被自动定位(关于发现过程详细介绍请看Getting Started with the Annotation Processing Tool (apt))。前者对于一个已知的factory来讲是一种主动而又简单的方式;而后者则是需要在jar文件的META-INF/services目录中提供一个特定的发现路径:在包含factory类的jar文件中作以下的操作:在META-INF/services目录中建立一个名为com.sun.mirror.apt.AnnotationProcessorFactory的UTF-8编码文件,在文件中写入所有要使用到的factory类全名,每个类为一个单独行。

JDK 5中的apt工具的不足之处在于它是Oracle提供的私有实现。在JDK 6中,通过JSR 269把自定义注解处理器这一功能进行了规范化,有了新的javax.annotation.processing这个新的API。对Mirror API也进行了更新,形成了新的javax.lang.model包。注解处理器的使用也进行了简化,不需要再单独运行apt这样的命令行工具,Java编译器本身就可以完成对注解的处理。对于同样的功能,如果用JSR 269的做法,只需要一个类就可以了。

与AspectJ的联系

学习完AspectJ的内容后,再回过头来结合注解的基础知识与高级应用中对于APT的介绍,就不难推测出ajc在编译期间正式借助于APT对自身框架中自定义的注解进行处理,从而将特定代码织入到项目中。同时我们也可以效仿这种方法,向APT提供我们自己的工厂类来实现自己需要的功能,比如进一步对AspectJ的规则进行校验等等,或者在运行时通过注解处理器动态地处理特定逻辑。

AOP

概述

AOP是Aspect Oriented Programming的缩写,即『面向切面编程』,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。它和我们平时接触到的OOP都是编程的不同思想。OOP,即『面向对象编程』,它提倡的是将功能模块化,对象化。AOP提倡的是针对同一类问题的统一处理,可以让我们在开发过程中把注意力尽可能多地放在真正需要关心的核心业务逻辑处理上,既提高工作效率,又使代码变得更加简洁优雅。

我们知道,面向对象的特点是继承、多态和封装。而封装就要求将功能分散到不同的对象中去,这在软件设计中往往称为职责分配。实际上也就是说,让不同的类设计不同的方法。这样代码就分散到一个个的类中去了。这样做的好处是降低了代码的复杂程度,使类可重用。

但是在分散代码的同时,w我们也在增加了代码的重复性。什么意思呢?比如说,我们在两个类中,可能都需要在每个方法中做日志。按面向对象的设计方法,我们就必须在两个类的方法中都加入日志的内容。也许他们是完全相同的,但就是因为在面向对象的设计中,无关的类是不应该有联系得,所以我们也就不能将这些重复的代码统一起来。

也许有人会说,那好办啊,我们可以将这段代码写在一个独立的类独立的方法里,然后再在这两个类中调用。但是,这样一来,这两个类不就跟这个独立的类有耦合了吗,它的改变会影响到两个类。那么,有没有什么办法,能让我们在需要的时候,随意地加入代码呢?这种在编译时或运行时,动态地将代码切入到类的指定方法、指定位置上的编程思想就是面向切面的编程。

一般而言,我们管切入到指定类指定方法的代码片段称为切面,而切入到哪些类、哪些方法则叫切入点。有了AOP,我们就可以把几个类共有的代码,抽取到一个切面中,等到需要时再切入对象中去,从而改变其原有的行为。这样看来,AOP其实只是OOP的补充而已。OOP从横向上区分出一个个的类来,而AOP则从纵向上向对象中加入特定的代码。有了AOP,OOP变得立体了,从技术上来说,AOP基本上是通过代理机制实现的。

综上所述,也就是说,如果几个或更多个逻辑过程中,有重复的操作行为,AOP就可以将其提取出来,运用代理机制,实现程序功能的统一维护,这么说来可能太含蓄,如果说到权限判断,日志记录等,可能就明白了。如果我们单纯使用OOP,那么权限判断怎么办?在每个操作前都加入权限判断?日志记录怎么办?在每个方法里的开始、结束、异常的地方手动添加日志?所有,如果使用AOP就可以借助代理完成这些重复的操作,就能够在逻辑过程中,降低各部分之间的耦合了。二者扬长补短,互相结合最好。OOP与AOP都是方法论,难学的都不是语法或者实现方式,难的是其中包含的看待问题的方法,以及如何从不同的角度来设计解决方案。

关于AOP的一些概念

  • 切面(Aspect):一个关注点的模块化,这个关注点实现可能另外横切多个对象。其实就是共有功能的实现。如日志切面、权限切面、事务切面等。
  • 通知(Advice):是切面的具体实现。以目标方法为参照点,根据放置的地方不同,可分为前置通知(Before)、后置通知(AfterReturning)、异常通知(AfterThrowing)、最终通知(After)与环绕通知(Around)5种。在实际应用中通常是切面类中的一个方法,具体属于哪类通知由配置指定的。
  • 切入点(Pointcut):用于定义通知应该切入到哪些连接点上。不同的通知通常需要切入到不同的连接点上,这种精准的匹配是由切入点的正则表达式来定义的。
  • 连接点(Joinpoint):就是程序在运行过程中能够插入切面的地点。例如,方法调用、异常抛出或字段修改等。
  • 目标对象(Target Object):包含连接点的对象,也被称作被通知或被代理对象。这些对象中已经只剩下干干净净的核心业务逻辑代码了,所有的共有功能等代码则是等待AOP容器的切入。
  • AOP代理(AOP Proxy):将通知应用到目标对象之后被动态创建的对象。可以简单地理解为,代理对象的功能等于目标对象的核心业务逻辑功能加上共有功能。代理对象对于使用者而言是透明的,是程序运行过程中的产物。
  • 编织(Weaving):将切面应用到目标对象从而创建一个新的代理对象的过程。这个过程可以发生在编译期、类装载期及运行期,当然不同的发生点有着不同的前提条件。譬如发生在编译期的话,就要求有一个支持这种AOP实现的特殊编译器(如AspectJ编译器);发生在类装载期,就要求有一个支持AOP实现的特殊类装载器;只有发生在运行期,则可直接通过Java语言的反射机制与动态代理机制来动态实现(如Spring)。
  • 引入(Introduction):添加方法或字段到被通知的类。

动态代理

AOP的实现手段之一是建立在Java语言的反射机制与动态代理机制之上的。业务逻辑组件在运行过程中,AOP容器会动态创建一个代理对象供使用者调用,该代理对象已经按程序员的意图将切面成功切入到目标方法的连接点上,从而使切面的功能与业务逻辑的功能都得以执行。从原理上讲,调用者直接调用的其实是AOP容器动态生成的代理对象,再由代理对象调用目标对象完成原始的业务逻辑处理,而代理对象则已经将切面与业务逻辑方法进行了合成。

动态代理分为JDK动态代理和CGLib动态代理,而代码织入方式分为动态织入与静态织入两大类,关于代理模式的一些细节不是本文的重点,在谈谈23种设计模式在Android源码及项目中的应用 中的代理模式章节已经有讲述过。

与AspectJ的联系

AspectJ会通过生成新的AOP代理类来对目标类进行增强,有兴趣的同学可以去查看经过ajc编译前后的代码,比照一下就会发现,假设我们要切入一个方法,那么AspectJ会重构一个新的方法,并且将原来的方法替代为这个新的方法,这个新的方法就会根据配置在调用目标方法的前后等指定位置插入特定代码,这样系统在调用目标方法的时候,其实是调用的被AspectJ增强后的代理方法,而这个代理类会在编译结束时生成好,所以属于静态织入的方式。

AspectJ

AspectJ实际上是对AOP编程思想的一个实践,当然,除了AspectJ以外,还有很多其它的AOP实现,例如ASMDex,但目前最好、最方便的,依然是AspectJ。AspectJ可以干净地模块化横切关注点,基本上可以实现无侵入,同时学习成本低,功能强大(甚至可以通过修改字节码来实现多继承),可扩展性高。

AspectJ 意思就是Java的Aspect,Java的AOP。它其实不是一个新的语言,它就是一个代码编译器(也就是AJC),在Java编译器的基础上增加了一些它自己的关键字识别和编译方法。因此,ajc也可以编译Java代码。它在编译期将开发者编写的Aspect程序编织到目标程序中,对目标程序作了重构,目的就是建立目标程序与Aspect程序的连接(耦合,获得对方的引用(默认情况下,也就是不使用this或target来约束切点的情况下,那么获得的是声明类型,不是运行时类型)和上下文信息),从而达到AOP的目的(这里在编译期还是修改了原来程序的代码,但是是AJC替我们做的)。

使用AspectJ有两种方法,一种是完全使用AspectJ的语言。这语言一点也不难,和Java几乎一样,也能在AspectJ中调用Java的任何类库。AspectJ只是多了一些关键词罢了。另一种是或者使用纯Java语言开发,然后使用AspectJ注解,也就是@AspectJ。当然不论哪种方法,最后都需要AspectJ的编译工具AJC来编译。

Spring AOP采用的动态织入,而AspectJ是静态织入。静态织入:指在编译时期就织入,即:编译出来的class文件,字节码就已经被织入了。动态织入又再分静动两种,静则指织入过程只在第一次调用时执行;动则指根据代码动态运行的中间状态来决定如何操作,每次调用Target Object的时候都执行。

使用Spring自己原生的AOP,你需要实现大量的接口,继承大量的类,所以Spring AOP一度被人所诟病,这与它的无侵入,低耦合完全冲突。不过Spring对开源的优秀框架,组件向来是采用兼容,并入的态度。所以,后来的Spring 就提供了Aspectj支持,也就是我们后来所说的基于纯POJO的AOP。

Android集成AspectJ

AOP的用处非常广,从Spring到Android,各个地方都有使用,特别是在后端,Spring中已经使用的非常方便了,而且功能非常强大,但是在Android中,AspectJ的实现是有所阉割的版本,并不是所有功能都支持,但对于一般的客户端开发来说,已经完全足够用了。

在Android上集成AspectJ实际上是比较复杂的,不是一行配置就能解决的,但是究其原理其实就是把java编译器替换为AJC。目前Github上已有多个可以应用在Android Studio上的插件,通过这些插件可以简单地在Android上集成AspectJ。

AspectJX
Hugo
gradle-android-aspectj-plugin
T-MVP

如果想自己集成的话,可以通过上述插件进行学习,实现方式都差不多,也可以通过Google自己查找相关资料,不过AspectJ的资料并不是很多,可能花费比较多的时间在试验上,所以这里直接给出方案。

首先在项目级的build.gradle中添加classpath:


classpath 'org.aspectj:aspectjtools:1.8.9'
接着在需要集成AspectJ的Module的build.gradle中添加依赖:

compile 'org.aspectj:aspectjrt:1.8.9'
最后在需要集成AspectJ的Module的build.gradle中添加如下代码:

import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main

final def log = project.logger
final def variants = project.android.applicationVariants
variants.all { variant ->
    log.error "========================";
    log.error "Aspectj切片开始编织Class!";
    log.error "========================";
    JavaCompile javaCompile = variant.javaCompile
    javaCompile.doLast {
        String[] args = ["-showWeaveInfo",
                         "-1.5",
                         "-inpath", javaCompile.destinationDir.toString(),
                         "-aspectpath", javaCompile.classpath.asPath,
                         "-d", javaCompile.destinationDir.toString(),
                         "-classpath", javaCompile.classpath.asPath,
                         "-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
        log.debug "ajc args: " + Arrays.toString(args)

        MessageHandler handler = new MessageHandler(true);
        new Main().run(args, handler);
        for (IMessage message : handler.getMessages(null, true)) {
            switch (message.getKind()) {
                case IMessage.ABORT:
                case IMessage.ERROR:
                case IMessage.FAIL:
                    log.error message.message, message.thrown
                    break;
                case IMessage.WARNING:
                    log.warn message.message, message.thrown
                    break;
                case IMessage.INFO:
                    log.info message.message, message.thrown
                    break;
                case IMessage.DEBUG:
                    log.debug message.message, message.thrown
                    break;
            }
        }
    }

AspectJ语法

AspectJ东西比较多,但是AOP做为方法论,它的学习和体会是需要一步一步,并且一定要结合实际来的。如果一下子学太多,反而会疲倦,一股脑把所有高级应用什么的都囫囵吞枣地学一遍,反而得不偿失。这就是是方法论学习和其他知识学习不一样的地方。

提醒:在学习AspectJ的语法时,将关键字与上面AOP的概念相结合,能够帮助自己更好地理解。

Join Points

Join Points 是AspectJ中最关键的一个概念,也是AOP中的基本概念。什么是Join Points 呢?Join Points 就是程序运行时的一些执行点。那么,一个程序中,哪些执行点可以是Join Points 呢?比如:

  • 一个函数的调用可以是一个Join Point。比如Log.e()这个函数。e的执行可以是一个Join Point,而调用e的函数也可以认为是一个Join Point。
  • 设置一个变量,或者读取一个变量,也可以是一个Join Point。比如某个类中有一个debug的boolean变量。设置它的地方或者读取它的地方都可以看做是Join Point。
  • for循环也可以看做是Join Point。

理论上说,一个程序中很多地方都可以被看做是Join Points,实际上,也就是你想把新的代码插在程序的哪个地方,是插在构造方法中,还是插在某个方法调用前,或者是插在某个方法中,这个地方就是Join Points。这里列出一张表格,里面列举了被AspectJ所认可的Joint Points:

简单直白粗暴点说,JoinPoint就是一个程序中的关键函数(包括构造函数)和代码段(staticblock)。

Pointcuts

前面介绍的内容可知,一个程序会有很多的JPoints,即使是同一个函数,还分为call类型和execution类型的JPoint。显然,不是所有的JPoint,也不是所有类型的JPoint都是我们关注的。那么怎么从一堆一堆的JPoints中选择自己想要的JPoints呢?这就是Pointcuts的功能:提供一种方法使得开发者能够选择自己感兴趣的JoinPoints。

AspectJ中,pointcut有一套标准语法,涉及的东西很多,还有一些比较高级的玩法。其实只要先掌握最常用的那些简单语法,而那些复杂的则是到实践中,确实有需求了,再去重新查阅文档进而实施就行了。

所以我们直接先来看一个例子,这里要说明一下,通常AspectJ需要编写aj文件,然后把AOP代码放到aj后缀名文件中。但是在Android开发中,建议不要使用aj文件。因为aj文件只有AspectJ编译器才认识,而Android编译器不认识这种文件。所以当更新了aj文件后,编译器认为源码没有发生变化,所以不会编译它。当然,这种问题在其他不认识aj文件的java编译环境中也存在。

所以,AspectJ提供了一种基于注解的方法来把AOP实现到一个普通的Java文件中。这样我们就把AOP当做一个普通的Java文件来编写、编译就好。这里给出一段非注解和注解的简单例子,后续的例子都将使用注解的方式。

非注解:


public pointcut  testAll(): call(public  *  *.println(..)) && !within(TestAspect) ;  
注解:

@Pointcut(“call(public  *  *.println(..)) && !within(TestAspect)")//方法切入点
public void testAll() { }

@Pointcut,表示这里定义的是一个Pointcut,在AspectJ中,你总是得定义一个Pointcut。
本例中,call(public * *.println(..))是一种选择条件。call表示我们选择的Joinpoint类型为call类型。
public * *.println(..):这小行代码使用了很多通配符。由于我们这里选择的JoinPoint类型为call类型,它对应的目标JPoint一定是某个或某些函数。所以我们要找到这个或这些函数。

  • public表示目标JPoint的访问类型(public/private/protect)。
  • 第一个*表示返回值的类型是任意类型。
  • 第二个*用来指明包名,此处不限定包名。
  • 紧接其后的println是函数名。

这表明我们选择的函数是任何包中定义的名字叫println的函数,访问类型为public,返回值任意。当然,要确定函数除了这些外,还有它的参数。

  • 在(..)中,就指明了目标函数的参数应该是什么样子的。比如这里使用了通配符..,代表任意个数的参数,任意类型的参数。

再来看call后面的&&:AspectJ可以把几个条件组合起来,目前支持 &&,||,以及!这三个条件,含义和Java中的是一样的。

所以最后的!within(TestAspectJ):前面的!表示不满足某个条件。within是另外一种类型选择方法,特别注意,这种类型和前面讲到的joinpoint的那几种类型不同。within的类型是数据类型,而JoinPoint的类型更像是动态的,执行时的类型。

所以,上例中的PointCut合起来的含义就是:

  • 选择那些调用public访问权限的println(不考虑println函数的参数是什么,返回值是什么,在哪个包中)的Joinpoint。
  • 另外,调用者的类型不是TestAspect的。

pointcuts中最常用的选择条件和JoinPoint的类型密切相关,这里再给出一张两者相对应的图:

以上图为例,如果我们想选择类型为methodexecution的JPoint,那么pointcuts的写法就得包括execution(XXX)来限定。

注意:execution截获的是方法真正执行的代码区,使用Around可以控制原方法执行与否,可以选择执行或者替换;而call截获的是方法的调用区,并不解惑代码真正的执行区域,它无法控制原来方法的执行与否,只是在方法调用前后插入切点,因此比较适合做一些轻量的监控(方法调用耗时等)

除了指定JPoint类型外,我们还要更进一步选择目标函数。选择的根据就是上图中列出的什么MethodSignature,ConstructorSignature,TypeSinature,FieldSignature等。名字听起来陌生得很,其实就是指定JPoint对应的函数(包括构造函数),Static block的信息。一个Method Signature的完整表达式为:


@注解 访问权限 返回值的类型 包名.函数名(参数) 

* @注解和访问权限(public/private/protect,以及static/final)属于可选项。如果不设置它们,则默认都会选择。以访问权限为例,如果没有设置访问权限作为条件,那么public,private,protect及static、final的函数都会进行搜索。  

* 返回值类型就是普通的函数的返回值类型。如果不限定类型的话,就用*通配符表示  
* 包名.函数名用于查找匹配的函数。可以使用通配符,包括*和..以及+号。其中*号用于匹配除.号之外的任意字符,而..则表示任意子package,+号表示子类。  比如:  
    * java.*.Date:可以表示java.sql.Date,也可以表示java.util.Date  
    * Test*:可以表示TestBase,也可以表示TestDervied  
    * java..*:表示java任意子类  
    * java..*Model+:表示Java任意package中名字以Model结尾的子类,比如TabelModel,TreeModel 等  
* 最后来看函数的参数。参数匹配比较简单,主要是参数类型,比如:  
    * (int, char):表示参数只有两个,并且第一个参数类型是int,第二个参数类型是char  
    * (String, ..):表示至少有一个参数。并且第一个参数类型是String,后面参数类型不限。在参数匹配中,  
    * ..代表任意参数个数和类型  
    * (Object ...):表示不定个数的参数,且类型都是Object,这里的...不是通配符,而是Java中代表不定参数的意思  

接下来介绍一些间接针对Jpoint的选择。除了根据前面提到的Signature信息来匹配JPoint外,AspectJ还提供其他一些选择方法来选择JPoint。比如某个类中的所有JPoint,每一个函数执行流程中所包含的JPoint。特别强调,不论什么选择方法,最终都是为了找到目标的JPoint。这里列出了一些常用的非JPoint选择方法:

注意:this()和target()匹配的时候不能使用通配符。

Advice

现在,我们知道如何通过Pointcuts来选择合适的JPoint。那么,下一步工作就很明确了,选择这些JPoint后,我们肯定是需要干一些事情的。

Advice其实是最好理解的,也就是我们具体插入的代码,以及如何插入这些代码。与我们介绍AOP概念时一样,以目标方法为参照点,根据放置的地方不同,可分为前置通知(Before)、后置通知(AfterReturning)、异常通知(AfterThrowing)、最终通知(After)与环绕通知(Around)5种。

基于刚刚testAll(),我们希望在这个Pointcuts选中的JPoint执行前做一些我们想做的事情,那么有两种写法:


/*
 * 直接替换@Pointcut为@Before
 */
@Before(“call(public  *  *.println(..)) && !within(TestAspect)")//方法切入点
public void testAll() { }

/*
 * 第二种方式
 */
@Pointcut(“call(public  *  *.println(..)) && !within(TestAspect)")//方法切入点
public void testAll() { }

@Before(“testAll()")
Public void doTestAll() { }

同样,这里也要列出AspectJ所支持的Advice的类型,在AS上将关键字改为注解即可:

注意,after和before没有返回值,但是around的目标是替代原JPoint的,所以它一般会有返回值,而且返回值的类型需要匹配被选中的JPoint。

注意:从技术上说,around是完全可以替代before和after的。而如果在对一个Pointcut声明Around之后还声明Before或者After则会报错。

TIPS:Around替代原理

目标方法体被Around方法替换,原方法重新生成,名为XXX_aroundBody(),如果要调用原方法需要在AspectJ程序的Around方法体内调用joinPoint.proceed()还原方法执行,是这样达到替换原方法的目的。

达到这个目的需要双方互相引用,桥梁便是Aspect类,目标程序插入了Aspect类所在的包获取引用。AspectJ通过在目标类里面加入Closure(闭包)类,该类构造函数包含了目标类实例、目标方法参数、JoinPoint对象等信息,同时该类作为切点原方法的执行代理,该闭包通过Aspect类调用Around方法传入Aspect程序。这样便达到了关联的目的,便可以在Aspect程序中监控和修改目标程序。

小结

至此,AspectJ的语法我们学了三大部分:

  • AspectJ中各种类型的JoinPoint,JPoint是一个程序的关键执行点,也是我们关注的重点。
  • Pointcuts:提供了一种方法来选择目标JPoint。程序有很多JPoint,但是需要一种方法来让我们选择我们关注的JPoint。这个方法就是利用pointcuts来完成的。
  • 通过pointcuts选择了目标JPoint后,我们总得干点什么吧?这就用上了advice。advice包括好几种类型,一般情况下都够我们用了。

Aspect

在小结里面说到的三个部分其实都有点像函数定义,在Java中,这些东西都是要放到一个class里的。在AspectJ中,也有类似的数据结构,叫Aspect。在不使用注解的时候,代码看起来如下:


public aspect 名字 {//aspect关键字和class的功能一样,文件名以.aj结尾
pointcuts定义...  
advice定义...  
}  

而使用注解只需要在类的头部使用@Aspect注解即可,而文件名仍然以.java结尾。
通过上述两种方式,我们定义了一个Aspect类,就把相关的JPoint和advice包含起来,形成了一个“关注面”。比如:

  • 我们定义一个LogAspect,在LogAspect中,我们在关键JPoint上设置advice,这些advice就是打印日志
  • 再定义一个SecurityCheckAspect,在这个Aspect中,我们在关键JPoint上设置advice,这些advice将检查调用app是否有权限。

通过这种方式,我们在原来的JPoint中,就不需要写Log打印的代码,也不需要写权限检查的代码了。所有这些关注点都挪到对应的AspectJ文件中来控制,这就是AOP的精髓。

控制流cflow

这个内容在Android的AspectJ注解版本里没有看到,但是仍然拿出来简单介绍一下。
那么什么叫控制流? 我们先理清这个概念。先看一段代码:


public class TestCfow {  

    public void foo(){  
        System.out.println("foo......");  
    }  

    public void bar(){  
        foo();  
        System.out.println("bar.........");  
    }  

    @Test  
    public void testMethod(){  
        bar();  
        foo();  
    }  
}  

用流程图画出testmethod()的流程:

这就是testMehtod()的控制流。那么cfow(execution(* testMethod())) 就是获取testMethod()的控制流。他将拦截到testMethod中的每行代码(包括:他流程里面调用的其它其他方法的流程,不管调用层次有多深,都属于他的控制流)。

注意:其实这里说是每行代码是不准的,其实是每行代码编译后的字节码。比如System.out.println() 其实编译后是3句话。

接下来我们写一个cflow的demo并且看看结果:


public aspect CfowAspect {  
    pointcut barPoint() :  execution(* bar(..));  
    pointcut fooPoint() : execution(* foo(..));  
    pointcut barCfow() : cflow(barPoint());//cflow的参数是一个pointcut  
    pointcut fooInBar() : barCfow() && fooPoint();  //获取bar流程内的foo方法调用  

    before() : barCfow(){  
        System.out.println("Enter:" + thisJoinPoint);  
    }  

}  

为什么会StackOverFow呢。 其实是这样的:cflowAspect织入了Bar(),所以他也算是bar的控制流的一部分, 这样一来,他就自己拦截自己,形成一个死循环,所以就溢出了。
那么让我们使用within来修改一下代码:


pointcut barCfow() : cflow(barPoint()) && !within(CfowAspect);  

再运行testMethod(),打印结果如下:


Enter:execution(void com.aspectj.demo.test.TestCfow.bar())  
Enter:call(void com.aspectj.demo.test.TestCfow.foo())  
Enter:execution(void com.aspectj.demo.test.TestCfow.foo())  
Enter:get(PrintStream java.lang.System.out)  
Enter:call(void java.io.PrintStream.println(String))  
foo......  
Enter:get(PrintStream java.lang.System.out)  
Enter:call(void java.io.PrintStream.println(String))  
bar.........  
foo......  

每条打印,都可以看出是拦截的那句话,同时这个结果也验证了上面需要注意的那句话。始终要记得:aspectj是静态织入,所以他拦截的是字节码。
现在我们改变一下需求: 只拦截bar方法调用里面的foo()方法,也就是说我们testMethod()里面的foo() 调用不要拦截。


public aspect CfowAspect {  

    pointcut barPoint() :  execution(* bar(..));  
    pointcut fooPoint() : execution(* foo(..));  
    pointcut barCfow() : cflow(barPoint()) && !within(CfowAspect);//cflow的参数是一个pointcut  
    pointcut fooInBar() : barCfow() && fooPoint();  //获取bar流程内的foo方法调用  

    before() : fooInBar(){  
        System.out.println("Enter:" + thisJoinPoint);  
    }  

}  

再次运行查看打印结果:


Enter:execution(void com.aspectj.demo.test.TestCfow.foo())  
foo......  
bar.........  
foo......  

发现是不是只有bar方法里面的foo()被拦截了? 用Spring AOP你就无法实现这个需求吧?是不是突然觉得aspectj真的很强大?但是这只是AspectJ强大的表现之一。 cfow()获取的是一个控制流程。他很少单独使用,一般与其他的pointcut 进行 &&运算。若要单独使用,一定要记得用!with()剔除asepct 本身。虽然目前在Android上没有发现和cflow有关的注解,但是在源码里有看到和控制流有关的类,所以我相信肯定有办法实现这个功能的,后续有这方面的需求做了再补上Android版的。

target()与this()

之前我们就说过,AspetcJ是属于静态织入的,但是其实AspectJ也有动态织入的部分,而target()与this()就是属于它动态织入的方式,像within()就是静态织入的。所以target()与this()需要在在运行时才能确定那些被拦截,同时target()和this()存在继承关系作用,也就是说:如果你的signature是一个基类,那么这个pointcut同时也会对他的子类也起作用。

另外target 和 this 可以获取他们对应的实例:

  • target()是指:我们pointcut 所选取的Join point 的所有者,直白点说就是: 指明拦截的方法属于那个类。
  • this()是指: 我们pointcut 所选取的Join point 的调用的所有者,就是说:方法是在那个类中被调用的。

参数传递与ProceedingJoinPoint

参数传递

到此,AspectJ最基本的东西其实讲差不多了,但是在实际使用AspectJ的时候,你会发现前面的内容还欠缺一点,尤其是advice的地方。

前面介绍的advice都是没有参数信息的,而JPoint肯定是或多或少有参数的。而且advice既然是对JPoint的截获或者hook也好,肯定需要利用传入给JPoint的参数干点什么事情。比方所around advice,我可以对传入的参数进行检查,如果参数不合法,我就直接返回,根本就不需要调用proceed做处理。

往advice传参数比较简单,就是利用前面提到的this(),target(),args()等方法。另外,整个pointcuts和advice编写的语法也有一些区别。具体方法如下:
先在pointcuts定义时候指定参数类型和名字


Before(“execution(* Test.TestDerived.testMethod(..)) && args(x) && target(derived)”)
public void testAll(int x, TestDerived derived) {
System.out.println(“arg1=” + derived);
System.out.println(“arg2=” + x);
}

注意上述pointcuts的写法,首先在testAll中定义参数类型和参数名。这一点和定义一个函数完全一样,接着看target和args。此处的target和args括号中用得是参数名。而参数名则是在前面pointcuts中定义好的。这属于target和args的另外一种用法。

注意,增加参数并不会影响pointcuts对JPoint的匹配

而advice的定义现在也和函数定义一样,把参数类型和参数名传进来。
注意,advice必须和使用的pointcuts在参数类型和名字上保持一致。

总结,参数传递其实并不复杂,关键是得记住语法:

  • pointcuts修改:像定义函数一样定义pointcuts,然后在this,target或args中绑定参数名(注意,不再是参数类型,而是参数名)。
  • advice修改:也像定义函数一样定义advice,然后在函数参数列表中绑定参数名(注意是参数名)
  • 在advice的代码中使用参数名。

ProceedingJoinPoint

参数传递还有另一种方法,就是使用ProceedingJoinPoint对象,这个对象不仅可以获取方法参数,还可以获取所处代码位置等等信息:


@Around(execution(* Test.TestDerived.testMethod(..))”)
public void testAll(ProceedingJoinPoint joinPoint) throws Throwable {
System.out.println(“arg1=” + joinPoint.getTarget());
System.out.println(“arg2=” + joinPoint.getArgs()[0]);
joinPoint.proceed();
}

关于ProceedingJoinPoint更详尽的内容,可以到官网直接查看API文档。

其他功能

AspectJ可以应用在很多功能上,有一些并不是非常常用,比如

  • 动态修改类的自节码: 给类增加field method ,让类继承多个类,实现更多接口等等
  • 对范型的支持: 也就是说pointcut 支持范型
  • 支持自动Wrap类,自动装箱和拆箱
  • 支持枚举。

有兴趣的同学可以自己参考他的官方网站研究研究,这里主要是跟大家讲一下,AspectJ的确强大的有点过分。

总结

因为AspectJ的使用需要匹配一些明确的Join Points,所以建议是在项目开发或重构完毕,或者是确定这个匹配规则不需要太多改动的时候来接入AspectJ,因为如果Join Points的函数命名等因素改变了,那么对应的匹配规则没有跟着改变,就有可能导致匹配不到指定的内容而无法在该地方插入自己想要的功能,不过这个和AspectJ的强大比起来其实算不上是什么问题,而且如果追求完美的话,甚至可以在APT编译期间加入自己的逻辑,判断是否有匹配的内容并不存在,如果不存在则抛出异常。

AspectJ的应用场景非常广,绝不仅局限于java文件的操作。在Android中,我们可以通过匹配所有方法,打印其运行时间来查看性能是否存在问题等,也可以结合自定义注解控制整个app的权限检查等,包括项目中时常需要加在业务逻辑中的统计代码也可以使用AspectJ进行抽离等等。

AspectJ只是一个框架,AOP只是一个思想,这两者涉及的内容要学习都不难,重点在于转变开发思维以及如何实际地使用它们来改善项目,这和重构、设计模式、响应式编程等等都是一样的,技术的学习在于应用,包括技术在内的很多事物如果无法落地,那么最终都只会沦为谈资。

参考

Android基于AOP的非侵入式监控之——AspectJ实战
深入理解Android之AOP
Android基于AOP的非侵入式监控之——AspectJ实战
看AspectJ在Android中的强势插入
简单理解AOP(面向切面编程)
面向切面编程(AOP)的理解
什么是面向切面编程AOP
跟我学aspectj系列
AspectJ语法详解
Java注解的基础与高级应用


版权声明

Creative Commons BY-NC-ND 4.0 International License

Lam’s Blog by Binghe Lin is licensed under a Creative Commons BY-NC-ND 4.0 International License.
林炳河创作并维护的Lam’s Blog采用创作共用保留署名-非商业-禁止演绎4.0国际许可证

本文首发于Lam’s Blog - Knowledeg as Action,版权所有,侵权必究。

本文永久链接:http://linbinghe.com/2017/65db25bc.html