据说SpringAOP 有坑?那就来踩一踩

前言

前几日,有朋友分享了这样一个案例:java

原来的项目一直都正常运行,忽然有一天发现代码部分功能报错。通过排查,发现Controller里部分方法为private的,原来是同事为Controller添加了AOP日志功能,致使原来的方法报错。spring

固然了,解决方案就是把private修饰的方法改成public,一切就都正常了。bash

不过这到底是为何呢?若是你也说不太清楚,就跟着笔者一块儿来探探究竟。app

1、SpringBoot添加AOP

咱们先为SpringBoot项目添加一个切面功能。ide

在这里,笔者的SpringBoot的版本为2.1.5.RELEASE,对应的Spring版本为5.1.7.RELEASE函数

咱们必需要先添加AOP的依赖:spring-boot

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
复制代码

而后来定义一个切面,来拦截Controller中的全部方法:源码分析

@Component
@Aspect
public class ControllerAspect {

    @Pointcut(value = "execution(* com.viewscenes.controller..*.*(..))")
    public void pointcut(){}

    @Before("pointcut()")
    public void before(JoinPoint joinPoint){
        System.out.println("前置通知");
    }
    @After("pointcut()")
    public void after(JoinPoint joinPoint){
        System.out.println("后置通知");
    }
    @AfterReturning(pointcut="pointcut()",returning = "result")
    public void result(JoinPoint joinPoint,Object result){
        System.out.println("返回通知:"+result);
    }
}
复制代码

而后写一个Controller:post

@RestController
public class UserController {

    @Autowired
    UserService userService;
    
    @RequestMapping("/list")
    public List<User> list() {
        return userService.list();
    }
}
复制代码

好了,如今访问/list方法,AOP就已经正常工做了。测试

前置通知
后置通知
返回通知:
[
User(id=59ffbdca-6b50-4466-936d-dddd693aa96b, name=0), 
User(id=ff600c29-2013-493a-aab1-e66329251666, name=1), 
User(id=85527844-bb3d-4cd3-98a1-786f0f754a98, name=2)
]
复制代码

2、CGLIB原理

首先,咱们要知道的是,在SpringBoot中,默认使用的就是CGLIB方式来建立代理。

在它的配置文件中,spring.aop.proxy-target-class默认是true。

{
  "name": "spring.aop.proxy-target-class",
  "type": "java.lang.Boolean",
  "description": "Whether subclass-based (CGLIB) proxies are to be created (true), as opposed to standard Java interface-based proxies (false).",
  "defaultValue": true
}
复制代码

而后再回顾下CGLIB的原理:

动态生成一个要代理类的子类,子类重写要代理的类的全部不是final的方法。在子类中采用方法拦截的技术拦截全部父类方法的调用,顺势织入横切逻辑。它比使用java反射的JDK动态代理要快。

咱们看到,CGLIB代理的重要条件是生成一个子类,而后重写要代理类的方法。

下面咱们看看CGLIB最基础的应用。

假如咱们有一个Student类,它有一个eat()方法。

public class Student {

    public void eat(String name) {
        System.out.println(name+"正在吃饭...");
    }
}
复制代码

而后,建立一个拦截器,在CGLIB中,它是一个回调函数。

public class TargetInterceptor implements MethodInterceptor {
    @Override
    public Object intercept(Object obj, Method method, 
		Object[] params, MethodProxy proxy) throws Throwable {
        System.out.println("调用前");
        Object result = proxy.invokeSuper(obj, params);
        System.out.println("调用后");
        return result;
    }
}
复制代码

而后咱们测试它:

public static void main(String[] args){

	//建立字节码加强器
	Enhancer enhancer =new Enhancer();
	//设置父类
	enhancer.setSuperclass(Student.class);
	//设置回调函数
	enhancer.setCallback(new TargetInterceptor());
	//建立代理类
	Student student=(Student)enhancer.create();
	student.eat("王二杆子");
}
复制代码

这样就完成了经过CGLIB对Student类的代理。

上面代码中的Student就是经过CGLIB建立的代理类,它的Class对象以下:

