阿里云-云小站(无限量代金券发放中)
【腾讯云】云服务器、云数据库、COS、CDN、短信等热卖云产品特惠抢购

AOP避坑指南

33次阅读
没有评论

共计 5888 个字符,预计需要花费 15 分钟才能阅读完成。

无论是使用 AspectJ 语法,还是配合 Annotation,使用 AOP,实际上就是让 Spring 自动为我们创建一个 Proxy,使得调用方能无感知地调用指定方法,但运行期却动态“织入”了其他逻辑,因此,AOP 本质上就是一个代理模式。

因为 Spring 使用了 CGLIB 来实现运行期动态创建 Proxy,如果我们没能深入理解其运行原理和实现机制,就极有可能遇到各种诡异的问题。

我们来看一个实际的例子。

假设我们定义了一个 UserService 的 Bean:

@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
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...");
    }
}

别忘了在 AppConfig 上加上@EnableAspectJAutoProxy。再次运行,不出意外的话,会得到一个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?逗我呢?

怎么肥四?

为什么加了 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$$EnhancerBySpringCGLIB$$1c76af9d(你没看错,Java 的类名实际上允许$ 字符)。为了让调用方获得 UserService 的引用,它必须继承自 UserService。然后,该代理类会覆写所有publicprotected方法,并在内部将调用委托给原始的 UserService 实例。

这里出现了两个 UserService 实例:

一个是我们代码中定义的 原始实例,它的成员变量已经按照我们预期的方式被初始化完成:

UserService original = new UserService();

第二个 UserService 实例实际上类型是 UserService$$EnhancerBySpringCGLIB,它引用了原始的UserService 实例:

UserService$$EnhancerBySpringCGLIB proxy = new UserService$$EnhancerBySpringCGLIB();
proxy.target = original;
proxy.aspect = ...

注意到这种情况仅出现在启用了 AOP 的情况,此刻,从 ApplicationContext 中获取的 UserService 实例是 proxy,注入到 MailService 中的 UserService 实例也是 proxy。

那么最终的问题来了:proxy 实例的成员变量,也就是从 UserService 继承的zoneId,它的值是null

原因在于,UserService成员变量的初始化:

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

UserService$$EnhancerBySpringCGLIB 中,并未执行。原因是,没必要初始化 proxy 的成员变量,因为 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 类,是直接生成字节码,并没有源码 - 编译 - 字节码这个步骤,因此:

注意

Spring 通过 CGLIB 创建的代理类,不会初始化代理类自身继承的任何成员变量,包括 final 类型的成员变量!

再考察 MailService 的代码:

@Component
public class MailService {@Autowired
    UserService userService;

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

如果没有启用 AOP,注入的是原始的 UserService 实例,那么一切正常,因为 UserService 实例的 zoneId 字段已经被正确初始化了。

如果启动了 AOP,注入的是代理后的 UserService$$EnhancerBySpringCGLIB 实例,那么问题大了:获取的 UserService$$EnhancerBySpringCGLIB 实例的 zoneId 字段,永远为null

那么问题来了:启用了 AOP,如何修复?

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

@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,代码都能正常工作。

思考

为什么 Spring 刻意不初始化 Proxy 继承的字段?

如果一个 Bean 不允许任何 AOP 代理,应该怎么做来“保护”自己在运行期不会被代理?

练习

修复启用 AOP 导致的 NPE。

下载练习

小结

由于 Spring 通过 CGLIB 实现代理类,我们要避免直接访问 Bean 的字段,以及由 final 方法带来的“未代理”问题。

遇到 CglibAopProxy 的相关日志,务必要仔细检查,防止因为 AOP 出现 NPE 异常。

正文完
星哥说事-微信公众号
post-qrcode
 0
星锅
版权声明:本站原创文章,由 星锅 于2024-08-05发表,共计5888字。
转载说明:除特殊说明外本站文章皆由CC-4.0协议发布,转载请注明出处。
【腾讯云】推广者专属福利,新客户无门槛领取总价值高达2860元代金券,每种代金券限量500张,先到先得。
阿里云-最新活动爆款每日限量供应
评论(没有评论)
验证码
【腾讯云】云服务器、云数据库、COS、CDN、短信等云产品特惠热卖中