使用AOP

AOP是Aspect Oriented Programming,即面向切面编程。我们先回顾一下OOP:Object Oriented Programming,OOP作为面向对象编程的模式,获得了巨大的成功,OOP的主要功能是数据封装、继承和多态。而AOP是一种新的编程方式,它和OOP不同,OOP把系统看作多个对象的交互,AOP把系统分解为不同的关注点,或者称之为切面(Aspect)。

要理解AOP的概念,我们先用OOP举例,比如一个业务组件BookService,它有几个业务方法:

  • createBook:添加新的Book;
  • updateBook:修改Book;
  • deleteBook:删除Book。

对每个业务方法,例如,createBook(),除了业务逻辑,还需要安全检查、日志记录和事务处理,它的代码像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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(),代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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的视角来编写上述业务,可以依次实现:

  1. 核心逻辑,即BookService;
  2. 切面逻辑,即:
  3. 权限检查的Aspect;
  4. 日志的Aspect;
  5. 事务的Aspect。

然后,以某种方式,让框架来把上述3个Aspect以Proxy的方式“织入”到BookService中,这样一来,就不必编写复杂而冗长的Proxy模式。

AOP原理

如何把切面织入到核心逻辑中?这正是AOP需要解决的问题。换句话说,如果客户端获得了BookService的引用,当调用bookService.createBook()时,如何对调用方法进行拦截,并在拦截前后进行安全检查、日志、事务等处理,就相当于完成了所有业务功能。

在Java平台上,对于AOP的织入,有3种方式:

  1. 编译期:在编译时,由编译器把切面调用编译进字节码,这种方式需要定义新的关键字并扩展编译器,AspectJ就扩展了Java编译器,使用关键字aspect来实现织入;
  2. 类加载器:在目标类被装载到JVM时,通过一个特殊的类加载器,对目标类的字节码重新“增强”;
  3. 运行期:目标对象和切面都是普通Java类,通过JVM的动态代理功能或者第三方库实现运行期动态织入。

最简单的方式是第三种,Spring的AOP实现就是基于JVM的动态代理。由于JVM的动态代理要求必须实现接口,如果一个普通类没有业务接口,就需要通过CGLIB或者Javassist这些第三方库实现。

AOP技术看上去比较神秘,但实际上,它本质就是一个动态代理,让我们把一些常用功能如权限检查、日志、事务等,从每个业务方法中剥离出来。

需要特别指出的是,AOP对于解决特定问题,例如事务管理非常有用,这是因为分散在各处的事务代码几乎是完全相同的,并且它们需要的参数(JDBC的Connection)也是固定的。另一些特定问题,如日志,就不那么容易实现,因为日志虽然简单,但打印日志的时候,经常需要捕获局部变量,如果使用AOP实现日志,我们只能输出固定格式的日志,因此,使用AOP时,必须适合特定的场景。

装配AOP

我们不用关心AOP创造的“术语”,只需要理解AOP本质上只是一种代理模式的实现方式,在Spring的容器中实现AOP特别方便。

我们以UserServiceMailService为例,这两个属于核心业务逻辑,现在,我们准备给UserService的每个业务方法执行前添加日志,给MailService的每个业务方法执行前后添加日志,在Spring中,需要以下步骤:

首先,我们通过Maven引入Spring对AOP的支持:

1
2
3
4
5
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
<version>${spring.version}</version>
</dependency>

上述依赖会自动引入AspectJ,使用AspectJ实现AOP比较方便,因为它的定义比较简单。

然后,我们定义一个LoggingAspect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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()方法,我们定义了一个@Around注解,它和@Before不同,@Around可以决定是否执行目标方法,因此,我们在doLogging()内部先打印日志,再调用方法,最后打印日志后返回结果。

LoggingAspect类的声明处,除了用@Component表示它本身也是一个Bean外,我们再加上@Aspect注解,表示它的@Before标注的方法需要注入到UserService的每个public方法执行前,@Around标注的方法需要注入到MailService的每个public方法执行前后。

紧接着,我们需要给@Configuration类加上一个@EnableAspectJAutoProxy注解:

1
2
3
4
5
6
@Configuration
@ComponentScan
@EnableAspectJAutoProxy
public class AppConfig {
...
}

Spring的IoC容器看到这个注解,就会自动查找带有@Aspect的Bean,然后根据每个方法的@Before@Around等注解把AOP注入到特定的Bean中。执行代码,我们可以看到以下输出:

1
2
3
4
5
6
7
8
[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方法中,最简单的方法是编写一个子类,并持有原始实例的引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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非常简单,一共需要三步:

  1. 定义执行方法,并在方法上通过AspectJ的注解告诉Spring应该在何处调用此方法;
  2. 标记@Component@Aspect
  3. @Configuration类上标注@EnableAspectJAutoProxy

至于AspectJ的注入语法则比较复杂,请参考Spring文档

Spring也提供其他方法来装配AOP,但都没有使用AspectJ注解的方式来得简洁明了,所以我们不再作介绍。

拦截器类型

顾名思义,拦截器有以下类型:

  • @Before:这种拦截器先执行拦截代码,再执行目标代码。如果拦截器抛异常,那么目标代码就不执行了;
  • @After:这种拦截器先执行目标代码,再执行拦截器代码。无论目标代码是否抛异常,拦截器代码都会执行;
  • @AfterReturning:和@After不同的是,只有当目标代码正常返回时,才执行拦截器代码;
  • @AfterThrowing:和@After不同的是,只有当目标代码抛出了异常时,才执行拦截器代码;
  • @Around:能完全控制目标代码是否执行,并可以在执行前后、抛异常后执行任意拦截代码,可以说是包含了上面所有功能。

使用注解装配AOP

上一节我们讲解了使用AspectJ的注解,并配合一个复杂的execution(* xxx.Xyz.*(..))语法来定义应该如何装配AOP。

在实际项目中,这种写法其实很少使用。假设你写了一个SecurityAspect

1
@Aspect@Componentpublic class SecurityAspect {    @Before("execution(public * com.itranswarp.learnjava.service.*.*(..))")    public void check() {        if (SecurityContext.getCurrentUser() == null) {            throw new RuntimeException("check failed");        }    }}

基本能实现无差别全覆盖,即某个包下面的所有Bean的所有方法都会被这个check()方法拦截。

还有的童鞋喜欢用方法名前缀进行拦截:

1
@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

1
@Componentpublic class UserService {    // 有事务:    @Transactional    public User createUser(String name) {        ...    }    // 无事务:    public boolean isValidName(String name) {        ...    }    // 有事务:    @Transactional    public void updateUser(User user) {        ...    }}

或者直接在class级别注解,表示“所有public方法都被安排了”:

1
@Component@Transactionalpublic class UserService {    ...}

通过@Transactional,某个方法是否启用了事务就一清二楚了。因此,装配AOP的时候,使用注解是最好的方式。

我们以一个实际例子演示如何使用注解实现AOP装配。为了监控应用程序的性能,我们定义一个性能监控的注解:

1
@Target(METHOD)@Retention(RUNTIME)public @interface MetricTime {    String value();}

在需要被监控的关键方法上标注该注解:

1
@Componentpublic class UserService {    // 监控register()方法性能:    @MetricTime("register")    public User register(String email, String password, String name) {        ...    }    ...}

然后,我们定义MetricAspect

1
@Aspect@Componentpublic 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),我们通过它获取性能监控的名称。

有了@MetricTime注解,再配合MetricAspect,任何Bean,只要方法标注了@MetricTime注解,就可以自动实现性能监控。运行代码,输出结果如下:

1
Welcome, Bob![Metrics] register: 16ms

AOP避坑指南

例如,UserService的初始化在UserService$$EnhancerBySpringCGLIB中并未执行,原因是,没必要初始化proxy的成员变量,因为proxy的目的是代理方法。

1
2
3
4
public class UserService {
public final ZoneId zoneId = ZoneId.systemDefault();
public UserService() {}
}

实际上,成员变量的初始化是在构造方法中完成的,这样才是编译器实际编译的代码。

1
2
3
4
5
6
7
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类型的成员变量!

那么,启动了AOP后,应该如何解决这个问题呢?

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

1
2
3
4
5
6
7
8
9
10
11
@Component
public class MailService {
@Autowired
UserService userService;

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

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

但如果我们添加一个public final的方法:

1
2
3
4
5
6
7
@Component
public class UserService {
...
public final ZoneId getFinalZoneId() {
return zoneId;
}
}

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

因此,正确使用AOP,我们需要一个避坑指南:

  1. 访问被注入的Bean时,总是调用方法而非直接访问字段;
  2. 编写Bean时,如果可能会被代理,就不要编写public final方法。

这样才能保证有没有AOP,代码都能正常工作。

思考

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

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

访问数据库 IoC容器
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×