You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

23 KiB

05Spring AOP 常见错误(上)

你好我是傅健。这节课开始我们聊聊Spring AOP使用中常遇到的一些问题。

Spring AOP是Spring中除了依赖注入外DI最为核心的功能顾名思义AOP即Aspect Oriented Programming翻译为面向切面编程。

而Spring AOP则利用CGlib和JDK动态代理等方式来实现运行期动态方法增强其目的是将与业务无关的代码单独抽离出来使其逻辑不再与业务代码耦合从而降低系统的耦合性提高程序的可重用性和开发效率。因而AOP便成为了日志记录、监控管理、性能统计、异常处理、权限管理、统一认证等各个方面被广泛使用的技术。

追根溯源我们之所以能无感知地在容器对象方法前后任意添加代码片段那是由于Spring在运行期帮我们把切面中的代码逻辑动态“织入”到了容器对象方法内所以说AOP本质上就是一个代理模式。然而在使用这种代理模式时,我们常常会用不好,那么这节课我们就来解析下有哪些常见的问题,以及背后的原理是什么。

案例1this调用的当前类方法无法被拦截

假设我们正在开发一个宿舍管理系统这个模块包含一个负责电费充值的类ElectricService它含有一个充电方法charge()

@Service
public class ElectricService {

    public void charge() throws Exception {
        System.out.println("Electric charging ...");
        this.pay();
    }

    public void pay() throws Exception {
        System.out.println("Pay with alipay ...");
        Thread.sleep(1000);
    }

}

在这个电费充值方法charge()中我们会使用支付宝进行充值。因此在这个方法中我加入了pay()方法。为了模拟pay()方法调用耗时代码执行了休眠1秒并在charge()方法里使用 this.pay()的方式调用这种支付方法。

但是因为支付宝支付是第三方接口,我们需要记录下接口调用时间。这时候我们就引入了一个@Around的增强 分别记录在pay()方法执行前后的时间并计算出执行pay()方法的耗时。

@Aspect
@Service
@Slf4j
public class AopConfig {
    @Around("execution(* com.spring.puzzle.class5.example1.ElectricService.pay()) ")
    public void recordPayPerformance(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        joinPoint.proceed();
        long end = System.currentTimeMillis();
        System.out.println("Pay method time costms: " + (end - start));
    }
}

最后我们再通过定义一个Controller来提供电费充值接口定义如下

@RestController
public class HelloWorldController {
    @Autowired
    ElectricService electricService;
    @RequestMapping(path = "charge", method = RequestMethod.GET)
    public void charge() throws Exception{
          electricService.charge();
    };
}

完成代码后,我们访问上述接口,会发现这段计算时间的切面并没有执行到,输出日志如下:

Electric charging ...
Pay with alipay ...

回溯之前的代码可知,在@Around的切面类中我们很清晰地定义了切面对应的方法但是却没有被执行到。这说明了在类的内部通过this方式调用的方法是没有被Spring AOP增强的。这是为什么呢我们来分析一下。

案例解析

我们可以从源码中找到真相。首先来设置个断点调试看看this对应的对象是什么样的

可以看到this对应的就是一个普通的ElectricService对象并没有什么特别的地方。再看看在Controller层中自动装配的ElectricService对象是什么样

可以看到这是一个被Spring增强过的Bean所以执行charge()方法时会执行记录接口调用时间的增强操作。而this对应的对象只是一个普通的对象并没有做任何额外的增强。

为什么this引用的对象只是一个普通对象呢这还要从Spring AOP增强对象的过程来看。但在此之前有些基础我需要在这里强调下。

1. Spring AOP的实现

Spring AOP的底层是动态代理。而创建代理的方式有两种JDK的方式和CGLIB的方式。JDK动态代理只能对实现了接口的类生成代理而不能针对普通类。而CGLIB是可以针对类实现代理主要是对指定的类生成一个子类覆盖其中的方法来实现代理对象。具体区别可参考下图

2. 如何使用Spring AOP

在Spring Boot中我们一般只要添加以下依赖就可以直接使用AOP功能

org.springframework.boot spring-boot-starter-aop