class com.viewscenes.test.Student$$EnhancerByCGLIB$$121a496f

既然CGLIB是经过生成子类的方式来建立代理,那么它生成的子类就要继承父类咯。

关于Java中的继承,有一条很重要的特性就是:

  • 子类拥有父类非 private 的属性、方法。

看到这里,也许你已经明白了一大半,不过我们继续看。若是照这样说法,若是父类中有private方法,生成的代理类中是看不到的。

上面的Student类中,学生不只要吃饭,也许还会偷偷睡觉,那咱们给它加一个私有方法:

public class Student {

    public void eat(String name) {
        System.out.println(name+"正在吃饭...");
    }
    private void sleep(String name){
        System.out.println(name+"正在偷偷睡觉...");
    }
}
复制代码

不过,怎么测试呢?这私有方法在外面也调用不到呀。不要紧,咱们用反射来试验:

//建立代理类
Student student=(Student)enhancer.create();
    
Method eat = student.getClass().getMethod("eat", String.class);
eat.invoke(student,"王二杆子");

Method sleep = student.getClass().getMethod("sleep", String.class);
sleep.invoke(student,"王二杆子");
复制代码

输出结果以下:

调用前
王二杆子正在吃饭...
调用后
Exception in thread "main" java.lang.NoSuchMethodException: com.viewscenes.test.Student$$EnhancerByCGLIB$$121a496f.sleep(java.lang.String)
	at java.lang.Class.getMethod(Class.java:1786)
	at com.viewscenes.test.Test.main(Test.java:23)
复制代码

很明显,在调用sleep方法的时候,抛出了java.lang.NoSuchMethodException异常。

至此,咱们更加肯定了一件事:

CGLIB建立的代理类,不会包含父类中的私有方法。

3、为啥其余属性没法注入

咱们看完了上面的测试,如今把Controller中的方法也改为private

再访问的时候,会报出java.lang.NullPointerException异常,是由于UserService为null,没有成功注入。

这就不太对了呀?若是说由于私有方法的缘由,致使代理类不会包含此方法的话,那么最多AOP不会生效,为何UserService也没有注入进来呢?

带着这个问题,笔者又翻了翻Spring aop相关的源码,这才理解咋回事。

在这里,咱们首先要记住一件事:无论方法是否为私有的,UserController这个Bean是已经肯定被代理了的。

一、SpringMVC处理请求

咱们的一个HTTP请求,会先通过SpringMVC中的DispatcherServlet,而后找到与之对应的HandlerMethod来处理。在后面,会先经过Spring的参数解析器,把Request参数解析出来,最后经过Method来调用方法。

二、反射调用

上面代码就是经过反射来调用Controller中的方法。

上面咱们说:

无论方法是否为私有的,UserController这个Bean是已经肯定被代理了的。

在这里,this.getBean()拿到的就是被代理后的对象。它长这样:

能够看到,在这个代理对象中,userService对象为NULL。那么,按理说,无论你方法是否为私有的,这样直接调用也都是要报空指针异常的呀。那么,为啥只有私有方法才会报错,而公共方法不会呢?

三、有啥不同

在这里,他们的method是同样的,都是java.lang.reflect包中的对象。

若是是私有方法,那么在代理类中,不会包含这个方法。此时经过Method.invoke()来调用目标方法,传入的实例对象是userController的代理类,而这个代理类中的userService为NULL,因此,执行的时候,才会看到userService没有注入,致使空指针异常。

若是是公共方法,在代理类中,就有它的子类实现,则会先调用到代理类的拦截器MethodInterceptor。拦截器负责链式调用AOP方法和目标方法。在拦截器执行过程当中,又调用了方法。但不一样的是,此时传入的实例对象并非代理类,而是代理类的目标对象。

有朋友对这块不理解,其实就是JDK中java.lang.reflect.Method的内容,来借助测试再看一下。

仍是拿上面的Student为例,咱们经过Method来获取它的方法并调用。

//建立代理类
Student student=(Student)enhancer.create();

Method eat = Student.class.getDeclaredMethod("eat", String.class);
eat.setAccessible(true);
eat.invoke(student,"王二杆子");

