據說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裏面不要用私有方法,也知道可能會產生問題。

但具體會產生哪些問題?產生問題的根源在哪裏?卻一直很朦朧,經過本文也許你對這個問題就有了更新的認識。

相關文章
相關標籤/搜索