而对于非Spring Boot程序除了添加相关AOP依赖项外我们还常常会使用@EnableAspectJAutoProxy来开启AOP功能。这个注解类引入ImportAspectJAutoProxyRegistrar它通过实现ImportBeanDefinitionRegistrar的接口方法来完成AOP相关Bean的准备工作。

补充完最基本的Spring底层知识和使用知识后我们具体看下创建代理对象的过程。先来看下调用栈

创建代理对象的时机就是创建一个Bean的时候而创建的的关键工作其实是由AnnotationAwareAspectJAutoProxyCreator完成的。它本质上是一种BeanPostProcessor。所以它的执行是在完成原始Bean构建后的初始化BeaninitializeBean过程中。而它到底完成了什么工作呢我们可以看下它的postProcessAfterInitialization方法

public Object postProcessAfterInitialization(@Nullable Object bean, String beanName) {
   if (bean != null) {
      Object cacheKey = getCacheKey(bean.getClass(), beanName);
      if (this.earlyProxyReferences.remove(cacheKey) != bean) {
         return wrapIfNecessary(bean, beanName, cacheKey);
      }
   }
   return bean;
}

上述代码中的关键方法是wrapIfNecessary顾名思义在需要使用AOP时它会把创建的原始的Bean对象wrap成代理对象作为Bean返回。具体到这个wrap过程可参考下面的关键代码行

protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) {
   // 省略非关键代码
   Object[] specificInterceptors = getAdvicesAndAdvisorsForBean(bean.getClass(), beanName, null);
   if (specificInterceptors != DO_NOT_PROXY) {
      this.advisedBeans.put(cacheKey, Boolean.TRUE);
      Object proxy = createProxy(
            bean.getClass(), beanName, specificInterceptors, new SingletonTargetSource(bean));
      this.proxyTypes.put(cacheKey, proxy.getClass());
      return proxy;
   }
   // 省略非关键代码 
}


上述代码中第6行的createProxy调用是创建代理对象的关键。具体到执行过程它首先会创建一个代理工厂然后将通知器advisors、被代理对象等信息加入到代理工厂最后通过这个代理工厂来获取代理对象。一些关键过程参考下面的方法

protected Object createProxy(Class<?> beanClass, @Nullable String beanName,
      @Nullable Object[] specificInterceptors, TargetSource targetSource) {
  // 省略非关键代码
  ProxyFactory proxyFactory = new ProxyFactory();
  if (!proxyFactory.isProxyTargetClass()) {
   if (shouldProxyTargetClass(beanClass, beanName)) {
      proxyFactory.setProxyTargetClass(true);
   }
   else {
      evaluateProxyInterfaces(beanClass, proxyFactory);
   }
  }
  Advisor[] advisors = buildAdvisors(beanName, specificInterceptors);
  proxyFactory.addAdvisors(advisors);
  proxyFactory.setTargetSource(targetSource);
  customizeProxyFactory(proxyFactory);
   // 省略非关键代码
  return proxyFactory.getProxy(getProxyClassLoader());
}

经过这样一个过程一个代理对象就被创建出来了。我们从Spring中获取到的对象都是这个代理对象所以具有AOP功能。而之前直接使用this引用到的只是一个普通对象自然也就没办法实现AOP的功能了。

问题修正

从上述案例解析中,我们知道,只有引用的是被动态代理创建出来的对象才会被Spring增强具备AOP该有的功能。那什么样的对象具备这样的条件呢?

有两种。一种是被@Autowired注解的于是我们的代码可以改成这样即通过@Autowired的方式在类的内部自己引用自己

@Service
public class ElectricService {
    @Autowired
    ElectricService electricService;
    public void charge() throws Exception {
        System.out.println("Electric charging ...");
        //this.pay();
        electricService.pay();
    }
    public void pay() throws Exception {
        System.out.println("Pay with alipay ...");
        Thread.sleep(1000);
    }
}

另一种方法就是直接从AopContext获取当前的Proxy。那你可能会问了AopContext是什么简单说它的核心就是通过一个ThreadLocal来将Proxy和线程绑定起来这样就可以随时拿出当前线程绑定的Proxy。

