Spring Aop使用之避坑和cglib动态代理

我们都知道AOP本质上就是一个代理模式,但是因为Spring使用了CGLIB来实现运行期动态创建Proxy,如果我们没能深入理解其运行原理和实现机制,就可能遇到各种诡异的问题。

1、举例反应现象

假设我们定义了一个UserService:

@Component
public class UserService {
    // 成员变量:
    public final ZoneId zoneId = ZoneId.systemDefault();

    // 构造方法:
    public UserService() {
        System.out.println("UserService(): init...");
        System.out.println("UserService(): zoneId = " + this.zoneId);
    }

    // public方法:
    public ZoneId getZoneId() {
        return zoneId;
    }

    // public final方法:
    public final ZoneId getFinalZoneId() {
        return zoneId;
    }
}

再写个MailService,并注入UserService:

@Component
public class MailService {
    @Autowired
    UserService userService;

    public String sendMail() {
        ZoneId zoneId = userService.zoneId;
        String dt = ZonedDateTime.now(zoneId).toString();
        return "Hello, it is " + dt;
    }
}

最后用main()方法测试一下:

@Configuration
@ComponentScan
@EnableAspectJAutoProxy
public class AppConfig {
    public static void main(String[] args) {
        ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
        MailService mailService = context.getBean(MailService.class);
        System.out.println(mailService.sendMail());
    }
}

查看输出,一切正常:

UserService(): init...
UserService(): zoneId = Asia/Shanghai
Hello, it is 2020-04-12T10:23:22.917721+08:00[Asia/Shanghai]

下面,我们给UserService加上AOP支持,就添加一个最简单的LoggingAspect:

@Aspect
@Component
public class LoggingAspect {
    @Before("execution(public * com..*.UserService.*(..))")
    public void doAccessCheck() {
        System.err.println("[Before] do access check...");
    }
}

再次运行,不出意外的话,会得到一个NullPointerException:

Exception in thread "main" java.lang.NullPointerException: zone
    at java.base/java.util.Objects.requireNonNull(Objects.java:246)
    at java.base/java.time.Clock.system(Clock.java:203)
    at java.base/java.time.ZonedDateTime.now(ZonedDateTime.java:216)
    at com.itranswarp.learnjava.service.MailService.sendMail(MailService.java:19)
    at com.itranswarp.learnjava.AppConfig.main(AppConfig.java:21)

仔细跟踪代码,会发现null值出现在MailService.sendMail()内部的这一行代码:

@Component
public class MailService {
    @Autowired
    UserService userService;

    public String sendMail() {
        ZoneId zoneId = userService.zoneId;
        System.out.println(zoneId); // null
        ...
    }
}

我们在UserService中特意用final修饰了一下成员变量:

@Component
public class UserService {
    public final ZoneId zoneId = ZoneId.systemDefault();
    ...
}

用final标注的成员变量为null?what happened?

2、原因分析

为什么加了AOP就报NPE,去了AOP就一切正常?final字段不执行,难道JVM有问题?为了解答这个诡异的问题,我们需要深入理解Spring使用CGLIB生成Proxy的原理:

第一步,正常创建一个UserService的原始实例,这是通过反射调用构造方法实现的,它的行为和我们预期的完全一致;

第二步,通过CGLIB创建一个UserService的子类,并引用了原始实例和LoggingAspect:

public UserService$$EnhancerBySpringCGLIB extends UserService {
    UserService target;
    LoggingAspect aspect;

    public UserService$$EnhancerBySpringCGLIB() {
    }

    public ZoneId getZoneId() {
        aspect.doAccessCheck();
        return target.getZoneId();
    }
}

如果我们观察Spring创建的AOP代理,它的类名总是类似UserService E n h a n c e r B y S p r i n g C G L I B EnhancerBySpringCGLIB EnhancerBySpringCGLIB1c76af9d。为了让调用方获得UserService的引用,它必须继承自UserService。然后,该代理类会覆写所有public和protected方法,并在内部将调用委托给原始的UserService实例。

当启用AOP时,从ApplicationContext中获取的UserService实例是proxy,注入到MailService中的UserService实例也是proxy。那么最终的问题来了:proxy实例的成员变量,也就是从UserService继承的zoneId,它的值是null。原因在于,proxy代理类没有初始化zoneId,因为proxy的目的是代理方法。

实际上,成员变量的初始化是在构造方法中完成的。这是我们看到的代码:

public class UserService {
    public final ZoneId zoneId = ZoneId.systemDefault();
    public UserService() {
    }
}

这是编译器实际编译的代码:

