您好!
欢迎来到京东云开发者社区
登录
首页
博文
课程
大赛
工具
用户中心
开源
首页
博文
课程
大赛
工具
开源
更多
用户中心
开发者社区
>
博文
>
AspectJ浅析系列(二)-切入点和通知
分享
打开微信扫码分享
点击前往QQ分享
点击前往微博分享
点击复制链接
AspectJ浅析系列(二)-切入点和通知
自猿其说Tech
2021-08-13
IP归属:未知
27320浏览
计算机编程
### 前言 本文介绍了切入点的种类,以及就多个较为常用的切入点进行了浅显的解读,将多个切入点相互引用的问题分情况进行了讨论。接着就五种通知的应用例子做了清晰的阐述,继而针对于在不同情况下,生效优先级的问题进行了一点思考,并给出了我结论所用到的实例。为了方便读者复现,于文末附上项目链接。 AspectJ有两种文件格式,一种是原生的 *.aj的文件格式。 另一种是将@aspect注解在普通 *.java的文件格式,本文所使用的仍然是第二种方式。 ### 1 切入点 第一篇Hello World里面选择的横切切入点是execution,是直接定义到了该方法。但是实际业务的时候,肯定要用到通配符的,第一篇里没有讲,所以在这里进行了较为浅显的总结和分析。 #### 1.1 @Aspect 为什么类上面要加上@Aspect的注解呢?原因是有这个注解的类才可以包含@Point的切入点注解,五种通知注解(本文的2.1至2.5)以及相关的声明注解(会在第五篇详细介绍)等内容,有这个注解的类才可以完成基础的织入功能。 这个注解不能修饰接口,而只能是类。 除去专有的语法,原生的aspect与用@aspect注解的java文件有什么区别? ![](//img1.jcloudcs.com/developer.jdcloud.com/1793bd4e-e665-4a47-ab01-51550ced6abf20210813135457.png) #### 1.2 @Point 那么@Point的切入点又是要怎么理解呢?一般来说需要使用@Pointcut注解配合一个占位用的方法声明来定义一个切入点(PointCut),然后在通知里面直接引用切入点或者是使用组合(详情可以参看本文的1.3)的方式引用多个切入点实现选择。 不过在理解下面的内容前,我们首先要理解“切入点”都包含了哪些? ![](//img1.jcloudcs.com/developer.jdcloud.com/dc4b74e1-6ced-47e3-90d5-6ea904e7a99f20210813143452.png) 为什么我推崇的说AspectJ是一个完全的解决方案呢?一个原因就在上面了! 选择No.1还是N0.2是要看实际的情况的。例如,一个方法只要运行就一定要有织入效果,那么自然是选择No.1的好。如果一个方法不同的地方被调用了十次,而我只想要在第三次和第四次调用的时候触发,那么自然就是选择No.2了(与下面介绍的this,target等组合,详情见1.6)。 对于No.3和No.4,如果类没有显示构造,就会自动暴露出来隐式构造的。其实这个时候区别其实不大的。 对于No.5和No.6,如果遇到了需要确认缓存数据是否是最新的,或者修改参数后标记该对象的情况,就可以使用了。 No.7是抓住异常,处理异常的,这里不做更多的展开。 No.8是对应了类的静态代码块,一般可以用来实现记录类的加载时间(you can use it to weave class load-time actions.)。 No.9更多的还是继承类的时候,对应于从返回父类的构造函数,直到第一个调用的构造函数结束的时间段。不同之处在于No.3只发生在第一个被调用的构造函数中对于层次结构中的每种类型(unlike a constructor-execution join point, occurs only in the first called constructor for each type in the hierarchy)而No.8只是发生在加载一个类的时候(Unlike class-initialization that occurs when a class loader loads a class)。 No.10在构造对象类的过程中super方法会调用其他的方法来实现参数的构造,此时只对应了其他方法的调用,与No.2相反。 No.11是AspectJ的神来之笔,因为他把自己的通知也暴露了出来!用于分析通知本身,遗憾的是只能够用原生语法实现。如果用注解语法的方式实现的话,它将选择表示建议的方法。(If you used @AspectJ syntax to represent the aspect, it would select the methods representing advice.) **接下来用一套样例来方便大家理解:** - cn.jdl.ka.service.KeenService.java: ```java package cn.jdl.ka.service; public class KeenService { private int KEEN_INT; public int getKEEN_INT() { //`No.5` return KEEN_INT; } public void setKEEN_INT(int KEEN_INT) { //`No.6` this.KEEN_INT = KEEN_INT; } //两个方法都属于No.3 public KeenService(){ setKEEN_INT(2); } public KeenService(int keenInt){ setKEEN_INT(2); } //No.1 public int div(int a,int b){ System.out.println("计算"+a+"除"+b+"的结果"); return a/b; } } ``` - cn.jdl.ka.KeenMain.java ```java package cn.jdl.ka; public class KeenMain { public static void main(String[] args) { //No.4 KeenService keenService = new KeenService(); try{ // No.2 只算div(8,2)的时候,不算Math.addExact(4,4)的时候 keenService.div(Math.addExact(4,4),2); }catch (Throwable throwable){ //`No.7` System.out.println(throwable); } } } ``` - cn.jdl.ka.aspect.KeenPointCutAspect3.java ```java package cn.jdl.ka.aspect; @Aspect public class KeenPointCutAspect3 { //切入点 @Pointcut("within(cn.jdl.ka.service.KeenService) && within(!KeenPointCutAspect3)") private void pointCut() {} @Before("pointCut()")//前置通知 public void beforeDiv(JoinPoint point) { System.out.println(point); System.out.println("该切入点的类型为:"+point.getKind()); } } ``` 举个浅显的例子,使用within织入服务类cn.jdl.ka.service.KeenService,就可以顺序地看到对应的具体切入点了: ```java //No.8类初始化 staticinitialization(cn.jdl.ka.service.KeenService.<clinit>) //No.10对象预初始化 preinitialization(cn.jdl.ka.service.KeenService()) //No.9对象初始化 initialization(cn.jdl.ka.service.KeenService()) //No.3构造函数运行 execution(cn.jdl.ka.service.KeenService()) //No.2方法调用 call(void cn.jdl.ka.service.KeenService.setKEEN_INT(int)) //No.1方法运行 execution(void cn.jdl.ka.service.KeenService.setKEEN_INT(int)) //No.6字段写 set(int cn.jdl.ka.service.KeenService.KEEN_INT) //No.1方法运行 execution(int cn.jdl.ka.service.KeenService.div(int, int)) //No.5字段读(out是个静态的IO类型变量) get(PrintStream java.lang.System.out) //No.4构造方法调用 call(java.lang.StringBuilder(String)) //剩下的就全是方法调用了,不再赘述。 ``` 上面的过程中展示了除去异常处理(No.7)的所有切入点类型语法。 在AspectJ in Action第二版第三章里面有更详细的介绍和例子,这里不再详细展开。 ##### 1.2.1 execution execution:用于匹配方法,粒度是方法级别的。 首先介绍已经熟悉的execution,在之前的Hello World里面已经见过面了。 他的核心就是一句话:返回值类型,方法名和参数共三个部分必须有,其他的都可以省略的。 ![](//img1.jcloudcs.com/developer.jdcloud.com/c7621d57-f278-4a1c-8181-ea3c83014b2820210813143718.png) 为了理解的更透彻一些,在服务类KeenService.java中新增一个函数: ```java /** * 两数相除,除数为0会报异常 */ public int div(int a,int b){ System.out.println(a+"除"+b+"结果为:"+ a/b); return a/b; } ``` 主入口KeenMain.java是: ```java public static void main(String[] args) { KeenService keenService = new KeenService(); keenService.div(4,2); } ``` 该类中的两个切入点其实是一致的: ```java package cn.jdl.ka.aspect; @Aspect public class KeenPointCutAspect { //切入点 @Pointcut("execution(public int cn.jdl.ka.service.KeenService.div(int,int)) ") public static void pointCut() { } @Pointcut("execution( int div(int,int))") public static void pointCut2() { } @Before("pointCut()") public void beforeDiv(JoinPoint joinPoint) { System.out.println("两数相除"); } @Before("pointCut2()") public void beforeDiv2(JoinPoint joinPoint) { System.out.println("两数相除2"); } } ``` 结果为: ![](//img1.jcloudcs.com/developer.jdcloud.com/7098bff3-288c-47c1-a415-35bb2a86a65720210813144230.png) 说的再深入一些,因为有三点不可以省略,所以细分为三块内容: - 返回值 如果要切入的一批方法都是int返回值自然好的,不过实际应用的过程中,更多的还是用一个*来代替所有类型的存在。 - 限定方法名 两个切入点就毫无区别了么?不是的,因为第二个切入点没有限定名,会导致属于其他服务类中名为div且有两个int参数的函数也被beforeDiv2通知所影响到,所以强烈建议加上限定名。否则在之后出问题,排查是很让人头大的。 ```java div() : 匹配任意类的所有名称为div的无参方法 div*() : 匹配任意类的名称以div开头的无参方法 *div() : 匹配任意类的名称以div结尾的无参方法 ``` 说完方法的延伸,那么类呢?不急,留在后面的within详细介绍。 - 参数 ```java () : 匹配不带参数的方法 (..) : 匹配任何类型且有任意数量(零个或多个)参数的方法 (*) : 匹配任何类型的一个参数的方法 (*,String) : 匹配第一个是任何类型,而第二个必须是字符串的方法 ``` 匹配是根据方法声明的参数类型进行匹配的,是在程序编写的时候就确定了,而不是根据执行时传入的参数类型决定的,如一个服务方法的参数为Object obj即使执行时传入java.util.Date成功运行了服务,也不会匹配切入点的。 ##### 1.2.2 call call和execution一样,粒度也是方法级别的,语法和参数是一致的。 区别有以下两点: - 触发的条件不同call是对应于method-call类型,是方法的调用点;而execution是method-execution,是在方法执行点。 - 在 call 中, this 和 target 不是指向同一个类;在 execution 中, this 和 target 指向同一个类 。 ##### 1.2.3 within 接下来介绍within。 within:用于匹配类内的所有方法,粒度是类级别的。 他的核心也是一句话:抓类的一切方法!这个一切包括了:类加载(clinit),初始化(init),公开方法(div),方法内调用的方法(类似java.lang.System.out和java.lang.StringBuilder.append(),等等等!实在是太多了。) 更多的时候,他的存在是为了排除对某个类的织入,比如说最常用的就是排除所有的切面文件: ```java @PointCut("!within(*Aspect)") public void pointCut() {} ``` 因为必填项只有一个类的限定名而已。所以再详细介绍一下如何限定类: ```java KeenService : 任何目录下名字为KeenService的类 KeenService+ : 任何目录下KeenService的类及其子类 Keen* : 任何目录下以Keen开头的类 *Keen : 任何目录下以Keen结尾的类 java.*.Date : java的二级子包下面所对应的日期类 java..* : java的所有子包下面的所有类 ``` 但是!这些都只是对应于普通的类而已,难道这就是AspectJ的全部潜力了么?当然不是的!他还可以匹配到注解和泛型的! 首先新建一个类和方法级别的注解 ```java @Inherited @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface Keen { //该注解实例是否禁用 boolean unable() default false; //字符串内容 String STRING() default ""; } ``` 在匹配的时候: ```java @Keen KeenService : 匹配所有存在@keen注解的KeenService类 @Keen * : 匹配任何存在@Keen注解的方法和类 @Ke* KeenService+ : 任何目录下有着以Ka开头的注解的KeenService的类及其子类 ``` 再说泛型的例子: ```java Map<Long,KeenService> : 匹配map对象,且仅仅只有第一个参数为Long类型,第二个为KeenService类型 * <KeenService> : 类似Collection<KeenService>和Set<KeenService>等 Collection <@Keen *> : 所有的Collection且参数为被该注解引用了的情况 ``` 难以穷举,不过其强大可见一斑。 ##### 1.2.4 args 浅显来看,其实args的关键字的作用就是两个: - 根据方法参数的个数和类型筛选切入点 - 获取参数列表 举个例子: ```java package cn.jdl.ka.aspect; @Aspect public class KeenArgsAspect1 { //切入点 @Pointcut("execution(public int cn.jdl.ka.service.KeenService.div(int,int))") public void pointCut() { } @Before(value = "pointCut() && args(a,b)", argNames = "a,b") public void beforePrintParameter(int a, int b) { System.out.println("打印参数:" + a + ";" + b); } } ``` 在通知的参数中直接引用参数类型,而不再是JoinPoint类型。更直接了但同时获得的信息也更少了。 语法规则是要求全限定名,而且不支持通配符。 在初学AspectJ的时候,我在这里难以抑制地对这个关键字产生了怀疑,真的还有继续学习该关键字的必要么,论据有二: - 从上面within和execution语法了解了之后,会发现已经提供了根据方法参数的个数和类型筛选切入点的功能。 - 在接下来要介绍的通知内容中,可以从JoinPoint对象的getArgs()方法获取参数列表。 抱着怀疑的态度继续阅读AspectJ in Action和网上搜寻资料。我又发现了一个之前忽略的内容。 为了理解的更透彻一些,在服务类KeenService.java中新增一个函数: ```java /** * 打印字符串 */ public void printString(StringBuffer str){ System.out.println(str); } ``` 新建一个切面演示args和call的区别,在切入点上方的注释里面我点明了会织入的方法: ```java package cn.jdl.ka.aspect; @Aspect public class KeenArgsAspect2 { //call(void cn.jdl.ka.service.KeenService.printString(StringBuffer)) //call(void java.io.PrintStream.println(Object)) @Pointcut("args(java.lang.StringBuffer)&& call(* *(..))") public void pointCutArgs() { } //call(void cn.jdl.ka.service.KeenService.printString(StringBuffer)) @Pointcut("call(* *(java.lang.StringBuffer))") public void pointCutCall() { } @Before("pointCutCall()") public void beforePrint(JoinPoint joinPoint) { System.out.println("切入点为:"+joinPoint); } } ``` args关键字可以抓到的切入点很多,最明显的就是方法运行和方法调用,在这里为了凸显区别,都指定在了方法调用的切入点上。 - args(java.lang.StringBuffer)是针对程序运行时的入参类型。 - call(* *(java.lang.StringBuffer))是针对程序编写时的声明类型 很明显,println(Object)方法的入参是StringBuffer类的父类,但是仍然被args关键字所捕捉到了! 再次整理一下思路,抓取切入点是细分为两种时机的: - 一种是程序编写出来后的声明类型,关键字包括execution,call,within等。 - 另一种是程序运行时,比对运行的方法是否符合,关键字包括args,this,target,if和注解等。 ##### 1.2.5 @annotation AspectJ真的很强大,甚至支持根据类型、方法和字段携带的注解进行选择! 继而衍生出来了@this、@target、@args、@within和@withincode共五种更多的注解使用方式 这里不准备详细展开,下一篇的自定义注解就是他的主场。 在cn.jdl.ka.component新建一个注解Keen.java: ```java package cn.jdl.ka.component; @Inherited @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface Keen { //该注解实例是否禁用 boolean unable() default false; //字符串内容 String STRING() default ""; } ``` 化用书中的表格,给大家简单介绍一下: ![](//img1.jcloudcs.com/developer.jdcloud.com/b62b4a7d-8fba-497d-8ef3-373ef060fa1220210813144915.png) 自我感觉翻译的很差,还是附上书中84页的原内容,方便大家理解。 ![](//img1.jcloudcs.com/developer.jdcloud.com/d1f9c60d-c79b-4175-9bb1-a80d16c3b08420210813145007.png) ##### 1.2.6 this 和 target 此处介绍的是切入点语法中的关键字,作用是用来辅助筛选切入点的! - target(A)筛选出来的切入点,一定是属于A类的。 this(A)筛选出来的切入点,一定是在A类中被调用的。 为了更方便理解,用一个接口,两个实现类,一个服务类和一个切面类构造了一个项目,希望对读者能够有所帮助。整个项目中存在三个move函数,都有调用,都有运行。 注意:两个关键字中使用的表达式必须是全限定名,而且不支持通配符! 项目整体结果如下: ![](//img1.jcloudcs.com/developer.jdcloud.com/d6ed100a-9c4c-4345-8163-60bc474f57c920210813145042.png) 一个接口和两个实现类: ```java package cn.jdl.ka.service; public interface Animal { public void move(); } package cn.jdl.ka.service.impl; public class Bird implements Animal { public void move() { System.out.println("鸟在飞"); } } package cn.jdl.ka.service.impl; public class Snake implements Animal { public void move() { System.out.println("蛇在爬"); } } ``` 服务类和主入口类: ```java package cn.jdl.ka.service; public class KeenService { public void move(List<Animal> list){ for(Animal a : list){ a.move(); } } public void Do() { List<Animal> list = new ArrayList<Animal>(); list.add(new Bird()); list.add(new Snake()); move(list); } } package cn.jdl.ka; public class KeenMain { public static void main(String[] args) { new KeenService().Do(); } } ``` 以及最重要的切面类,所会捕捉到的切入点用注释的方式写在了方法内部: ```java package cn.jdl.ka.aspect; @Aspect以及最重要的切面类,所会捕捉到的切入点用注释的方式写在了方法内部: class ThisAndTargetAspect { //包括一次KeenService的move方法调用,和两次Animal的move方法调用 @Pointcut("call(* move(..))") public static void pointCutCall() { } //包括接口Animal,具体类Bird的初始化,Bird对象的初始化和move方法运行,接口方法的调用 @Pointcut("target(cn.jdl.ka.service.impl.Bird)") public static void pointCutTarget1() { } //共十个,两个子类各有五个;以Bird为例,类初始化,接口初始化,对象初始化,Animal方法的调用和Bird方法的运行 @Pointcut("target(cn.jdl.ka.service.Animal)") public static void pointCutTarget2() { } //共六个,类的初始化,类对象初始化,两个方法分别的调用和运行 @Pointcut("target(cn.jdl.ka.service.KeenService)") public static void pointCutTarget3() { } //包括接口Animal,具体类Bird的初始化,Bird对象的初始化和move方法运行,加上内部对out的对象获取值对println的调用 @Pointcut("this(cn.jdl.ka.service.impl.Bird)") public static void pointCutThis1() { } //共12个,两个子类各六个;以Bird为例,类初始化,接口初始化,对象初始化,move方法的运行,加上内部对out的对象获取值对println的调用 @Pointcut("this(cn.jdl.ka.service.Animal)") public static void pointCutThis2() { } //共18个,除去构造,调用和运行,还需要说一句,加上了ArrayList的构造函数调用和List.add(Object)方法的调用 @Pointcut("this(cn.jdl.ka.service.KeenService)") public static void pointCutThis3() { } /* 开始组合! */ //call(void cn.jdl.ka.service.Animal.move()) @Pointcut("pointCutCall() && pointCutTarget1()") public static void pointCutCompose1() { } //call(void cn.jdl.ka.service.Animal.move()),target对象为:cn.jdl.ka.service.impl.Bird //call(void cn.jdl.ka.service.Animal.move()),target对象为:cn.jdl.ka.service.impl.Snake @Pointcut("pointCutCall() && pointCutTarget2()") public static void pointCutCompose2() { } //call(void cn.jdl.ka.service.KeenService.move(List)),target对象为:cn.jdl.ka.service.KeenService @Pointcut("pointCutCall() && pointCutTarget3()") public static void pointCutCompose3() { } //一个也没有 @Pointcut("pointCutCall() && pointCutThis1()") public static void pointCutCompose4() { } //一个也没有 @Pointcut("pointCutCall() && pointCutThis2()") public static void pointCutCompose5() { } //call(void cn.jdl.ka.service.KeenService.move(List)),this对象为:cn.jdl.ka.service.KeenService //call(void cn.jdl.ka.service.Animal.move()),this对象为:cn.jdl.ka.service.KeenService //call(void cn.jdl.ka.service.Animal.move()),this对象为:cn.jdl.ka.service.KeenService @Pointcut("pointCutCall() && pointCutThis3()") public static void pointCutCompose6() { } //依次修改引用,可以慢慢理解 @Before("pointCutCompose6()") public void beforeMove(JoinPoint point) { System.out.println("抓到切入点为:"+point+"所在的限定名"+point.getSourceLocation().getWithinType()+",行数:"+point.getSourceLocation().getLine()); System.out.println("this对象为:"+point.getThis()+",target对象为:"+point.getTarget()); } } ``` 这一段内容我建议读者亲自运行和修改,为了节省精力,可以直接拉取文末我附上的项目。 ##### 1.2.7 if 没错在切入点的时候还支持条件筛选! 通常用上面的方式,切入点方法只是占位,倘若编写函数体就会报错:0 Pointcuts without an if() expression should have an empty method body 而编译报错中的if()就是这里介绍到的一个东西,这个切入点基于连接点的一些条件检查来选择连接点。 在本文使用@aspect的注解方式去复现时,if关键字仍然起效的。内部表达式有一定的限制(The boolean expression used can only access static members, parameters exposed by the enclosing pointcut or advice, and thisJoinPoint forms): - 可以用一个静态变量的值来判断是否可以织入某个通知。 - 可以获得系统变量进行比对 - 可以获得切入点上下文来进行比对 ```java package cn.jdl.ka.aspect; @Aspect public class KeenIfAspect { static boolean tracked = true; @Pointcut("if()") public static boolean tracked() { return tracked; } @Pointcut("if()") public static boolean systemTime() { return System.currentTimeMillis()>300 ; } @Pointcut("if()") public static boolean joinPointCut(JoinPoint point) { // 被除数>=18就织入,否则就不织入 int a=(Integer) point.getArgs()[0]; return a>=18?true:false; } //切入点 @Pointcut("execution(* cn.jdl.ka.service.KeenService.div(int,int))") public void pointCut() { } @Before("pointCut() && joinPointCut(JoinPoint)") public void beforePrintParameter(JoinPoint point) { System.out.println("成功织入方法"); } } ``` 可以得到结果: ![](//img1.jcloudcs.com/developer.jdcloud.com/3d3f5219-d225-4128-9ade-413f8668ba3620210813145307.png) 需要特别注意一下的就是,它不能在切面中调用非静态方法,也不能使用 after 通知公开的返回值或异常。 #### 1.3 类内引用 其实这里更多的是多个切入点如何联合工作。 支持三种逻辑关系表达式分别是与(&&),或(||),非(!)。除这三者外,更重要的还是合理使用小括号,将所需要的表达式用小括号括起来,再用逻辑关系表达式效果更好。 有的时候用一个通配符或许就可以替代了多个匹配。 ```java @Pointcut("execution(public void cn.jdl.ka.service.KeenService.printHelloWorld())") public static void pointCut() { } @Pointcut("execution(public int cn.jdl.ka.service.KeenService.div(int,int))") public static void pointCut2() { } @Before("pointCut() && pointCut2()") public void beforePrintHelloWorld(JoinPoint joinPoint) { System.out.println("前置通知>>你好,世界!"); } //其实上面的三个组合等价于下面的语句 @Before("execution(public * cn.jdl.ka.service.KeenService.*(..))") public void beforePrintHelloWorld2(JoinPoint joinPoint) { System.out.println("前置通知>>你好,世界!"); } ``` 更多的例子难以穷举,不过要多说一点,就是一个切入点只能够对应一种情况! 这是什么也匹配不到的: ```java @Pointcut("execution(* KeenService.*(..)) && call(* KeenService.*(..))") public static void pointCut() { } ``` 所以只需要牢记:不要将两种切入点用与符号连接(don’t combine two pointcuts of different kinds with the && operator.) #### 1.4 类间引用 需要切入点设置成为公开的静态方法才可以: ```java package cn.jdl.ka.aspect; @Aspect public class KeenPriorityAdviceAspect1 { @Pointcut("execution(public void cn.jdl.ka.service.KeenService.printHelloWorld())") public static void pointCut() { } } ``` 在另外一个切面里面,不需要显示导入KeenPriorityAdviceAspect1包 ```java package cn.jdl.ka.aspect; @Aspect public class KeenPriorityAdviceAspect2 { //同名的同一个切入点 @Pointcut("execution(public void cn.jdl.ka.service.KeenService.printHelloWorld())") public void pointCut() { } //使用另一个类的切入点 @After("KeenPriorityAdviceAspect1.pointCut()")//后置通知 public void afterPrintHelloWorld(JoinPoint joinPoint) { System.out.println("后置通知"); } //使用本类的切入点 @AfterReturning(value = "pointCut()", returning = "result")//返回通知 public void afterReturningDiv(JoinPoint point, Object result) { System.out.println("返回后置通知,结果为:"+result); } } ``` 切入点的外部引用使得通知的织入更为灵活。接下来就是要介绍一下通知了,毕竟光找到了切入点,总是要做点儿什么的吧。 ### 2 通知 通知包括了五大类,前置通知,后置通知,返回通知,异常通知和全能的环绕通知。在特殊情况下,还可以实现修改参数,捕捉异常等功能,这些样例解说也会一起放在本文中介绍。 #### 2.1 @Before和@After 还是用之前的hello world做例子,服务类KeenService.java是: ```java public void printHelloWorld(){ System.out.println("Hello World!"); } ``` 主入口KeenMain.java是: ```java public static void main(String[] args) { KeenService keenService = new KeenService(); keenService.printHelloWorld(); } ``` 在KeenAdviceAspect.java中仍然以该方法为切入点: ```java //切入点 @Pointcut("execution(public void cn.jdl.ka.service.KeenService.printHelloWorld())") public void pointCut(){} ``` @Before:前置通知,是在方法执行之前执行 @After:后置通知, 不论是否抛出异常,在方法执行之后执行 ```java @Before("pointCut()")//前置通知 public void beforePrintHelloWorld(JoinPoint joinPoint) { System.out.println("前置通知>>你好,世界!"); } @After("pointCut()")//后置通知 public void afterPrintHelloWorld(JoinPoint joinPoint) { System.out.println("后置通知>>你好,世界!"); } ``` 运行后结果如图: ![](//img1.jcloudcs.com/developer.jdcloud.com/2c5ce304-5c1a-47a5-983e-9ea54db45f6520210813145604.png) #### 2.2 @AfterReturning和@AfterThrowing 服务类KeenService.java中新增函数: ```java /** * 两数相除,除数为0会报异常 */ public int div(int a,int b){ System.out.println(a+"除"+b+"结果为:"+ a/b); return a/b; } ``` 主入口KeenMain.java是: ```java public static void main(String[] args) { KeenService keenService = new KeenService(); keenService.div(4,2); try { keenService.div(4,0); }catch (Exception e){ } } ``` 在KeenAdviceAspect.java中切入该点。 ```java //切入点 @Pointcut("execution(public int cn.jdl.ka.service.KeenService.div(int,int))") public void divPointCut() { } ``` @AfterReturning:返回通知。只有当连接点方法成功执行后,返回通知方法才会执行!如果出现异常就不会运行。而且是在@After之后才会执行@AfterReturning @AfterThrowing:异常通知。是连接点方法出现异常后才会执行,否则不执行。重点在于发生的时机,可以获取异常,但是没有办法处理异常,所以必须在主函数中抓取异常。(Specifically, after throwing advice can’t swallow an exception, and the caller of the join point receives the exception thrown by the join point.) ```java /*返回的结果放置在result变量中,在返回通知方法中可以从result变量中获取连接点方法的返回结果了。*/ @AfterReturning(value = "divPointCut()", returning = "result")//返回通知 public void afterReturningDiv(JoinPoint point, Object result) { System.out.println("连接点方法为:" + point.getSignature().getName() + ",参数为:" + Arrays.asList(point.getArgs())+ ",执行结果为:" + result); } /*出现异常信息存储在ex变量中,在异常通知方法中就可以从ex变量中获取异常信息了*/ @AfterThrowing(value = "divPointCut()", throwing = "ex")//异常通知 public void afterThrowingDiv(JoinPoint point, Throwable ex) { System.out.println("连接点方法为:" + point.getSignature().getName() + ",参数为:" + Arrays.asList(point.getArgs()) + ",异常为:" + ex); } ``` 运行结果为: ![](//img1.jcloudcs.com/developer.jdcloud.com/98572924-4d13-4b2e-939e-01efba94df8420210813145749.png) #### 2.3 @Around 仍然以上面的div方法做切点,所以服务类和切点不再赘述。 但是主入口有所改变: ```java public static void main(String[] args) { KeenService keenService = new KeenService(); keenService.div(4,2); keenService.div(4,0); } ``` 用环绕通知的话,就不需要在主函数里面进行try...catch...了,而是直接在环绕通知里面就直接地获取到了参数值。 ```java @Around("divPointCut()") public Object aroundDiv(ProceedingJoinPoint point) { // result为连接点的放回结果 Object result = null; String methodName = point.getSignature().getName(); System.out.println("前置通知方法>目标方法名:" + methodName + ",参数为:" + Arrays.asList(point.getArgs())); try { // 执行目标方法 result = point.proceed(); System.out.println("返回通知方法>目标方法名" + methodName + ",返回结果为:" + result); } catch (Throwable e) { System.out.println("异常通知方法>目标方法名" + methodName + ",异常为:" + e); }finally { System.out.println("后置通知方法>目标方法名" + methodName + ",返回结果为:" + result); } return result; } ``` ![](//img1.jcloudcs.com/developer.jdcloud.com/8852c167-23ed-4e11-b5dc-aa58ee06fdf620210813145841.png) 注意!使用编译期织入的方式是不会走java.lang.reflect.Method#getAnnotation方法的,因为在实际运行的过程中,服务的方法上面根本就没有注解,而需要编织的东西在编译期就已经完成了。 ### 2.4JoinPoint的上下文 #### 2.4.1 修改入参 如果我们还想利用其进行参数的修改,则调用时必须用joinPoint.proceed(Object[] args)方法,将修改后的参数进行回传。如果用joinPoint.proceed()方法,则修改后的参数并不会真正被使用。 而joinPoint.proceed方法又只能够在环绕通知中使用,所以想要实现修改入参的目的,必须使用环绕通知的方法。 ```java //修改传入的参数 @Around("divPointCut()") public Object aroundDiv2(ProceedingJoinPoint point) { Object result = null; //获取方法参数值数组 Object[] args = point.getArgs(); //得到其方法签名 MethodSignature methodSignature = (MethodSignature) point.getSignature(); // 得到方法名 String methodName = methodSignature.getMethod().getName(); /*执行目标方法*/ try { //如果调用joinPoint.proceed()方法,则修改的参数值不会生效 // result = point.proceed(); args[0] = 6; result = point.proceed(args); System.out.println("返回通知方法>目标方法名" + methodName + ",返回结果为:" + result); } catch (Throwable e) { System.out.println("异常通知方法>目标方法名" + methodName + ",异常为:" + e); } return result; } ``` ##### 2.4.2 获取注解入参 沿用1.2.6中的注解内容,在切面里面: ```java //得到其方法签名 MethodSignature methodSignature = (MethodSignature) point.getSignature(); Method method = methodSignature.getMethod(); // 从方法或者类上面获取注解实例 Keen keen = null; boolean flag = method.isAnnotationPresent(Keen.class); if (flag) { keen = method.getAnnotation(Keen.class); } else { // 如果方法上没有注解,则搜索类上是否有注解 keen = method.getDeclaringClass().getAnnotation(Keen.class); } if (null != keen) { //获得注解的属性 System.out.println(keen.STRING()); } ``` 这个实际应用的案例会放在第三篇的自定义注解内介绍,请保持期待噢! ##### 2.4.3 部分属性值 接下来,将目光放在通知传入的参数上面,在通知内能够获得的上下文属性,输出或者修改才是核心目的啊!直接看源码吧。 JoinPoint:提供访问当前被通知方法的目标对象、代理对象、方法参数等数据: ```java package org.aspectj.lang; import org.aspectj.lang.reflect.SourceLocation; public interface JoinPoint { String toString(); //连接点所在位置的相关信息 String toShortString(); //连接点所在位置的简短相关信息 String toLongString(); //连接点所在位置的全部相关信息 Object getThis(); //返回AOP代理对象 Object getTarget(); //返回目标对象 Object[] getArgs(); //返回被通知方法参数列表 Signature getSignature(); //返回当前连接点签名 SourceLocation getSourceLocation();//返回连接点方法所在类文件中的位置 String getKind(); //连接点类型 StaticPart getStaticPart(); //返回连接点静态部分 } ``` 在通知内部的时候,就可能用到下面的内容: ```java System.out.println("该切入点的所在的限定名"+point.getSourceLocation().getWithinType()+",行数:"+point.getSourceLocation().getLine()); System.out.println("该切入点的具体语句"+point); System.out.println("this对象为:"+point.getThis()); System.out.println("target对象为:"+point.getTarget()); ``` ProceedingJoinPoint:用于环绕通知,使用proceed()方法来执行目标方法: ```java public interface ProceedingJoinPoint extends JoinPoint { public Object proceed() throws Throwable; public Object proceed(Object[] args) throws Throwable; } ``` JoinPoint.StaticPart:提供访问连接点的静态部分,如被通知方法签名、连接点类型等: ```java public interface StaticPart { Signature getSignature(); //返回当前连接点签名 String getKind(); //连接点类型 int getId(); //唯一标识 String toString(); //连接点所在位置的相关信息 String toShortString(); //连接点所在位置的简短相关信息 String toLongString(); //连接点所在位置的全部相关信息 } ``` 随着应用场景的不同,可以方便地获得需要的内容。 ##### 2.4.4 再谈this和target 在通知里面,可以用get方法获得两者的对象,这是最核心的目的。 如果使用execution切方法,那么就是在方法运行的时候切入的,此时的getThis()和getTarget()方法都会获得同一个对象,就是调用时候的对象,此时没有继续谈论的意义。 所以接下来的讨论都是基于call调用方法的。 - 如果是非静态方法,getThis()返回的是调用该方法的对象;getTarget()返回的是该方法所在的对象 - 如果是静态方法,getThis()返回的是null,因为不知道什么时候会被谁调用;getTarget()返回的是该方法所在的对象 注意! - this中使用的表达式必须是类型全限定名,不支持通配符; - target指定的类必须要import,否则不报错,也切不到 target()是指:所选取的Join point 的所有者,直白点说就是: 指明切入的方法属于那个类。 this()是指: 所选取的Join point 的调用的所有者,直白点说就是:方法是在那个类中被调用的。 #### 2.5 优先级 理解了五种通知的各自含义后,迫在眉睫的需要了解不同切面的不同通知对同一切入点的优先级是如何的。 如果发生了通知的冲突,导致报错的话,解决办法有两个: - 将其中的某两个合并在一起,通过代码控制 - 将其中的某一个advice抽离到另外一个aspect内,然后用为 aspect 指定执行顺序。 ##### 2.5.1 同一文件同类型 针对相同的切入点,同一个切面文件中,相同类型的通知方式的执行顺序,我认为是按照从上到下的顺序和先里后外的原则执行的。 环绕通知是从上到下的顺序,比如前置通知: KeenPriorityAdviceAspect1.java文件: ```java package cn.jdl.ka.aspect; @Aspect public class KeenPriorityAdviceAspect1 { //切入点 @Pointcut("execution(public void cn.jdl.ka.service.KeenService.printHelloWorld())") public void pointCut() { } @Before("pointCut()")//前置通知 public void beforePrintHelloWorld1(JoinPoint joinPoint) { System.out.println("前置通知1>>你好,世界!"); } @Before("pointCut()")//前置通知 public void beforePrintHelloWorld2(JoinPoint joinPoint) { System.out.println("前置通知2>>你好,世界!"); } } ``` 修改了两者的先后顺序后,得到结果为: ![](//img1.jcloudcs.com/developer.jdcloud.com/f149382c-167e-4732-8be5-7e2884a6033220210813150458.png) 再比如后置通知: KeenPriorityAdviceAspect1.java文件: ```java package cn.jdl.ka.aspect; @Aspect public class KeenPriorityAdviceAspect1 { //切入点 @Pointcut("execution(public void cn.jdl.ka.service.KeenService.printHelloWorld())") public void pointCut() { } @After("pointCut()") public void afterPrintHelloWorld(JoinPoint joinPoint) { System.out.println("后置通知>>你好,世界!"); } @After("pointCut()") public void afterPrintHelloWorld2(JoinPoint joinPoint) { System.out.println("后置通知2>>你好,世界!"); } } ``` ![](//img1.jcloudcs.com/developer.jdcloud.com/09b7e9b4-f27b-4a6b-ad6f-4399f2ec97b420210813150534.png) 修改了两者的先后顺序后,得到结果为: ![](//img1.jcloudcs.com/developer.jdcloud.com/bd0e3b4b-ee20-40ce-be5d-e7877b0923b820210813150556.png) 环绕通知则是秉承着先里后外的原则: KeenPriorityAdviceAspect1.java文件: ```java package cn.jdl.ka.aspect; @Aspect public class KeenPriorityAdviceAspect1 { //切入点 @Pointcut("execution(public void cn.jdl.ka.service.KeenService.printHelloWorld())") public void pointCut() { } @Around("pointCut()") public Object aroundPrintHelloWorld2(ProceedingJoinPoint point) { Object result = null; System.out.println("前置通知方法2"); try { result = point.proceed(); } catch (Throwable e) { System.out.println("异常通知方法2"); }finally { System.out.println("后置通知方法2"); } return result; } @Around("pointCut()") public Object aroundPrintHelloWorld(ProceedingJoinPoint point) { Object result = null; System.out.println("前置通知方法"); try { result = point.proceed(); } catch (Throwable e) { System.out.println("异常通知方法"); }finally { System.out.println("后置通知方法"); } return result; } } ``` ![](//img1.jcloudcs.com/developer.jdcloud.com/7c1b9978-4d77-43b6-bc8d-38aae7e1495e20210813150638.png) 修改了顺序之后为: ![](//img1.jcloudcs.com/developer.jdcloud.com/42017370-ab3a-4c67-bfca-57a516e6636220210813150702.png) 原因就是环绕通知在第二次环绕织入的时候,是将上一次切入的服务当做原型再次织入了。这里需要单独的注意。 ##### 2.5.2 同一文件不同类型 同一个切面文件中,不同类型的通知方式是有特别优先级顺序的。 在这个测试的环绕通知里面,只考虑实现了前置,后置和异常。 所切入的服务为: ```java public int div(int a,int b){ System.out.println("计算"+a+"除"+b+"的结果"); return a/b; } ``` **服务正常无报错** KeenPriorityAdviceAspect2.java文件: ```java package cn.jdl.ka.aspect; @Aspect public class KeenPriorityAdviceAspect2 { @Pointcut("execution(public int cn.jdl.ka.service.KeenService.div(int,int))") public void pointCut() { } @AfterReturning(value = "pointCut()", returning = "result")//返回通知 public void afterReturningDiv(JoinPoint point, Object result) { System.out.println("返回后置通知,结果为:" + result); } @After("pointCut()") public void afterDiv(JoinPoint joinPoint) { System.out.println("后置通知"); } @Before("pointCut()") public void beforeDiv(JoinPoint joinPoint) { System.out.println("前置通知"); } @Around("pointCut()") public Object aroundDiv(ProceedingJoinPoint point) { Object result = null; String methodName = point.getSignature().getName(); System.out.println("around前置通知方法>目标方法名:" + methodName); try { result = point.proceed(); } catch (Throwable e) { System.out.println("around异常通知方法>目标方法名" + methodName + ",异常为:" + e); } finally { System.out.println("around后置通知方法>目标方法名" + methodName + ",运行结果为:" + result); } return result; } @Before("pointCut()") public void beforeDiv2(JoinPoint joinPoint) { System.out.println("前置通知2"); } @After("pointCut()") public void afterDiv2(JoinPoint joinPoint) { System.out.println("后置通知2"); } } ``` 运行后结果如图: ![](//img1.jcloudcs.com/developer.jdcloud.com/f17c76a4-6265-4c62-8fbd-4b8763537d4d20210813150952.png) 中间反复修改顺序得到的结论是,整体分三步运行: - 该切面的所有前置通知(@Before)和@Around的前置通知方法,按照从上到下的顺序依次执行。 - 原方法执行 - 所有的后置通知(@After),返回后置通知(@AfterReturning)和@Around的后置通知方法,按照从上到下的顺序依次执行。 **服务异常有报错** KeenPriorityAdviceAspect3.java文件: ```java package cn.jdl.ka.aspect; @Aspect public class KeenPriorityAdviceAspect3 { @Pointcut("execution(public int cn.jdl.ka.service.KeenService.div(int,int))") public void pointCut() { } @AfterThrowing(value = "pointCut()", throwing = "ex")//异常通知 public void afterThrowingDiv(JoinPoint point, Throwable ex) { System.out.println("异常通知"); } @After("pointCut()") public void afterDiv(JoinPoint joinPoint) { System.out.println("后置通知"); } @Around("pointCut()") public Object aroundDiv(ProceedingJoinPoint point) { Object result = null; String methodName = point.getSignature().getName(); System.out.println("around前置通知方法>目标方法名:" + methodName); try { result = point.proceed(); } catch (Throwable e) { System.out.println("around异常通知方法>目标方法名" + methodName + ",异常为:" + e); } finally { System.out.println("around后置通知方法>目标方法名" + methodName + ",运行结果为:" + result); } return result; } @Before("pointCut()") public void beforeDiv(JoinPoint joinPoint) { System.out.println("前置通知"); } } ``` 运行得到的结果: ![](//img1.jcloudcs.com/developer.jdcloud.com/5d1be90f-56f8-4842-83a3-f34dc320831920210813151105.png) 结论与上文大致相同,仍然是整体分三步运行: - 该切面的所有前置通知(@Before)和@Around的前置通知方法,按照从上到下的顺序依次执行。 - 原方法执行 - 所有的后置通知(@After),异常通知(@AfterThrowing),@Around的异常通知方法和@Around的后置通知方法,按照从上到下的顺序依次执行。 ##### 2.5.3 不同文件相同类型 如果是两个切面文件里面相同的通知类型去切同一个切入点呢? 直接运行的话,谁先谁后没办法确定的。 同样的,在服务异常的情况下也是有这个问题。 如果集成了Spring框架的话,可以使用@Order属性来完成深度控制,就是我们所说的优先级控制了。有一个无法忽视的缺点就是,只对spring实现的AOP才有效,对aspectj 编译实现的无效!不过这个部分等到第四篇来详细展开。 这里我们介绍一个注解@DeclarePrecedence可以应用于类,接口或者注解。里面的参数是支持直接写类名的,但是更建议使用完全的限定名,不过也支持通配符。多个参数需要用英文逗号来分割 作为aspectj专用的顺序注解,必须使用专门的ajc进行编译,如果使用javac编译会报错。 KeenPriorityAdviceAspect4.java文件,这是参数直接写类名: ```java package cn.jdl.ka.aspect; @Aspect @DeclarePrecedence("KeenPriorityAdviceAspect5,KeenPriorityAdviceAspect4") public class KeenPriorityAdviceAspect4 { @Pointcut("execution(public int cn.jdl.ka.service.KeenService.div(int,int))") public static void pointCut() { } @Around("pointCut()") public Object aroundDiv4(ProceedingJoinPoint point) { Object result = null; String methodName = point.getSignature().getName(); System.out.println("around4前置通知方法>目标方法名:" + methodName); try { result = point.proceed(); } catch (Throwable e) { System.out.println("around4异常通知方法>目标方法名" + methodName + ",异常为:" + e); } finally { System.out.println("around4后置通知方法>目标方法名" + methodName + ",运行结果为:" + result); } return result; } } ``` KeenPriorityAdviceAspect5.java文件,参数使用完全限定名(推荐): ```java package cn.jdl.ka.aspect; @Aspect @DeclarePrecedence(" cn.jdl.ka.aspect.KeenPriorityAdviceAspect5, cn.jdl.ka.aspect.KeenPriorityAdviceAspect4") public class KeenPriorityAdviceAspect5 { @Pointcut("execution(public int cn.jdl.ka.service.KeenService.div(int,int))") public static void pointCut() { } @Around("pointCut()") public Object aroundDiv5(ProceedingJoinPoint point) { Object result = null; String methodName = point.getSignature().getName(); System.out.println("around5前置通知方法>目标方法名:" + methodName); try { result = point.proceed(); } catch (Throwable e) { System.out.println("around5异常通知方法>目标方法名" + methodName + ",异常为:" + e); } finally { System.out.println("around5后置通知方法>目标方法名" + methodName + ",运行结果为:" + result); } return result; } } ``` ![](//img1.jcloudcs.com/developer.jdcloud.com/0b6b2ae0-3d69-416e-8419-27de967ab4d920210813151309.png) ##### 2.5.4 不同文件不同类型 至于这个情况就是前面的结合了。细小划分下去其实和上面的结论是一致的,也是先确定切面的顺序,再确定通知的顺序。不再赘述。 ### 3 小结 本文仍然是主要介绍使用@Aspect的java文件方式。尽管@AspectJ语法有助于早期错误检测,但由于切入点表达式是字符串,在解析这些字符串之前不会报告其中的错误。(Although the @AspectJ syntax promotes early error detection, due to the fact that pointcut expressions are strings, errors in them aren’t reported until those strings are parsed. ) 如果使用ajc编译方面,它会立即解析切入点并报告任何的错误。但是如果使用二进制或加载时织入,错误检测就会在运行时发生。(If you use ajc to compile the aspects, it parses pointcuts immediately and issues any errors. But if you use the binary or load-time weaver, parsing and (consequently) error detection occur at that time.) 本文详细介绍了切入点的种类,以及就多个较为常用的切入点进行了浅显的解读,将多个切入点相互引用的问题分情况进行了讨论。接着就五种通知的应用例子做了清晰的阐述,继而针对于在不同情况下,生效优先级的问题进行了一点思考,并给出了我结论所用到的实例。 为了方便读者复现,最后附上项目链接:https://git.jd.com/weijikuo/keen-aop.git 欢迎评论探讨! #### 参考资料 AspectJ in Action第二版 [官方介绍切入点] https://www.eclipse.org/aspectj/doc/released/progguide/semantics-pointcuts.html#pointcut-definition [AspectJ之this和target的区别] https://blog.csdn.net/wenyingzhi/article/details/84069529 [aspectj 5 捕获异常处理上的连接点] https://www.jianshu.com/p/74c3776d6182 [AspectJ 切面注解中五种通知注解 ] https://blog.csdn.net/u010502101/article/details/78823056 [advice的执行顺序] https://juejin.cn/post/6844903969433583624 [aop小结] https://blog.csdn.net/chen8238065/article/details/73348458 [AspectJ切入点语法详解] https://blog.csdn.net/zhengchao1991/article/details/53391244 [AOP学习之execution] https://www.jianshu.com/p/509fcd44f76e [spring AspectJ的Execution表达式] https://www.cnblogs.com/powerwu/articles/5177662.html ------------ ###### 自猿其说Tech-JDL京东物流技术发展部 ###### 作者:销售发展技术部 魏继扩
原创文章,需联系作者,授权转载
上一篇:多线程与数据库事务及数据库连接之间的关系
下一篇:一文教会你mock(Mockito和PowerMock双剑合璧)
相关文章
Taro小程序跨端开发入门实战
Flutter For Web实践
配运基础数据缓存瘦身实践
自猿其说Tech
文章数
426
阅读量
2149964
作者其他文章
01
深入JDK中的Optional
本文将从Optional所解决的问题开始,逐层解剖,由浅入深,文中会出现Optioanl方法之间的对比,实践,误用情况分析,优缺点等。与大家一起,对这项Java8中的新特性,进行理解和深入。
01
Taro小程序跨端开发入门实战
为了让小程序开发更简单,更高效,我们采用 Taro 作为首选框架,我们将使用 Taro 的实践经验整理了出来,主要内容围绕着什么是 Taro,为什么用 Taro,以及 Taro 如何使用(正确使用的姿势),还有 Taro 背后的一些设计思想来进行展开,让大家能够对 Taro 有个完整的认识。
01
Flutter For Web实践
Flutter For Web 已经发布一年多时间,它的发布意味着我们可以真正地使用一套代码、一套资源部署整个大前端系统(包括:iOS、Android、Web)。渠道研发组经过一段时间的探索,使用Flutter For Web技术开发了移动端可视化编程平台—Flutter乐高,在这里希望和大家分享下使用Flutter For Web实践过程和踩坑实践
01
配运基础数据缓存瘦身实践
在基础数据的常规能力当中,数据的存取是最基础也是最重要的能力,为了整体提高数据的读取能力,缓存技术在基础数据的场景中得到了广泛的使用,下面会重点展示一下配运组近期针对数据缓存做的瘦身实践。
自猿其说Tech
文章数
426
阅读量
2149964
作者其他文章
01
深入JDK中的Optional
01
Taro小程序跨端开发入门实战
01
Flutter For Web实践
01
配运基础数据缓存瘦身实践
添加企业微信
获取1V1专业服务
扫码关注
京东云开发者公众号