不过使用这种方法有个小前提,就是需要在@EnableAspectJAutoProxy里加一个配置项exposeProxy = true表示将代理对象放入到ThreadLocal这样才可以直接通过 AopContext.currentProxy()的方式获取到,否则会报错如下:

按这个思路,我们修改下相关代码:

import org.springframework.aop.framework.AopContext;
import org.springframework.stereotype.Service;
@Service
public class ElectricService {
    public void charge() throws Exception {
        System.out.println("Electric charging ...");
        ElectricService electric = ((ElectricService) AopContext.currentProxy());
        electric.pay();
    }
    public void pay() throws Exception {
        System.out.println("Pay with alipay ...");
        Thread.sleep(1000);
    }
}

同时不要忘记修改EnableAspectJAutoProxy注解的exposeProxy属性示例如下

@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class Application {
    // 省略非关键代码
}

这两种方法的效果其实是一样的,最终我们打印出了期待的日志,到这,问题顺利解决了。

Electric charging ...
Pay with alipay ...
Pay method time cost(ms): 1005

案例2直接访问被拦截类的属性抛空指针异常

接上一个案例在宿舍管理系统中我们使用了charge()方法进行支付。在统一结算的时候我们会用到一个管理员用户付款编号,这时候就用到了几个新的类。

User类包含用户的付款编号信息

public class User {
    private String payNum;
    public User(String payNum) {
        this.payNum = payNum;
    }
    public String getPayNum() {
        return payNum;
    }
    public void setPayNum(String payNum) {
        this.payNum = payNum;
    }
}

AdminUserService类包含一个管理员用户User其付款编号为202101166另外这个服务类有一个login()方法,用来登录系统。

@Service
public class AdminUserService {
    public final User adminUser = new User("202101166");
    
    public void login() {
        System.out.println("admin user login...");
    }
}

我们需要修改ElectricService类实现这个需求在电费充值时需要管理员登录并使用其编号进行结算。完整代码如下

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class ElectricService {
    @Autowired
    private AdminUserService adminUserService;
    public void charge() throws Exception {
        System.out.println("Electric charging ...");
        this.pay();
    }

    public void pay() throws Exception {
        adminUserService.login();
        String payNum = adminUserService.adminUser.getPayNum();
        System.out.println("User pay num : " + payNum);
        System.out.println("Pay with alipay ...");
        Thread.sleep(1000);
    }
}

代码完成后执行charge()操作,一切正常:

Electric charging ...
admin user login...
User pay num : 202101166
Pay with alipay ...

这时候由于安全需要就需要管理员在登录时记录一行日志以便于以后审计管理员操作。所以我们添加一个AOP相关配置类具体如下

@Aspect
@Service
@Slf4j
public class AopConfig {
    @Before("execution(* com.spring.puzzle.class5.example2.AdminUserService.login(..)) ")
    public void logAdminLogin(JoinPoint pjp) throws Throwable {
        System.out.println("! admin login ...");
    }
}

添加这段代码后我们执行charge()操作发现不仅没有相关日志而且在执行下面这一行代码的时候直接抛出了NullPointerException

String payNum = dminUserService.user.getPayNum();

本来一切正常的代码因为引入了一个AOP切面抛出了NullPointerException。这会是什么原因呢我们先debug一下来看看加入AOP后调用的对象是什么样子。

可以看出加入AOP后我们的对象已经是一个代理对象了如果你眼尖的话就会发现在上图中属性adminUser确实为null。为什么会这样为了解答这个诡异的问题我们需要进一步理解Spring使用CGLIB生成Proxy的原理。

案例解析

我们在上一个案例中解析了创建Spring Proxy的大体过程在这里我们需要进一步研究一下通过Proxy创建出来的是一个什么样的对象。正常情况下AdminUserService只是一个普通的对象而AOP增强过的则是一个AdminUserService $EnhancerBySpringCGLIB$xxxx。

这个类实际上是AdminUserService的一个子类。它会overwrite所有public和protected方法并在内部将调用委托给原始的AdminUserService实例。

