2022-06-07:AOP简介、装配AOP、AOP避坑指南
1、AOP简介
AOP是Aspect Oriented Programming,即面向切面编程。
在介绍AOP之前,我们先回忆一下OOP:Object Oriented Programming,OOP作为面向对象编程的模式,获得了巨大的成功,OOP的主要功能是数据封装、继承、多态。
而AOP是一种新的编程方式,它和OOP不同,OOP把系统看做多个对象的交互,AOP把系统分解为不同的关注点,或者称之为切面(Aspect)。
要理解AOP的概念,我们先用OOP举例,比如一个业务组件BookService,它有几个业务方法:
- createBook:添加新的Book;
- updateBook:修改Book;
- deleteBook:删除Book.
对每个业务方法,例如,createBook(),除了业务逻辑,还需要安全检查、日志记录和事务处理,它的代码像这样:
public class BookService { public void createBook(Book book) { securityCheck(); Transaction tx = startTransaction(); try { // 核心业务逻辑 tx.commit(); } catch (RuntimeException e) { tx.rollback(); throw e; } log("created book: " + book); } }
继续编写updateBook(),代码如下:
public class BookService { public void updateBook(Book book) { securityCheck(); Transaction tx = startTransaction(); try { // 核心业务逻辑 tx.commit(); } catch (RuntimeException e) { tx.rollback(); throw e; } log("updated book: " + book); } }
对于安全检查、日志、事务等代码,它们会重复出现在每个业务方法中。使用OOP,我们很难将这些四处分散的代码模块化。
考察业务模型可以发现,BookService关心的是自身的核心逻辑,但整个系统还要求关注安全检查、日志、事务等功能,这些功能实际上“横跨”了多个业务方法,为了实现这些功能,不得不在每个业务方法上重复编写代码。
一种可行的方法是使用Proxy模式,将某个功能,例如权限检查,放在Proxy中:
public class SecurityCheckBookService implements BookService { private final BookService target; public SecurityCheckBookService(BookService target) { this.target = target; } public void createBook(Book book) { securityCheck(); target.createBook(book); } public void updateBook(Book book) { securityCheck(); target.updateBook(book); } public void deleteBook(Book book) { securityCheck(); target.deleteBook(book); } private void securityCheck() { ... } }
这种方法的缺点是比较麻烦,必须先抽取接口,然后针对每个方法实现Proxy。
另一种方法是,既然SecurityCheckBookService的代码都是标准的Proxy样版代码,不如把权限检查视为一种切面(Aspect),把日志、事务也视为切面,然后以某种自动化的方式,把切面织入到核心逻辑中,实现Proxy模式。
如果我们以AOP的视角编写上述业务,可以依次实现:
- 核心逻辑,即BookService;
- 切面逻辑,即:权限检查的Aspect、日志的Aspect、事务的Aspect。
然后,以某种方式,让框架来把上述3个Aspect以Proxy的方式“织入”到BookService中,这样一来就不用编写复杂而冗长的Proxy模式。
AOP原理
如何把切面织入到核心逻辑中?这正是AOP需要解决的问题。换句话说,如果客户端获得了BookService的引用,当调用bookService.createBook()时,如何对调用方法进行拦截,并在拦截前后进行安全检查、日志、事务等处理,就相当于完成了所有业务功能。
在Java平台上,对AOP的织入,有3种方式:
- 编译期:在编译时,由编译器把切面调用编译进字节码,这种方式需要重新定义新的关键字并扩展编译器,AspectJ就扩展了Java编译器,使用关键字aspect来实现织入;
- 类加载器:在目标类被装载到JVM时,通过一个特殊的类加载器,对目标类的字节码重新“增强”;
- 运行期:目标对象和切面都是普通Java类,通过JVM的动态代理功能或第三方库实现运行期动态织入。
最简单的方式是第三种,Spring的AOP就是基于JVM的动态代理。由于JVM的动态代理要求必须实现接口,如果一个普通类没有业务接口,就需要通过CGLIB或者Javassist这些第三方库实现。
AOP技术看上去比较神秘,但实际上其本质就是一个动态代理,让我们把一些常用功能如权限检查、日志、事务等,从每个业务方法中剥离出来。
需要特别指出的是,AOP对于解决特定问题,例如事务管理很有用,这是因为分散在各处的事务代码几乎是完全相同的,并且它们需要的参数(JDBC的Connection)也是固定的。另一些特定问题,如日志就不那么容易实现,因为日志虽然简单,但是打印的时候经常要捕获局部变量,如果使用AOP实现日志,我们只能输出固定格式的日志,因此,使用AOP时必须适合特定的场景。
2、装配AOP
在AOP编程中,我们经常会遇到以下概念:
- Aspect:切面,即一个横跨多个核心逻辑的功能,或者称之为系统关注点;
- Joinpoint:连接点,即定义在应用程序流程的何处插入切面的执行;
- Pointcut:切入点,即一组连接点的集合;
- Advice:增强,指特定连接点上执行的动作;
- Introduction:引介,指为一个已有的Java对象动态地增加新的接口;
- Weaving:织入,指将切面整合到程序的执行流程中;
- Interceptor:拦截器,是一种实现增强的方式;
- Target Object:目标对象,即真正执行业务的核心逻辑对象;
- AOP Proxy:AOP代理,是客户端持有的增强后的对象引用。
不过在使用的时候,我们并不用关心AOP这些术语,只需要理解AOP本质上只是一种代理模式的实现方式,在Spring的容器中实现AOP很方便。
我们以UserService和MailService为例,这两个属于核心业务逻辑,现在我们准备给UserService的每个业务方法执行前添加日志,给MailService的每个业务方法执行前后添加日志,在Spring中,需要以下步骤:
①通过Maven引入Spring对AOP的支持:
<dependency> <groupId>org.springframework</groupId> <artifactId>spring-aspects</artifactId> <version>${spring.version}</version> </dependency>
上述依赖会自动引入AspectJ,使用AspectJ实现AOP比较方便,因为它的定义比较简单。
②我们定义一个LoggingAspect:
@Aspect @Component public class LoggingAspect { // 在执行UserService的每个方法前执行: @Before("execution(public * com.itranswarp.learnjava.service.UserService.*(..))") public void doAccessCheck() { System.err.println("[Before] do access check..."); } // 在执行MailService的每个方法前后执行: @Around("execution(public * com.itranswarp.learnjava.service.MailService.*(..))") public Object doLogging(ProceedingJoinPoint pjp) throws Throwable { System.err.println("[Around] start " + pjp.getSignature()); Object retVal = pjp.proceed(); System.err.println("[Around] done " + pjp.getSignature()); return retVal; } }
观察doAccessCheck()方法,我们定义了一个@Before注解,后面的字符串是告诉AspectJ应该在何处执行该方法,这里写的意思是:执行UserService的每个public方法前执行doAccessCheck()代码。
再观察doLogging()方法,我们定义了一个@Aroung注解,它和@Before不同,@Around可以决定是否执行目标方法,因此,我们在doLogging()内部先打印日志,再调用方法,最后打印日志后返回结果。
在LoggingAspect类的声明处,除了用@Component表示它本身也是一个Bean外,我们再加上@Aspect注解,表示它的@Before标注的方法需要注入到UserService的每个public方法执行前,@Around标注的方法需要注入到MailService的每个public方法执行前后。
紧接着,我们需要给@Configuration类加上一个@EnableAspectJAutoProxy注解:
@Configuration @ComponentScan @EnableAspectJAutoProxy public class AppConfig { ... }
Spring的IoC容器看到该注解,就会自动查找带有@Aspect的Bean,然后根据每个方法的@Before、@Around等注解把AOP注入到特定的Bean中。执行代码,我们可以看到以下输出:
[Before] do access check... [Around] start void com.itranswarp.learnjava.service.MailService.sendRegistrationMail(User) Welcome, test! [Around] done void com.itranswarp.learnjava.service.MailService.sendRegistrationMail(User) [Before] do access check... [Around] start void com.itranswarp.learnjava.service.MailService.sendLoginMail(User) Hi, Bob! You are logged in at 2020-02-14T23:13:52.167996+08:00[Asia/Shanghai] [Around] done void com.itranswarp.learnjava.service.MailService.sendLoginMail(User)
这说明执行业务逻辑前后,确实执行了我们定义的Aspect(即LoggingAspect的方法)。
不过LoggingAspect定义的方法是如何注入到其他Bean中的呢?
其实AOP的原理很简单,我们以LoggingAspect.doAccessCheck()为例,把它注入到UserService的每个public方法中,最简单的方法是编写一个子类,并持有原始实例的引用:
public UserServiceAopProxy extends UserService { private UserService target; private LoggingAspect aspect;//原始实例的引用 public UserServiceAopProxy(UserService target, LoggingAspect aspect) { this.target = target; this.aspect = aspect; } public User login(String email, String password) { // 先执行Aspect的代码: aspect.doAccessCheck(); // 再执行UserService的逻辑: return target.login(email, password); } public User register(String email, String password, String name) { aspect.doAccessCheck(); return target.register(email, password, name); } ... }
这些都是Spring容器启动时为我们自动创建的注入了Aspect的子类,它取代了原始的UserService(原始的UserService实例作为内部变量隐藏在UserServiceAopProxy中)。如果我们打印从Spring容器获取的UserService实例类型,它类似UserService$$EnhancerBySpringCGLIB$$1f44e01c,实际上是Spring使用CGLIB动态创建的子类,但是对于调用方来说,感受不到任何区别。
Spring对接口类型使用JDK动态代理,对普通类使用CGLIB创建子类。如果一个Bean的class是final,Spring将无法为其创建子类。
可见,虽然Spring容器内部实现AOP的逻辑比较复杂(需要使用AspectJ解析注解,并通过CGLIB实现代理类),但我们使用AOP却非常简单,一共需要3步:
- 定义执行方法,并在方法上通过AspectJ的注解告诉Spring应该在何处调用该方法;
- 标记@Component和@Aspect;
- 在@Configuration类上标记@EnableAspectJAutoProxy。
至于AspectJ的注入语法则比较复杂,参考Spring文档。
Spring也提供了其他方法来装配AOP,但都没有使用AspectJ注解的方式来得简洁明了,所以我们不再做介绍了。
拦截器类型
拦截器有以下类型:
- @Before:先执行拦截代码,再执行目标代码。如果拦截器抛出异常,那么目标代码就不再执行了;
- @After:先执行目标代码,再执行拦截器代码。无论目标代码是否抛出异常,拦截器代码都会执行;
- @AfterReturning:和@After不同的是,只有当目标代码正常返回时,才执行拦截器代码;
- @AfterThrowing:和@After不同的是,只有当目标代码抛出了异常时,才执行拦截器代码;
- @Around:能完全控制目标代码是否执行,并且可以在执行前后、抛出异常后执行任意拦截代码,可以说是包含了上边的所有功能。
小结
在Spring容器中使用AOP很简单,只需要定义执行方法,并用AspectJ的注解标注应该在何处执行并执行。
Spring通过CGLIB动态创建子类等方式来实现AOP代理模式,大大简化了代码。
3、使用注解装配AOP
上一节我们讲解了如何使用AspectJ的注解,并配合一个复杂的execution(* xxx.Xyz.*(..))语法来定义如何装配AOP。
在实际项目中,这种写法其实很少使用。假设我们写了一个SecurityAspect:
@Aspect @Component public class SecurityAspect { @Before("execution(public * com.itranswarp.learnjava.service.*.*(..))") public void check() { if (SecurityContext.getCurrentUser() == null) { throw new RuntimeException("check failed"); } } }
基本上可以实现无差别全覆盖,即某个包下的所有Bean的所有方法都会被这个check()方法拦截。
还可以根据方法名前缀进行拦截:
@Around("execution(public * update*(..))") public Object doLogging(ProceedingJoinPoint pjp) throws Throwable { // 对update开头的方法切换数据源: String old = setCurrentDataSource("master"); Object retVal = pjp.proceed(); restoreCurrentDataSource(old); return retVal; }
不过这种非精准匹配的误伤很大,因为从方法前缀区分是不是数据库操作是非常不可取的。
我们在使用AOP时,要注意虽然Spring容器可以把指定的方法通过AOP规则装配到指定的Bean的指定方法前后,但是,如果自动装配时,因为不恰当的范围,容易导致意想不到的结果,即很多不需要AOP代理的Bean也被自动代理了,并且后续新增的Bean,如果不清楚现有的AOP装配规则,容易被强迫装配。
使用AOP时,被装配的Bean最好自己清楚地知道自己被安排了。例如,Spring提供的@Transactional就是一个很好的例子。如果我们自己写的Bean希望在一个数据库事务中被调用,就标注上@Transactional:
@Component public class UserService { // 有事务: @Transactional public User createUser(String name) { ... } // 无事务: public boolean isValidName(String name) { ... } // 有事务: @Transactional public void updateUser(User user) { ... } }
或者直接在class级别注解,表示“所有public方法都被安排了”:
@Component @Transactional public class UserService { ... }
通过@Transactional,某个方法是否启用了事务就清楚了。因此装配AOP时,使用注解是最好的方式。
我们用一个实际例子演示如何使用注解实现AOP装配。为了监控应用程序的性能,我们定义一个性能监控的注解:
@Target(METHOD) @Retention(RUNTIME) public @interface MetricTime { String value(); }
在需要被监控的关键方法上标注该注解:
@Component public class UserService { // 监控register()方法性能: @MetricTime("register") public User register(String email, String password, String name) { ... } ... }
然后我们定义MetricAspect:
@Aspect @Component public class MetricAspect { @Around("@annotation(metricTime)") public Object metric(ProceedingJoinPoint joinPoint, MetricTime metricTime) throws Throwable { String name = metricTime.value(); long start = System.currentTimeMillis(); try { return joinPoint.proceed(); } finally { long t = System.currentTimeMillis() - start; // 写入日志或发送至JMX: System.err.println("[Metrics] " + name + ": " + t + "ms"); } } }
注意metric()方法标注了@Around("@annotation(metricTime)"),它的意思是,符合条件的目标方法是带有@MetricTime注解的方法,因为metric()方法参数类型是MetricTime(而参数名是metricTime),我们通过它获取性能监控的名称。
有了@MetricTime注解,再配合MetricAspect,任何Bean,只要方法标注了@MetricTime注解,就可以自动实现性能监控。运行代码,输出结果如下:
Welcome, Bob!
[Metrics] register: 16ms
小结
使用注解实现AOP需要先定义注解,然后使用@Around("@annotation(name)")实现装配;
使用注解既简单,又能明确标识AOP装配,是使用AOP推荐的方式。
4、AOP避坑指南
无论使用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修饰了一下成员变量zoneId,这里却变成了null,原因在什么地方?
为何加了AOP就报NPE,不加就一切正常?且final字段并不执行?为了解答该问题,我们需要深入理解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。然后,该代理类会覆写所有public与protected方法,并在内部将调用委托给原始的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,我们需要一个避坑指南:
- 访问被注入的Bean时,总是调用方法而非直接访问字段;
- 编写Bean时,如果可能会被代理,就不要编写public final方法。
这样才能保证无论有无AOP,代码都可以正常工作。
小结
- 由于Spring通过CGLIB实现代理类,我们要避免直接访问Bean的字段,以及由final方法带来的“未代理”问题。
- 遇到CglibAopProxy的相关日志,务必要仔细检查,防止因为AOP出现NPE异常。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
2021-06-07 QT:中文字符串与“常量中有字符串”报错
2020-06-07 Pycharm:设置自带控制台的python版本