Spring AOP中定义切点PointCut详解

2022-07-02 08:25:45

1、AOP是什么?

        软件工程有一个基本原则叫做“关注点分离”(Concern Separation),通俗的理解就是不同的问题交给不同的部分去解决,每部分专注于解决自己的问题。这年头互联网也天天强调要专注嘛!

        这其实也是一种“分治”或者“分类”的思想,人解决复杂问题的能力是有限的,所以为了控制复杂性,我们解决问题时通常都要对问题进行拆解,拆解的同时建立各部分之间的关系,各个击破之后整个问题也迎刃而解了。人类的思考,复杂系统的设计,计算机的算法,都能印证这一思想。额,扯远了,这跟AOP有神马关系?

AOP为Aspect Oriented Programming的缩写,意为:面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。AOP是Spring框架中的一个重要内容,它通过对既有程序定义一个切入点,然后在其前后切入不同的执行内容,比如常见的有:打开数据库连接/关闭数据库连接、打开事务/关闭事务、记录日志等。基于AOP不会破坏原来程序逻辑,因此它可以很好的对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。

面向切面编程(Aspect Oriented Programming,AOP)其实就是一种关注点分离的技术,在软件工程领域一度是非常火的研究领域。我们软件开发时经常提一个词叫做“业务逻辑”或者“业务功能”,我们的代码主要就是实现某种特定的业务逻辑。但是我们往往不能专注于业务逻辑,比如我们写业务逻辑代码的同时,还要写事务管理、缓存、日志等等通用化的功能,而且每个业务功能都要和这些业务功能混在一起,痛苦!所以,为了将业务功能的关注点和通用化功能的关注点分离开来,就出现了AOP技术。这些通用化功能的代码实现,对应的就是我们说的切面(Aspect)。

        业务功能代码和切面代码分开之后,责任明确,开发者就能各自专注解决问题了,代码可以优雅的组织了,设计更加高内聚低耦合了(终极目标啊!)。但是请注意,代码分开的同时,我们如何保证功能的完整性呢? 你的业务功能依然需要有事务和日志等特性,即切面最终需要合并(专业术语叫做织入,Weave)到业务功能中。怎么做到呢? 这里就涉及AOP的底层技术啦,有三种方式:

  1. 编译时织入:在代码编译时,把切面代码融合进来,生成完整功能的Java字节码,这就需要特殊的Java编译器了,AspectJ属于这一类
  2. 类加载时织入:在Java字节码加载时,把切面的字节码融合进来,这就需要特殊的类加载器,AspectJ和AspectWerkz实现了类加载时织入
  3. 运行时织入:在运行时,通过动态代理的方式,调用切面代码增强业务功能,Spring采用的正是这种方式。动态代理会有性能上的开销,但是好处就是不需要神马特殊的编译器和类加载器啦,按照写普通Java程序的方式来就行了!AOP的术语:

2、aop 术语

  • 通知(有的地方叫增强)(Advice)

    需要完成的工作叫做通知,就是你写的业务逻辑中需要比如事务、日志等先定义好,然后需要的地方再去用。

       切面(Aspect)是一个类,而通知就是类里的方法以及这个方法如何织入到目标方法的方式(用@AfterReturning@Around标注的方法)。我们的例子中只展示了两类通知,根据织入到目标方法方式的不同,AspectJ提供了五种定义通知的标注:

  1. @Before:前置通知,在调用目标方法之前执行通知定义的任务
  2. @After:后置通知,在目标方法执行结束后,无论执行结果如何都执行通知定义的任务
  3. @After-returning:后置通知,在目标方法执行结束后,如果执行成功,则执行通知定义的任务
  4. @After-throwing:异常通知,如果目标方法执行过程中抛出异常,则执行通知定义的任务
  5. @Around:环绕通知,在目标方法执行前和执行后,都需要执行通知定义的任务
  • 连接点(Join point)

    就是spring中允许使用通知的地方,基本上每个方法前后抛异常时都可以是连接点

  • 切点(Poincut)

    其实就是筛选出的连接点,一个类中的所有方法都是连接点,但又不全需要,会筛选出某些作为连接点做为切点。如果说通知定义了切面的动作或者执行时机的话,切点则定义了执行的地点

  • 切面(Aspect)

    其实就是通知和切点的结合,通知和切点共同定义了切面的全部内容,它是干什么的,什么时候在哪执行

  • 引入(Introduction)

    在不改变一个现有类代码的情况下,为该类添加属性和方法,可以在无需修改现有类的前提下,让它们具有新的行为和状态。其实就是把切面(也就是新方法属性:通知定义的)用到目标类中去

  • 目标(target)

    被通知的对象。也就是需要加入额外代码的对象,也就是真正的业务逻辑被组织织入切面。

  • 织入(Weaving)

    把切面加入程序代码的过程。切面在指定的连接点被织入到目标对象中,在目标对象的生命周期里有多个点可以进行织入:

    • 编译期:切面在目标类编译时被织入,这种方式需要特殊的编译器
    • 类加载期:切面在目标类加载到JVM时被织入,这种方式需要特殊的类加载器,它可以在目标类被引入应用之前增强该目标类的字节码
    • 运行期:切面在应用运行的某个时刻被织入,一般情况下,在织入切面时,AOP容器会为目标对象动态创建一个代理对象,Spring AOP就是以这种方式织入切面的。

3、切点表达式

切点的功能是指出切面的通知应该从哪里织入应用的执行流。切面只能织入公共方法。

在Spring AOP中,使用AspectJ的切点表达式语言定义切点其中excecution()是最重要的描述符,其它描述符用于辅助excecution()。

excecution()的语法如下:

   execution(modifiers-pattern? ret-type-pattern declaring-type-pattern? name-pattern(param-pattern)throws-pattern?)

这个语法看似复杂,但是我们逐个分解一下,其实就是描述了一个方法的特征:

问号表示可选项,即可以不指定。

excecution(* com.tianmaying.service.BlogService.updateBlog(..))

  • modifier-pattern:表示方法的修饰符
  • ret-type-pattern:表示方法的返回值
  • declaring-type-pattern?:表示方法所在的类的路径
  • name-pattern:表示方法名
  • param-pattern:表示方法的参数
  • throws-pattern:表示方法抛出的异常

3.1、注意事项

  • 其中后面跟着“?”的是可选项。
  • 在各个pattern中,可以使用"*"来表示匹配所有。
  • 在param-pattern中,可以指定具体的参数类型,多个参数间用“,”隔开,各个也可以用“*”来表示匹配任意类型的参数,如(String)表示匹配一个String参数的方法;(*,String)表示匹配有两个参数的方法,第一个参数可以是任意类型,而第二个参数是String类型。
  • 可以用(..)表示零个或多个任意的方法参数。

使用&&符号表示与关系,使用||表示或关系、使用!表示非关系。在XML文件中使用and、or和not这三个符号。

3.2、在切点中引用Bean

Spring还提供了一个bean()描述符,用于在切点表达式中引用Spring Beans。例如:

excecution(* com.tianmaying.service.BlogService.updateBlog(..))  and bean('tianmayingBlog')

这表示将切面应用于BlogService的updateBlog方法上,但是仅限于ID为tianmayingBlog的Bean。

也可以排除特定的Bean:

excecution(* com.tianmaying.service.BlogService.updateBlog(..))  and !bean('tianmayingBlog')

3.3、现在来看看几个例子:

1)execution(* *(..))
//表示匹配所有方法
2)execution(public * com. savage.service.UserService.*(..))
//表示匹配com.savage.server.UserService中所有的公有方法
3)execution(* com.savage.server..*.*(..))
//表示匹配com.savage.server包及其子包下的所有方法

4、一个场景

     接下来上例子!David对土豪老板定机票的例子比较满意,所以决定继续沿用这个例子。

  Boss在订机票时,我们希望能够记录订机票这个操作所消耗的时间,同时记录日志(这里我们简单的在控制台打印预定成功的信息)。

     我们来看普通青年的做法吧:

package com.tianmaying.aopdemo;

