约定编程Spring AOP-SpringAOP详解

这里我们采用AspectJ的方式讨论AOP的开发。因为Spring AOP只能对方法进行拦截,所以首先要确定需要拦截什么方法,让他能够织入约定的流程中。

1. 确定连接点

任何AOP编程,首先要确定的是在什么地方需要AOP,也就是需要确定连接点(在Spring 中就是什么类的什么方法)的问题。我们现在假设有一个UserService接口,他有一个printUser方法。代码如下:

package cn.hctech2006.boot.bootaop.service.impl;

import cn.hctech2006.boot.bootaop.bean.User;
import cn.hctech2006.boot.bootaop.service.UserService;

public class UserServiceImpl implements UserService {

    @Override
    public void printUser(User user) {
        if (user == null){
            throw new RuntimeException("检查用户参数是否为空。。。。");
        }
        System.out.println("id ="+user.getName());
    }
}

这样一个简单的服务的接口和实现类就实现了。下面我们将以printUser方法作为连接点,进行AOP编程。

2. 开发切面

package cn.hctech2006.boot.bootaop.aspect;

import org.aspectj.lang.annotation.*;

@Aspect
public class MyAspect {
    @Before("execution(* cn.hctech2006.boot.bootaop.service.impl.UserServiceImpl.printUser(..))")
    public void before(){
        System.out.println("before......");
    }
    @After("execution(* cn.hctech2006.boot.bootaop.service.impl.UserServiceImpl.printUser(..))")
    public void after(){
        System.out.println("after......");
    }
    @AfterReturning("execution(* cn.hctech2006.boot.bootaop.service.impl.UserServiceImpl.printUser(..))")
    public void afterReturning(){
        System.out.println("afterReturning......");
    }
    @AfterThrowing("execution(* cn.hctech2006.boot.bootaop.service.impl.UserServiceImpl.printUser(..))")
    public void afterThrowing(){
        System.out.println("afterThrowing......");
    }

}

这里需要注意那些加粗的代码,主要是注解。首先Spring是以@Aspect作为切面声明的,当以@Aspect作为注解时,Spring就会知道这是一个切面,然后我们就可以通过各类注解来定义各类的通知了。正如代码中的@Before,@After, @AfterReturning, @AfterThrowing等几个注解。通过我们之前AOP的概念和流程的介绍,相信大家也知道他们就是定义流程中的方法,然后即将由Spring AOP将其织入约定的流程中,只是这里我们还没有讨论他们的配置内容,尤其是他们里面的正则表达式,这是切点需要讨论的问题。而且,上述我们还没有讨论环绕通知的问题。因为环绕通知是最强大的通知还会涉及其他的内容讨论,所以后面会以单独的小结去讨论他。下面我们来讨论切点的问题。

3. 切点定义

在上述切面的定义中,我们看到@Before,@After,@AfterReturning, @AfterThrowing登等等注解,他们还需要定义一个正则表达式,这个正则表达式的作用就是什么时候启用AOP,毕竟不是所有的功能都需要启用AOP,也就是Spring会通过正则表达式去匹配,去确定对应的方法(连接点)是否启用切面编程,但是我们在上面的代码中看到每个注解都重复写了同一个正则式,这显然是比较冗余的。为了克服这些问题,Spring定义了切点的(point cut)概念,切点的作用就是向Spring描述那些类的那些方法需要启动AOP编程。有了切点的概念,就可以把代码修改为以下的样子,从而把冗余的正则表达式排除在外。

package cn.hctech2006.boot.bootaop.aspect;

import org.aspectj.lang.annotation.*;

@Aspect
public class MyAspect {
    @Pointcut("execution(* cn.hctech2006.boot.bootaop.service.impl.UserServiceImpl.printUser(..))")
    public void pointcut(){

    }
    @Before("pointcut()")
    public void before(){
        System.out.println("before......");
    }
    @After("pointcut()")
    public void after(){
        System.out.println("after......");
    }
    @AfterReturning("pointcut()")
    public void afterReturning(){
        System.out.println("afterReturning......");
    }
    @AfterThrowing("pointcut()")
    public void afterThrowing(){
        System.out.println("afterThrowing......");
    }

}