从具体实现角度看CGLIB中AOP的实现是基于org.springframework.cglib.proxy包中 Enhancer和MethodInterceptor两个接口来实现的。

整个过程,我们可以概括为三个步骤:

  • 定义自定义的MethodInterceptor负责委托方法执行
  • 创建Enhance并设置Callback为上述MethodInterceptor
  • enhancer.create()创建代理。

接下来我们来具体分析一下Spring的相关实现源码。

在上个案例分析里我们简要提及了Spring的动态代理对象的初始化机制。在得到Advisors之后会通过ProxyFactory.getProxy获取代理对象

public Object getProxy(ClassLoader classLoader) {
	return createAopProxy().getProxy(classLoader);
}

在这里我们以CGLIB的Proxy的实现类CglibAopProxy为例来看看具体的流程

public Object getProxy(@Nullable ClassLoader classLoader) {
    // 省略非关键代码
    // 创建及配置 Enhancer
    Enhancer enhancer = createEnhancer();
    // 省略非关键代码
    // 获取Callback包含DynamicAdvisedInterceptor亦是MethodInterceptor
    Callback[] callbacks = getCallbacks(rootClass);
    // 省略非关键代码
    // 生成代理对象并创建代理(设置 enhancer 的 callback 值)
    return createProxyClassAndInstance(enhancer, callbacks);
    // 省略非关键代码
}

上述代码中的几个关键步骤大体符合之前提及的三个步骤其中最后一步一般都会执行到CglibAopProxy子类ObjenesisCglibAopProxy的createProxyClassAndInstance()方法:

protected Object createProxyClassAndInstance(Enhancer enhancer, Callback[] callbacks) {
   //创建代理类Class
   Class<?> proxyClass = enhancer.createClass();
   Object proxyInstance = null;
   //spring.objenesis.ignore默认为false
   //所以objenesis.isWorthTrying()一般为true
   if (objenesis.isWorthTrying()) {
      try {
         // 创建实例
         proxyInstance = objenesis.newInstance(proxyClass, enhancer.getUseCache());
      }
      catch (Throwable ex) {
          // 省略非关键代码
      }
   }
       
    if (proxyInstance == null) {
       // 尝试普通反射方式创建实例
       try {
          Constructor<?> ctor = (this.constructorArgs != null ?
                proxyClass.getDeclaredConstructor(this.constructorArgTypes) :
                proxyClass.getDeclaredConstructor());
          ReflectionUtils.makeAccessible(ctor);
          proxyInstance = (this.constructorArgs != null ?
                ctor.newInstance(this.constructorArgs) : ctor.newInstance());
      //省略非关键代码
       }
    }
   // 省略非关键代码
   ((Factory) proxyInstance).setCallbacks(callbacks);
   return proxyInstance;
}

这里我们可以了解到Spring会默认尝试使用objenesis方式实例化对象如果失败则再次尝试使用常规方式实例化对象。现在我们可以进一步查看objenesis方式实例化对象的流程。

参照上述截图所示调用栈objenesis方式最后使用了JDK的ReflectionFactory.newConstructorForSerialization()完成了代理对象的实例化。而如果你稍微研究下这个方法,你会惊讶地发现,这种方式创建出来的对象是不会初始化类成员变量的。

所以说到这里,聪明的你可能已经觉察到真相已经暴露了,我们这个案例的核心是代理类实例的默认构建方式很特别。在这里,我们可以总结和对比下通过反射来实例化对象的方式,包括:

  • java.lang.Class.newInsance()
  • java.lang.reflect.Constructor.newInstance()
  • sun.reflect.ReflectionFactory.newConstructorForSerialization().newInstance()

前两种初始化方式都会同时初始化类成员变量但是最后一种通过ReflectionFactory.newConstructorForSerialization().newInstance()实例化类则不会初始化类成员变量,这就是当前问题的最终答案了。

问题修正

了解了问题的根本原因后修正起来也就不困难了。既然是无法直接访问被拦截类的成员变量那我们就换个方式在UserService里写个getUser()方法,从内部访问获取变量。