System.out.println("----------------------");
Method sleep = Student.class.getDeclaredMethod("sleep", String.class);
sleep.setAccessible(true);
sleep.invoke(student,"王二杆子");
复制代码

上面的代码中,先经过反射拿到Method对象,其中eat是公共方法,sleep是私有方法。invoke传入的对象都是经过CGLIB生成的代理对象,结果就是eat执行了代理,而sleep并无。

调用前
王二杆子正在吃饭...
调用后
----------------------
王二杆子正在偷偷睡觉...
复制代码

这也就解释了,为啥一样是调用method.invoke(),私有方法没有注入成功,而公共方法正常。

4、JDK代理

既然说,CGLIB是经过继承的方式实现代理。那私有方法能不能经过JDK动态代理的方式来呢?

不瞒各位,笔者当时确实想到了这个,不过立刻被右脑打脸。JDK动态代理是经过接口来的,接口里怎么可能有私有方法?

哈哈,看来此路不通。不过笔者却发现了另一个有意思的现象。

至此,咱们再也不讨论公有私有方法的问题,仅仅看Controller是否能够改成JDK动态代理的方式。

一、改成jdk动态代理

首先,咱们须要在配置文件中,设置spring.aop.proxy-target-class=false

而后还须要搞一个接口,这个接口还必须包含一个方法。不然Spring在生成代理的时候,还会判断,若是不包含这些条件,还会是CGLIB的代理方式。

public interface BaseController {
    default void print(){
        System.out.println("-------------");
    }
}
复制代码

而后让咱们的Controller实现这个接口就好了。如今代理方式就变成了JDK动态代理

ok,如今访问/list,你会获得一个友好的404提示:

{
    "status": 404,
    "error": "Not Found",
    "message": "No message available",
    "path": "/list"
}
复制代码

二、为什么404?

这是为啥捏?

SpringMVC初始化的时候,会先遍历全部的Bean,过滤包含Controller注解和RequestMapping注解的类,而后查找类上的方法,获取方法上的URL。最后把URL和方法的映射注册到容器。

若是你对这一过程不理解,能够参阅笔者文章 - Spring源码分析(四)SpringMVC初始化

在过滤的时候,大概有三个条件:

  • 对象自己是否包含Controller相关注解
  • 对象的父类是否包含Controller相关注解
  • 对象的接口是否包含Controller相关注解

此时咱们的userController是一个JDK的代理对象,这三条件都不知足呀,因此Spring认为它并非一个Controller

所以,咱们须要在它接口BaseController上添加一个@RestController注解才行。

加完以后,过滤条件知足了。SpringMVC终于认识它是一个Controller了。不过,若是你如今去访问,还会获得一个404。

三、为什么仍是404?

笔者当时也是崩溃的,为啥仍是404呢?

if (beanType != null && this.isHandler(beanType)) {
	this.detectHandlerMethods(beanName);
}
复制代码

原来经过isHandler条件判断以后,还须要经过detectHandlerMethods检测bean上的方法,注册url和对象method的映射关系。

可是这里有个坑~

咱们知道,不论是JDK动态代理仍是CGLIB动态代理,此时的bean都是代理对象。检测bean上的方法,必定得检测真实的目标对象才有意义。

Spring也正是这样作的,它经过ClassUtils.getUserClass(handlerType);来获取真实对象。

而后看到这段代码的时候,才发现:

这里只处理了CGLIB代理的状况。。换言之,若是是JDK的代理对象,这里返回的仍是代理对象。

那么在外层,拿着这个代理对象去selectMethods查找方法,固然一无所得。最后的结果就是,没有把这个url和对象method映射起来,当咱们访问/list的时候,会报出404。

这里的SpringMVC版本为5.1.7.RELEASE,不知道其余版本是否是也是这样处理的。欢迎探讨~

总结

之前老听一些人说,在Controller里面不要用私有方法,也知道可能会产生问题。

但具体会产生哪些问题?产生问题的根源在哪里?却一直很朦胧,经过本文也许你对这个问题就有了更新的认识。

相关文章
相关标签/搜索