代码中,我们使用了@PointCut来定义切点,他标注在pointCut上,则在后面的通知注解中就可以使用方法名称来定义了。其中各个注解中的“pointCut()”就是对这个切点的引用。
此时,我们有必要对这个正则表达式做进一步的分析,首先我们来看下面的正则表达式:

execution(" execution(* cn.hctech2006.boot.bootaop.service.impl.UserServiceImpl.printUser(..)) ")

其中:

  • execution表示执行的时候拦截里面的正则表达式方法
  • *代表任意返回类型的方法
  • cn.hctech2006.boot.bootaop.service.impl.UserServiceImpl是目标对象的全限定名称。
  • printUser指定目标对象的方法
  • (…)表示任意参数进行匹配
    这样Spring就可以通过这个正则表达式知道你需要对类UserServiceImpl的printUser方法进行AOP增强,他就会将正则表达式匹配的对应方法和对应切面的方法织入到上图的约定流程中,从而完成AOP编程。

对于这个正则时而言,它还可以使用AspectJ的指示器。下面我们稍微讨论一下他们,如图

在这里插入图片描述
例如,上述服务类对象在Spring IOC容器的名称为userServiceImpl,而我们只想要让这个类的initUser()方法织入AOP的流程,那么我们可以做这样的限定:

execution(" execution(* cn.hctech2006.boot.bootaop.**.*.printUser(..)) &&bean('userServiceImpl')")

表达式中的&&代表的是并且的意思,而bean中定义的字符串代表对Spring Bean名称的限定,这样就限定具体的类了。有关参数的限定,后面我们还会谈到,这里不再赘述。

  1. 测试AOP
    上面完成了连接点,切面和切点等定义,接下来我们可以进行测试AOP,为此需要先搭建一个Web开发环境,开发一个用户控制器(UserController),代码如下:
package cn.hctech2006.boot.bootaop.controller;

import cn.hctech2006.boot.bootaop.bean.User;
import cn.hctech2006.boot.bootaop.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

/**
 * 定义控制器
 */
@Controller
//定义类启动路径
@RequestMapping("/user")
public class UserController {
    @Autowired
    private UserService userService = null;
    //定义请求
    @RequestMapping("/print")
    //转换为JSON
    @ResponseBody
    public User printUser(String name){
        User user = new User();
        user.setName(name);
        userService.printUser(user);//若是user==null则执行afterThrowing方法。
        return user;//加入断点
    }
}

这里自动注入UserService服务接口,然后使用他进行用户信息打印,因为方法标注了@ResponseBody,所以最后Spring MVC会将其转换为JSN的请求。这里UserService的实现类满足了切点的定义,因此Spring AOP会将其织入对应的流程中,这就是我们本节需要关注的问题。
然后我们配置Spring Boot的配置文件,使其能够运行。
调试这段代码,打开浏览器,等待服务启动完成后,在return user这里加上端点,然后打开浏览器输入http://localhost:8241/user/print?name=1调试,就可以看到请求进入断点。如图
在这里插入图片描述
从途中的监控可以看出userService对象,实际上是一个JDK动态代理对象(很显然不是这是一个CGLAB动态代理对象),他代理了目标对象的UserServiceImpl,通过这些Spring会将我们定义的内容织入AOP的流程,这样我们的AOP就成功运行了。于此同时,也可以看到后台打出的日志:

before......
id =1
after......
afterReturning......

显然这就是Spring与我们约定的流程。从日志上看,我们仅仅结果测试成功了,但是代理对象不对,先不管。也就是说Spring 已经通过动态代理技术帮助把我们所定义的切面和服务无方法织入约定的流程中了。如果我们把控制器(UserController)中的User对象设置为null那么他将会抛出异常,这个时候会执行afterThrowing而不是afterRunning,但是无论如何他都会执行after方法。下面设置用户对象为空的时候进行打印得到的测试日志:

before......
after......
afterThrowing......
2020-04-11 18:54:05.704 ERROR 31184 --- [nio-8241-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: 检查用户参数是否为空。。。。] with root cause

java.lang.RuntimeException: 检查用户参数是否为空。。。。

