⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/6844903865163186189 「清幽之地」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

前言

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

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

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

不过这究竟是为什么呢?如果你也说不太清楚,就跟着笔者一起来探探究竟。

一、SpringBoot添加AOP

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

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

我们必须要先添加AOP的依赖:

<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:

@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)
]

二、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创建的代理类,不会包含父类中的私有方法。

三、为啥其他属性无法注入

我们看完了上面的测试,现在把Controller中的方法也改成private

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

这就不太对了呀?如果说因为私有方法的原因,导致代理类不会包含此方法的话,那么最多AOP不会生效,为什么UserService也没有注入进来呢?

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

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

1、SpringMVC处理请求

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

2、反射调用

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

上面我们说:

不管方法是否为私有的,UserController这个Bean是已经确定被代理了的。

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

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

3、有啥不一样

在这里,他们的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(),私有方法没有注入成功,而公共方法正常。

四、JDK代理

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

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

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

至此,我们不再讨论公有私有方法的问题,仅仅看Controller是否可以改为JDK动态代理的方式。

1、改为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"
}

2、为何404?

这是为啥捏?

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

如果你对这一过程不理解,可以参阅笔者文章 - Spring源码分析(四)SpringMVC初始化

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

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

此时我们的userController是一个JDK的代理对象,这三条件都不满足呀,所以Spring认为它并不是一个Controller

因此,我们需要在它接口BaseController上添加一个@RestController注解才行。

加完之后,过滤条件满足了。SpringMVC终于认识它是一个Controller了。不过,如果你现在去访问,还会得到一个404。

3、为何还是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里面不要用私有方法,也知道可能会产生问题。

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

文章目录
  1. 1. 前言
  2. 2. 一、SpringBoot添加AOP
  3. 3. 二、CGLIB原理
  4. 4. 三、为啥其他属性无法注入
    1. 4.0.1. 1、SpringMVC处理请求
    2. 4.0.2. 2、反射调用
    3. 4.0.3. 3、有啥不一样
  • 5. 四、JDK代理
    1. 5.0.1. 1、改为jdk动态代理
    2. 5.0.2. 2、为何404?
    3. 5.0.3. 3、为何还是404?
  • 6. 总结