我们在AdminUserService里加了个getUser()方法:

public User getUser() {
    return user;
}

在ElectricService里通过getUser()获取User对象

//原来出错的方式:
//String payNum = = adminUserService.adminUser.getPayNum();
//修改后的方式:
String payNum = adminUserService.getAdminUser().getPayNum();

运行下来,一切正常,可以看到管理员登录日志了:

Electric charging ...
! admin login ...
admin user login...
User pay num : 202101166
Pay with alipay ...

但你有没有产生另一个困惑呢既然代理类的类属性不会被初始化那为什么可以通过在AdminUserService里写个getUser()方法来获取代理类实例的属性呢?

我们再次回顾createProxyClassAndInstance的代码逻辑创建代理类后我们会调用setCallbacks来设置拦截后需要注入的代码

protected Object createProxyClassAndInstance(Enhancer enhancer, Callback[] callbacks) {
   Class<?> proxyClass = enhancer.createClass();
   Object proxyInstance = null;
   if (objenesis.isWorthTrying()) {
      try {
         proxyInstance = objenesis.newInstance(proxyClass, enhancer.getUseCache());
      }
   // 省略非关键代码
   ((Factory) proxyInstance).setCallbacks(callbacks);
   return proxyInstance;
}

通过代码调试和分析我们可以得知上述的callbacks中会存在一种服务于AOP的DynamicAdvisedInterceptor它的接口是MethodInterceptorcallback的子接口实现了拦截方法intercept()。我们可以看下它是如何实现这个方法的:

public Object intercept(Object proxy, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
   // 省略非关键代码
    TargetSource targetSource = this.advised.getTargetSource();
    // 省略非关键代码 
      if (chain.isEmpty() && Modifier.isPublic(method.getModifiers())) {
         Object[] argsToUse = AopProxyUtils.adaptArgumentsIfNecessary(method, args);
         retVal = methodProxy.invoke(target, argsToUse);
      }
      else {
         // We need to create a method invocation...
         retVal = new CglibMethodInvocation(proxy, target, method, args, targetClass, chain, methodProxy).proceed();
      }
      retVal = processReturnType(proxy, target, method, retVal);
      return retVal;
   }
   //省略非关键代码
}

当代理类方法被调用会被Spring拦截从而进入此intercept(),并在此方法中获取被代理的原始对象。而在原始对象中,类属性是被实例化过且存在的。因此代理类是可以通过方法拦截获取被代理对象实例的属性。

说到这里我们已经解决了问题。但如果你看得仔细就会发现其实你改变一个属性也可以让产生的代理对象的属性值不为null。例如修改启动参数spring.objenesis.ignore如下

此时再调试程序你会发现adminUser已经不为null了

所以这也是解决这个问题的一种方法,相信聪明的你已经能从前文贴出的代码中找出它能够工作起来的原理了。

重点回顾

通过以上两个案例的介绍相信你对Spring AOP动态代理的初始化机制已经有了进一步的了解这里总结重点如下

  1. 使用AOP实际上就是让Spring自动为我们创建一个Proxy使得调用者能无感知地调用指定方法。而Spring有助于我们在运行期里动态织入其它逻辑因此AOP本质上就是一个动态代理。

  2. 我们只有访问这些代理对象的方法才能获得AOP实现的功能所以通过this引用是无法正确使用AOP功能的。在不能改变代码结果前提下我们可以通过@Autowired、AopContext.currentProxy()等方式获取相应的代理对象来实现所需的功能。

  3. 我们一般不能直接从代理类中去拿被代理类的属性这是因为除非我们显示设置spring.objenesis.ignore为true否则代理类的属性是不会被Spring初始化的我们可以通过在被代理类中增加一个方法来间接获取其属性。

思考题

第二个案例中,我们提到了通过反射来实例化类的三种方式:

  • java.lang.Class.newInsance()
  • java.lang.reflect.Constructor.newInstance()
  • sun.reflect.ReflectionFactory.newConstructorForSerialization().newInstance()

其中第三种方式不会初始化类属性,你能够写一个例子来证明这一点吗?

期待你的思考,我们留言区见!