可以看到无论是否发生异常,后置通知(after都会被执行)都会按照流程执行;而因为发生了异常所以按照约定,异常通知(afterThrowing)被触发,返回通知(afterReturning)则不会被触发。这些都是Spring AOP与我们约定的流程。
5. 环绕通知
环绕通知(Around)是所有通知中最为强大的通知,强大也意味着难以控制,一般而言,使用他的场景是在你需要大幅度修改原有目标对象的服务逻辑之上,否则都尽量使用其他的通知。环绕通知是一个取代原有目标对象方法的通知,当然他也提供了回调原有目标对象方法的能力。
我们在代码中加入环绕通知

    @Around("pointcut()")
    public void around(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("around before......");
        joinPoint.proceed();
        System.out.println("around after......");
    }

这样我们呢就加入了一个环绕通知,并且在他之前和之后都加入了我们自己的打印内容,而他拥有一个ProceedingJoinPoint类型的参数。这个参数的对象有一个proceed方法,通过这个方法可以回调目标对象的方法。然后我们可以在

        joinPoint.proceed();

这行代码加入断点进行调试,通过调试启动Spring Boot服务,在浏览器输入地址http://localhost:8241/user/print?name=1调试,就可以看到请求进入断点。如图
在这里插入图片描述
从监控的信息里面可以看到他的属性,带有原来目标对象的信息,这样就可以通过他的proceed方法回调原有目标对象的方法。测试日志如下:

around before......
before......
id =1
around after......
after......
afterReturning......

注意这个结果的真实测试结果并不是我们期待的结果,因为我们期待的结果中日志的顺序应该如下

before......
around before......
id =1
around after......
after......
afterReturning......

这里测试的Spring版本为5,使用注解测试的时候总是在顺序上出现这样的出入,估计是Spring版本之间的差异留下的问题。这是需要注意的,所以在没有必要的时候,尽量不要使用环绕通知,正如之前所说的,他很强大,但是也很危险。
6. 引入
在测试AOP的时候,我们打印了用户信息,如果用户信息为空,则抛出异常。事实上,我们还可以检测用户信息是否为空,如果是空则不再打印,这样就没有异常产生了。但是现有的UserService接口并没有提供这样的功能,这里假定UserService这个服务不是自己写的,而是别人提供的,我们不能修改他,这时Spring还允许增强这个接口的功能,我们可以为这个接口引入新的接口,例如,要引入一个用户检测的接口UserValidator,其定义如下代码所示

package cn.hctech2006.boot.bootaop.validator;

import cn.hctech2006.boot.bootaop.bean.User;

public interface UserValidator {
    //检测用户对象是否为空
    public boolean validate(User user);
}

很快我们就可以给出他的实现类

package cn.hctech2006.boot.bootaop.validator.impl;

import cn.hctech2006.boot.bootaop.bean.User;
import cn.hctech2006.boot.bootaop.validator.UserValidator;

public class UserValidatorImpl implements UserValidator {
    @Override
    public boolean validate(User user) {
        System.out.println("引入新的接口:"+UserValidator.class.getSimpleName());
        return user != null;
    }
}

这样,我们通过Spring AOP引入的定义就能够增强UserService接口的功能,这个时候清单中的代码如下:

    @DeclareParents(value = "cn.hctech2006.boot.bootaop.service.impl.UserServiceImpl+",defaultImpl = UserValidatorImpl.class)
    public UserValidator userValidator;

这里我们看到了一个注解@DeclareParents,他的作用是引入新的类来增强服务,他有两个必须配置的属性value和defaultImpl

  • value:指向你要增强功能的目标对象,这里是要增强UserServiceImpl对象,因此可以看到配置为cn.hctech2006.boot.bootaop.service.impl.UserServiceImpl+
  • defaultImpl:引入增强功能的类,这里配置为UserValidatorImpl用来提供校验用户是否为空的功能。
    为了验证他,我们在代码中加入一个新的方法
    //定义请求
    @RequestMapping("/vp")
    //返回Json对象
    @ResponseBody
    public User validateAndPrint(String name){
        User user = new User();
        user.setName(name);
        //强制类型转换
        UserValidator userValidator = (UserValidator) userService;
        //验证用户是否为空
        if (userValidator.validate(user)){
            userService.printUser(user);
        }
        return user;
    }

这里用户可以看到,我把原来的userService对象强制转换为UserValidator对象,然后就可以使用验证方法去验证用户对象是否为空。在浏览器输入http://localhost:8241/user/vp?name=1,就可以得到如下的日志:

引入新的接口:UserValidator
around before......
before......
id =1
around after......
after......
afterReturning......

可见引入这个新的接口增强原有类的功能成功了。那么他是根据什么原理来增强原有对象功能的呢?首先我们回到代码,可以看到生成代理对象的代码是:

public static Object newProxyInstance(ClassLoader classLoader, Class<?>[] interfaces, InvocationHandler invocationHandler)throws IllegalArgumentException

这里的newProxyInstance的第二个参数是一个对象数组,也就是说这里生产代理对象的时候会把UserService和UserValidator两个接口传递进去,让代理对象下挂在这两个接口之下,这样这个代理对象就可以互相转换并且使用他们的方法了。所以可以看到代码清单中强制转换的代码,为了验证这一点,我们加入断点进行测试,如图就是我通过自己调试验证原理的过程。
在这里插入图片描述
从图中可以看出在CGLIB动态代理中下挂的两个接口鳗鱼是我们可以把代理对象通过这两个接口相互转换了,然后调度其对应的方法,这就是引入的原理。同样的JDK也可以做到类似的功能。

  1. 获取通知参数
    在上述通知中,我们没有给通知传递参数。有时候我们希望传递参数给通知,这也是允许的,我们只需要在切点处加入对应的正则表达式就可以。当然对于非环绕通知还可以使用一个连接点类型(join point)的参数,通过它也可以获取参数。看一下代码
//在前置通知中获取参数
    @Before("pointcut() && args(user)")
    public void before(JoinPoint point, User user){
        Object[] args = point.getArgs();
        System.out.println("before......");
    }

正则表达式pointCut()&&args(user)中,pointCut()表示启用原来定义切点的规则,并且约定将连接点(目标对象方法)名称为user的参数传递进来。这里要注意,JoinPoint类型的参数对于非环绕通知而言,Spring AOP会自动把它传递到通知中对于环绕通知而言,可以使用ProceedingJoinPoint类型的参数。之前我们讨论过他的结构,使用它将允许进行目标对象的回调,这里不妨在这个方法之上加入断点看看获取的参数是什么,如图。
在这里插入图片描述
从监控中可以看出,参数user信息传递成功了。通过连接点参数的getArgs方法也可以获取所有参数,而对于连接点参数还可以获取目标对象的信息,从而完成我们的工作。其他通知也是大同小异,这里就不再赘述了。

  1. 织入
    织入是一个生成动态代理对象并且将切面和目标对象方法编织成为约定流程的过程。对于流程上的通知,上面已经有了比较完善的说明,而上面我们都是采用接口+实现类的模式,这是Spring推荐的方式,也是本书遵循的方式。但是对于是否拥有接口则不是Spring AOP的强制要求,对于动态代理的也有多种实现方式,我们之前谈到的JDK只是其中的一种,业界比较流行的还有CGLIB,Javassist,ASM等。Spring 采用JDK和CDLIB,对于JDK而言,他是要求被代理的目标必须拥有接口,而对于CGLIB则是不做要求。因此在默认的情况下,Spring会按照这样一条规则,即当你需要使用AOP的类拥有接口时,他会以JDK动态代理运行,否则以CGLIB运行。
    下面我们来验证一下,修改成不实现任何接口。

@Component
public class UserServiceImpl  {
    public void printUser(User user) {
        if (user == null){
            throw new RuntimeException("检查用户参数是否为空。。。。");
        }
        System.out.println("id ="+user.getName());
    }
}

然后修改控制台的依赖注入,直接依赖与不存在接口的实现类,如代码清单所示。

/**
 * 定义控制器
 */
@Controller
//定义类启动路径
@RequestMapping("/user")
public class UserController {
    @Autowired
    private UserServiceImpl userService = null;
    //定义请求
    @RequestMapping("/print")
    //转换为JSON
    @ResponseBody
    public User printUser(String name){
        User user = new User();
        user.setName(name);
 //       user = null;
        userService.printUser(user);//若是user==null则执行afterThrowing方法。
        return user;//加入断点
    }

然后我们在注释的地方加断点,可以看到如图的信息:
在这里插入图片描述
从图中可以看出,此时Spring已经使用了CGLIB为我们生成了代理对象,从而将切面的内容织入对应的流程中。

发布了180 篇原创文章 · 获赞 114 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/weixin_43404791/article/details/105454906