public class Boss {

    private BookingService bookingService;

    public Boss() {
        this.bookingService = new QunarBookingService();
    }

    //...

    public void goSomewhere() {
        long start = System.currentTimeMillis();

                //订机票
        boolean status = bookingService.bookFlight();

        //查看耗时
        long duration = System.currentTimeMillis() - start;
        System.out.println(String.format("time for booking flight is %d seconds", duration));

        //记录日志
        if (status) {
            System.out.println("booking flight succeeded!");
        } else {
            System.out.println("booking flight failed!");
        }
    }
}

    我们看到,在订机票的同时,还要处理查看耗时和记录日志,关注的事情太多了,头大啊。而且项目大了之后,除了订机票之外,很多业务功能都要写类似的代码。让AOP来拯救我们吧!

4.1、使用AOP的场景

    相比在IoC例子中的代码,我们让BookingServicebookFlight()方法返回一个boolean值,表示是否预定成功。这样我们可以演示如何获取被切方法的返回值。

通过AOP我们怎么做呢,David今天送出独门秘籍,告诉你通过3W方法(What-Where-When)来理解AOP。

  • What:    What当然指的时切面啦!首先我们将记录消耗时间和记录日志这两个功能的代码分离出来,我们可以做成两个切面,命名为TimeRecordingAspectLogAspect
  • Where:  切面的织入发生在哪呢?切面针对的目标对象TargetSmartBoss(区别于Boss)!这里还有有一个很关键的概念叫做切入点Pointcut,在这个场景中就是指在SmartBoss调用什么方法的时候的时候应用切面。显然,我们希望增强的是bookFlight()方法,即在bookFlight方法调用的地方,我们加入时间记录和日志。
  • When:  什么时候织入呢?这涉及到织入的时机问题,我们可以在bookFlight()执行前织入,执行后织入,或者执行前后同时切入。When的概念用专业术语来说叫做通知Advice)。

了解了3W之后,来看看代码吧,先上LogAspect:

插入一段,POM文件不要忘记了引入Spring AOP相关的依赖:

    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-context</artifactId>
        <version>4.2.0.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-aop</artifactId>
        <version>4.2.0.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>org.aspectj</groupId>
        <artifactId>aspectjweaver</artifactId>
        <version>1.8.5</version>
    </dependency>  
    <dependency>
        <groupId>org.aspectj</groupId>
        <artifactId>aspectjrt</artifactId>
        <version>1.8.5</version>
    </dependency>

也可以引入SprintBoot依赖:

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

LogAspect

package com.tianmaying.aopdemo.aspect;

import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Aspect//1
@Component
public class LogAspect {

    @Pointcut("execution(* com.tianmaying.aopdemo..*.bookFlight(..))") //2
    private voidlogPointCut() {
    }

    @AfterReturning(pointcut = "logPointCut()", returning = "retVal") //3
    public void logBookingStatus(booleanretVal) {  //4
        if (retVal) {
            System.out.println("booking flight succeeded!");
        } else {
            System.out.println("booking flight failed!");
        }
    }
}

我们看这段代码:

1、通过一个@Apsect标注,表示LogAspect是一个切面,解决了What问题。

2、通过定义一个标注了@Pointcut的方法,定义了Where的问题,"execution(* com.tianmaying.aopdemo..*.bookFlight(..))"表示在com.tianmaying.aopdemo包或者子包种调用名称为bookFlight的地方就是切入点!定义Pioncut的语法这里不详解了,David这里要告诉你的时它的作用:解决Where的问题!

3、通过一个@AfterReturning标注表示在bookFlight()调用之后将切面织入,这是一个AfterReturning类型的Advice,注意这里可以通过returning属性获取bookFlight()的返回值。

4、这里定义了实现切面功能的代码,经过这么一番闪转腾挪,最后写日志的代码跑到这里来了!

4.2、再来看TimeRecordingAspect:

TimeRecordingAspect:

package com.tianmaying.aopdemo.aspect;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class TimeRecordingAspect {

    @Pointcut("execution(* com.tianmaying.aopdemo..*.bookFlight(..))")
    private voidtimeRecordingPointCut(){
    }

    @Around("timeRecordingPointCut()") //1
    public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {  //2

        long start = System.currentTimeMillis();
Object retVal = pjp.proceed(); // 3目标方法调用

        long duration = System.currentTimeMillis() - start;
        System.out.println(String.format(
                "time for booking flight is %d seconds", duration));

        return retVal;
    }
}

  • LogAspect不同,因为要计算bookFlight()的耗时,我们必须在调用前后到切入代码,才能算出来这之间的时间差。因此,在1处,我们定义的是一个Around类型的Advice。
  • 2处是实现AroundAdvice的方法,其方法的参数和返回值是固定写法。
  • 3处也是固定写法,表示对目标方法(即bookFlight())的调用,注意不要漏了,漏掉的话原方法就不会被调用了,通常情况下肯定不是你想要的结果!

      回头再看SmartBoss的代码,比Boss简单多了,goSomewhere()方法中只剩下一条语句,酷的掉渣啊,只关注订机票,其他的事情都F**k Off吧!

package com.tianmaying.aopdemo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class SmartBoss {
    private BookingService bookingService;

        //...

    public void goSomewhere() {
        bookingService.bookFlight();
    }
}

      当然,要让代码Run起来,还需要在App类中加上@EnableAspectJAutoProxy标注,这样Spring启动时就去去扫描AOP相关的标注,在创建对象时帮我们去执行织入过程!

5、处理通知中参数

      有时我们需要给通知中的方法传递目标对象的一些信息,比如传入目标业务方法的参数。

      在前面的代码中我们曾经通过@AfterReturning(pointcut = "logPointCut()", returning = "retVal")在通知中获取目标业务方法的返回值。获取参数的方式则需要使用关键词是args。

       假设需要对系统中的accountOperator方法,做Account的验证,验证逻辑以切面的方式显示,示例如下:

@Before("com.tianmaying.UserService.accountOperator() && args(account,..)")
public void validateAccount(Account account) {
    // ...
    // 这可以获取传入accountOperator中的Account信息
}

      args()中参数的名称必须跟切点方法的签名中(public void validateAccount(Account account))的参数名称相同。如果使用切点函数定义,其中的参数名称也必须与通知方法签名中的参数完全相同,例如:

@Pointcut("com.tianmaying.UserService.accountOperator() && args(account,..)")
private void accountOperation(Account account) {}

@Before("accountOperation(account)")
public void validateAccount(Account account) {
    // ...
}

例子1

1、添加maven依赖注解

        <!--springBoot的aop-->
<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

2、添加AOP类

@Component
@Aspect
public class JournalServiceAspect {
}

3、设置切面点

    /**切面点*/
    private final String POINT_CUT = "execution(* com.xx.xx..*(..))";
    @Pointcut(POINT_CUT)
    private void pointcut(){}

4、配置前置通知

/**
 * 前置通知,方法调用前被调用
 * @param joinPoint
 */
@Before(value = POINT_CUT)
public void before(JoinPoint joinPoint){
    logger.info("前置通知");
    //获取目标方法的参数信息
    Object[] obj = joinPoint.getArgs();
    //AOP代理类的信息
    joinPoint.getThis();
    //代理的目标对象
    joinPoint.getTarget();
    //用的最多 通知的签名
    Signature signature = joinPoint.getSignature();
    //代理的是哪一个方法
    logger.info("代理的是哪一个方法"+signature.getName());
    //AOP代理类的名字
    logger.info("AOP代理类的名字"+signature.getDeclaringTypeName());
    //AOP代理类的类(class)信息
    signature.getDeclaringType();
    //获取RequestAttributes
    RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
    //从获取RequestAttributes中获取HttpServletRequest的信息
    HttpServletRequest request = (HttpServletRequest) requestAttributes.resolveReference(RequestAttributes.REFERENCE_REQUEST);
    //如果要获取Session信息的话,可以这样写:
    //HttpSession session = (HttpSession) requestAttributes.resolveReference(RequestAttributes.REFERENCE_SESSION);
    //获取请求参数
    Enumeration<String> enumeration = request.getParameterNames();
    Map<String,String> parameterMap = Maps.newHashMap();
    while (enumeration.hasMoreElements()){
        String parameter = enumeration.nextElement();
        parameterMap.put(parameter,request.getParameter(parameter));
    }
    String str = JSON.toJSONString(parameterMap);
    if(obj.length > 0) {
        logger.info("请求的参数信息为:"+str);
    }
}