public class UserService {
    public final ZoneId zoneId;
    public UserService() {
        super(); // 构造方法的第一行代码总是调用super()
        zoneId = ZoneId.systemDefault(); // 继续初始化成员变量
    }
}

然而,对于Spring通过CGLIB动态创建的UserService$$EnhancerBySpringCGLIB代理类,它的构造方法中,并未调用super(),因此,从父类继承的成员变量,包括final类型的成员变量,统统都没有初始化。

我们可能会有疑问:Java语言规定,任何类的构造方法,第一行必须调用super(),如果没有,编译器会自动加上,怎么Spring的CGLIB就可以搞特殊?

这是因为自动加super()的功能是Java编译器实现的,它发现你没加,就自动给加上,发现你加错了,就报编译错误。但实际上,如果直接构造字节码,一个类的构造方法中,不一定非要调用super()。Spring使用CGLIB构造的Proxy类,是直接通过被代理类生成字节码,并没有源码-编译-字节码这个步骤。

那么如何修复呢?

3、避坑操作

修复很简单,只需要把直接访问字段的代码,改为通过方法访问:

@Component
public class MailService {
    @Autowired
    UserService userService;

    public String sendMail() {
        // 不要直接访问UserService的字段:
        ZoneId zoneId = userService.getZoneId();
        ...
    }
}

无论注入的UserService是原始实例还是代理实例,getZoneId()都能正常工作,因为代理类会覆写getZoneId()方法,并将其委托给原始实例:

public UserService$$EnhancerBySpringCGLIB extends UserService {
    UserService target = ...
    ...

    public ZoneId getZoneId() {
        return target.getZoneId();
    }
}

注意到我们还给UserService添加了一个public+final的方法:

@Component
public class UserService {
    ...
    public final ZoneId getFinalZoneId() {
        return zoneId;
    }
}

如果在MailService中,调用的不是getZoneId(),而是getFinalZoneId(),又会出现NullPointerException,这是因为,代理类无法覆写final方法(这一点绕不过JVM的ClassLoader检查),该方法返回的是代理类的zoneId字段,即null。

实际上,如果我们加上日志,Spring在启动时会打印一个警告:

10:43:09.929 [main] DEBUG org.springframework.aop.framework.CglibAopProxy - Final method [public final java.time.ZoneId xxx.UserService.getFinalZoneId()] cannot get proxied via CGLIB: Calls to this method will NOT be routed to the target instance and might lead to NPEs against uninitialized fields in the proxy instance.

上面的日志大意就是,因为被代理的UserService有一个final方法getFinalZoneId(),这会导致其他Bean如果调用此方法,无法将其代理到真正的原始实例,从而可能发生NPE异常。

因此,正确使用AOP,我们需要一个避坑指南:
1、访问被注入的Bean时,总是调用方法而非直接访问字段;
2、编写Bean时,如果可能会被代理,就不要编写public final方法。
这样才能保证有没有AOP,代码都能正常工作。

最后提出几个问题并给出回答:
1、为什么Spring刻意不初始化Proxy继承的字段?

@Component
public class HelloServiceImpl implements HelloService {

    @Value("${good.from:xxx}")
    String goodFrom;

    Good good;

    @PostConstruct
    public void init() {
        good = new Good(goodFrom, ...);
    }

    public void sentMail(String to) {
        ...
    }

}

因为通过上面的解读我们知道,cglib是直接生成代理类的字节码的,没有走预编译的过程,所以他的构造函数没有加上父类的super(),而编译过程中实际的初始化都是在构造函数中完成的,自然没办法初始化,另外就是有些变量需要通过依赖其他注入比如上面的goodForm和good,Spring生成的代理类怎么注入?所以说根本原因就是spring无法在逻辑上正常初始化proxy的字段,所以干脆不初始化,并通过NPE直接暴露出来。
另外还有一个次要原因就是如果对字段进行修改,proxy的字段其实根本没改:

@Component
public class MailService {
    String status = "init";

    public void sentMail(String to) {
        this.status = "sent";
    }
}

你想如果在代理类中sentMail,又会委托给原生类去调用,实际改的还是原生类的字段内容,所以只有原始Bean的方法会对自己的字段进行修改,他无法改proxy的字段。

2 如果一个Bean不允许任何AOP代理,应该怎么做来“保护”自己在运行期不会被代理?
将类设置为 final 的防止 cglib 创建Proxy,并且不继承接口防止 JDK 自带的动态代理。

以上内容主要转载自:https://www.liaoxuefeng.com/wiki/1252599548343744/1339039378571298 稍稍加入了一些个人的理解

  • 3
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值