**注意:这里用到了JoinPoint和RequestContextHolder。
1)、通过JoinPoint可以获得通知的签名信息,如目标方法名、目标方法参数信息等。
2)、通过RequestContextHolder来获取请求信息,Session信息。**

5、配置后置返回通知

/**
 * 后置返回通知
 * 这里需要注意的是:
 *      如果参数中的第一个参数为JoinPoint,则第二个参数为返回值的信息
 *      如果参数中的第一个参数不为JoinPoint,则第一个参数为returning中对应的参数
 * returning:限定了只有目标方法返回值与通知方法相应参数类型时才能执行后置返回通知,否则不执行,
 *            对于returning对应的通知方法参数为Object类型将匹配任何目标返回值
 * @param joinPoint
 * @param keys
 */
@AfterReturning(value = POINT_CUT,returning = "keys")
public void doAfterReturningAdvice1(JoinPoint joinPoint,Object keys){
    logger.info("第一个后置返回通知的返回值:"+keys);
}

@AfterReturning(value = POINT_CUT,returning = "keys",argNames = "keys")
public void doAfterReturningAdvice2(String keys){
    logger.info("第二个后置返回通知的返回值:"+keys);
}

6、后置异常通知

/**
 * 后置异常通知
 *  定义一个名字,该名字用于匹配通知实现方法的一个参数名,当目标方法抛出异常返回后,将把目标方法抛出的异常传给通知方法;
 *  throwing:限定了只有目标方法抛出的异常与通知方法相应参数异常类型时才能执行后置异常通知,否则不执行,
 *            对于throwing对应的通知方法参数为Throwable类型将匹配任何异常。
 * @param joinPoint
 * @param exception
 */
@AfterThrowing(value = POINT_CUT,throwing = "exception")
public void doAfterThrowingAdvice(JoinPoint joinPoint,Throwable exception){
    //目标方法名:
    logger.info(joinPoint.getSignature().getName());
    if(exception instanceof NullPointerException){
        logger.info("发生了空指针异常!!!!!");
    }
}

7、后置最终通知

/**
 * 后置最终通知(目标方法只要执行完了就会执行后置通知方法)
 * @param joinPoint
 */
@After(value = POINT_CUT)
public void doAfterAdvice(JoinPoint joinPoint){
    logger.info("后置最终通知执行了!!!!");
}

8、环绕通知

/**
 * 环绕通知:
 *   环绕通知非常强大,可以决定目标方法是否执行,什么时候执行,执行时是否需要替换方法参数,执行完毕是否需要替换返回值。
 *   环绕通知第一个参数必须是org.aspectj.lang.ProceedingJoinPoint类型
 */
@Around(value = POINT_CUT)
public Object doAroundAdvice(ProceedingJoinPoint proceedingJoinPoint){
    logger.info("环绕通知的目标方法名:"+proceedingJoinPoint.getSignature().getName());
    try {
        Object obj = proceedingJoinPoint.proceed();
        return obj;
    } catch (Throwable throwable) {
        throwable.printStackTrace();
    }
    return null;
}

例子2:

https://blog.csdn.net/zzhongcy/article/details/103583008

参考:

https://juejin.im/post/5c47e4b26fb9a049b50726d8

https://zhuanlan.zhihu.com/p/23356489

https://www.tianmaying.com/tutorial/spring-aop

https://blog.csdn.net/DuShiWoDeCuo/article/details/78180803

  • 作者:zzhongcy
  • 原文链接:https://blog.csdn.net/yangyangye/article/details/102484741
    更新时间:2022-07-02 08:25:45