面向切面编程(AOP)

AOP,Aspect Oriented Programming,也叫面向切片编程,是一种编程范式。网上对于AOP的解释都比较繁琐,简单来讲,AOP能通过代理模式对已有的模块进行增强,动态地将代码切入到某个类的指定位置上的思想就是面向切面的编程。

动态代理

动态代理是对代理模式的一种实现。他不同于静态代理,静态代理需要Proxy类和被代理类实现同一接口,采用接口方法覆写的方式实现代理,有些类似于包装器模式。

我们为什么需要代理?假设在当前的业务场景中,业务层某一个方法需要进行多次数据库增删改的操作,这就导致每次使用dao对象对数据库进行操作的时候,都会产生一次数据库连接,这恰恰破坏了数据库事务的原子性:如果中间某个操作异常,程序终止了,但在这异常之前的操作均已提交,导致事务不完整,也无法回滚。正常情况下,我们需要将数据库连接与当前线程绑定,即一个线程一个数据库连接,即使在当前线程中进行了多个数据库操作,这些操作都维持在了一次连接中,若其中某个操作异常,我们仍有余地进行整体的回滚。

下面是手撕的一个用来处理connection的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Component("connectionUtils")
public class ConnectionUtils {
private ThreadLocal<Connection> tl = new ThreadLocal<>();
@Resource(name = "dataSource")
private DataSource dataSource;

public Connection getThreadConnection() {
Connection conn = tl.get();
try {
// whether there has already been a connection in current thread.
if (conn == null) {
conn = dataSource.getConnection();
tl.set(conn);
}
return conn;
} catch (Exception e) {
throw new RuntimeException(e);
}
}

public void removeConnection() {
tl.remove();
}
}

其中我们使用ThreadLocal来进行数据库连接和线程的绑定。其中提供了两个方法:getThreadConnection方法中,会事先判断当前线程中是否已经拥有连接对象,若没有则创建一个并返回,若有则直接返回;removeConnection负责关闭数据库连接。

处理完连接,下面需要处理数据库的事务控制。一个简易的对事务进行控制的类应该至少拥有开始事务、关闭事务、提交和回滚。下面是一个建议的事务控制类。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Component("transactionManager")
public class TransactionManager {
@Resource(name = "connectionUtils")
private ConnectionUtils connUtils;

public void beginTransaction() {
try {
connUtils.getThreadConnection().setAutoCommit(false);
} catch (Exception e) {
e.printStackTrace();
}
}


public void commit() {
try {
connUtils.getThreadConnection().commit();
} catch (Exception e) {
e.printStackTrace();
}
}

public void rollback() {
try {
connUtils.getThreadConnection().rollback();
} catch (Exception e) {
e.printStackTrace();
}
}

public void release() {
try {
// 若直接对Connection对象执行close方法的话,jvm只是将这个连接对象放回连接池。
// 所以我们需要使用ConnectionUtils对象中手写的remove方法将其彻底关掉。
connUtils.getThreadConnection().close();
connUtils.removeConnection();
} catch (Exception e) {
e.printStackTrace();
}
}
}

这时我们就算完成了事务和线程的绑定,保证了事务的原子性。在业务层中,我们可以非常建议的使用我们的事务控制类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public ReturnValue YourServiceMethod() {
ReturnValue returnValue = null;
try {
tm.beginTransaction();
// Your Service Operations
// ...
tm.commit();
return returnValue;
} catch (Exception e) {
tm.rollback();
throw new RuntimeException(e);
} finally {
tm.release();
}
}

可见,我们需要将事务控制的代码将业务逻辑包装起来。但现在有一个问题,我们可能会在一个项目中有很多的业务逻辑,如果给每个业务方法都加上这么一段代码来进行包装的话,会非常的麻烦。而且如果需要对事务控制的代码进行修改的话,业务方法可能也都需要修改,这便造成了代码的不易于维护。这时代理的好处就来了,我们可以实现一个代理类来进行上述事务控制,这样每个业务方法在执行的时候,都会在代理类中的方法中走一遍再继续执行,相当于一个拦截器。Java中主要实现动态代理的方式有两种,JDK代理和cglib代理。

JDK代理

JDK代理是采用JDK内置的对象来实现代理。它要求被代理的对象必须至少实现一个接口,否则就无法进行代理。JDK提供的代理类叫做Proxy,我们可以使用其newProxyInstance方法来实现代理。

1
static Object	newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h)

这个方法需要三个参数:

  • loader:被代理类的类加载器。可以用 类.getClass().getClassloader()来获取到。
  • interfaces:被代理类实现的所有接口。可以用 类.getClass().getInterfaces()来获取到。
  • h:一个InvocationHandler类,直接new一个匿名内部类就可以,其中需要实现一个invoke,类似于回调函数,作用是用来增强被代理的类的方法。

下面是实现的一个建议的用来代理的类,其中实现了一个getAccService方法用来创建能用被代理的AccountSerivce类。

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
27
28
29
30
31
32
33
@Configuration
@ComponentScan("org.example")
public class BeanFactory {
@Resource(name = "accountService")
private AccountService accService;
@Resource(name = "transactionManager")
private TransactionManager tm;

@Bean(name = "proxyAccountService")
public AccountService getAccService() {
return (AccountService) Proxy.newProxyInstance(accService.getClass().getClassLoader(),
accService.getClass().getInterfaces(),
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object returnValue = null;
try {
tm.beginTransaction();
// operations
returnValue = method.invoke(accService, args);

tm.commit();
return returnValue;
} catch (Exception e) {
tm.rollback();
throw new RuntimeException(e);
} finally {
tm.release();
}
}
});
}
}

cglib代理

cglib代理是基于子类的动态代理,他不同于JDK内置的代理对象,cglib代理并不需要被代理的类实现接口就可以代理,但需要引入cglib包。

它通过一个Enhancer对象中的create方法来实现代理。create方法相比上述的newProxyInstance方法来说,不需要传递interface作为参数,因为cglib代理并不需要被代理类实现任何接口。对于第三个参数,create方法需要传递一个MethodInterceptor对象,其中需要实现一个intercept方法作为回调函数来进行拦截方法的实现,具体使用与JDK代理类似,下面是使用cglib实现的getAccService()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public AccountService getAccService() {
return Enhancer.create(accService.getClass(), new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
Object returnValue = null;
try {
tm.beginTransaction();
// operations
returnValue = method.invoke(accService, args);

tm.commit();
return returnValue;
} catch (Exception e) {
tm.rollback();
throw new RuntimeException(e);
} finally {
tm.release();
}
}
});
}

AOP

AOP思想就如上所述,旨在使用动态代理的方式,对模块进行增强。

在Spring中,同时支持基于接口的动态代理以及基于子类的动态代理。

AOP涉及的一些术语:

  • Joinpoint:连接点。指的就是需要被代理所拦截的地方。在上述例子中,很显然连接点就是业务层中的那些方法。在Spring中只支持方法类型的连接点。

  • Pointcut:切入点。指的是对于哪些Jointpoint进行拦截。这或许与Joinpoint概念有些混淆。在距离的拦截方法的实现中,我们可以用一些条件语句来对所有Joinpoint,在Spring中也就是方法,进行筛选。我们可以规定那些方法需要被拦截并增强,哪些不需要。而那些需要被拦截的就是Pointcut,所有需要被拦截和不需要被拦截的都是Jointpoint。简单来讲,Joinpoint是指所有可以被拦截的点,而Pointcut是实际需要被拦截的点,Joinpoint包含Pointcut,所有Pointcut一定是Joinpoint,但Joinpoint不一定是Pointcut。

  • Advice:通知,也就是具体的增强实现,简单来讲就是拦截后对连接点需要做的事情。具体分为before advice,after advice,after-returning advice,after-throwing advice和around advice。这个很好理解,用上面的JDK代理中的invoke方法做个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    Object returnValue = null;
    try {
    tm.beginTransaction(); // before
    // operations
    returnValue = method.invoke(accService, args);

    tm.commit(); // after-returning
    return returnValue;
    } catch (Exception e) { // after-throwing
    tm.rollback();
    throw new RuntimeException(e);
    } finally { // after
    tm.release();
    }
    }

    在具体操作之前的都是before advice,之后的都是after-returning advice,catch中异常抛出部分的都是after-throwing,finally中最后执行的都是after,而整个invoke方法,就是一个around advice。所以。在around advice中,需要有明确的Pointcut方法调用。

  • Introduction:引介。是一种特殊的advice,在不修改类代码的前提下,引介可以在运行时为类动态添加方法或者Field。

  • Target:被代理的目标对象。

  • Weaving:织入。把增强的部分应用到目标对象来创建新的被代理的对象的过程。

  • Proxy:代理。Target被织入增强后,会产生一个被代理的结果类。在上述代码中,我们直接return了被代理的AccountService类。

  • Aspect:切面。Pointcut和Advice的结合,也就是说,是被增强的方法和如何增强的方法两者的结合。

Spring中基于XML的AOP

为了方便演示,我们预先创建一个Logger类用来模拟打印日志,其中提供四个方法,分别对应除了around advice之外的四种advice。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Logger {
public void beforeLog() {
System.out.println("Before logging...");
}

public void afterLog() {
System.out.println("After logging...");
}

public void afterReturningLog() {
System.out.println("After-returning logging...");
}

public void afterThrowingLog() {
System.out.println("After-throwing logging...");
}
}

我们希望printLog函数在每个业务方法执行之前执行,也就是说,我们希望printLog方法作为一个before advice。

下面,在bean.xml中进行相关配置。首先根据Spring官方文档来引入<bean>标签,并配置Service类来把需要被代理的类放进IoC容器中,当然,使用注解也是可以的:

1
2
3
4
5
6
7
8
9
10
11
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/aop
https://www.springframework.org/schema/aop/spring-aop.xsd">

<bean id="accountService" class="org.example.service.impl.AccountServiceImpl"></bean>
</beans>

配置Advice类

首先需要把Advice类放进IoC容器中,具体方法和配置普通Bean一样:

1
<bean id="logger" class="org.example.utils.Logger"></bean>

<aop:config>

使用<aop:config>标签来表明这时AOP配置的开始:

1
2
3
4
<bean id="logger" class="org.example.utils.Logger"></bean>
<aop:config>

</aop:config>

<aop:aspect>

<aop:config>标签来内使用<aop:aspect>标签来标注切面的配置。其中有两个属性:

  • id:给切面一个唯一的标识。

  • ref:指定Advice类bean的id。

1
2
3
4
<bean id="logger" class="org.example.utils.Logger"></bean>
<aop:config>
<aop:aspect id="logAdvice" ref="logger"></aop:aspect>
</aop:config>

配置Advice类型和切入点

<aop:aspect>标签来内使用额外标签来配置Advice类型:

  • <aop:before>:before advice
  • <aop:after>:after advice
  • <aop:after-returning>:after-returning advice
  • <aop:after-throwing>:after-throwing advice
  • <aop:around>:around advice

其中他们都有两个属性:

  • method:指定Advice类中的具体方法。

  • pointcut:指定切入点位置。需要特定的切入表达式,表达式具体形式为:

    1
    访问修饰符 返回值 包名.类名.方法值(参数列表)

上述表达式,需要放在execution()的括号内部。例如,我要在AccountServiceImpl中的saveAccount方法之前进行printLog的切入,具体xml配置如下:

1
2
3
4
5
6
7
<bean id="'accountService" class="org.example.service.impl.AccountServiceImpl"/>
<bean id="logger" class="org.example.utils.Logger"/>
<aop:config>
<aop:aspect id="logAdvice" ref="logger">
<aop:before method="beforeLog" pointcut="execution(public void org.example.service.impl.AccountServiceImpl.saveAccount())"/>
</aop:aspect>
</aop:config>

对于除了around advice之外的剩下三种advice,具体配置方法都是同样的。关于around advice,会在后文讲到。

使用切入表达式需要引入aspectj的相关jar包,具体maven坐标为:

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/org.aspectj/aspectjweaver -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.6</version>
</dependency>

全通配表达式

上述的访问修饰符 返回值 包名.类名.方法值(参数列表)是标准的切入表达式写法,但太过于繁琐,若我需要同时切入多个业务层方法,每个方法都得写一个这玩意,太麻烦,所以我们还有一些简易的写法。

全通配表达式的具体形式为* *..*.*(..),这些乱起八糟的星号的点不需要有任何替换,直接放进execution()内就可使用:

1
2
3
4
5
<aop:config>
<aop:aspect id="logAdvice" ref="logger">
<aop:before method="beforeLog" pointcut="execution(* *..*.*(..))"/>
</aop:aspect>
</aop:config>

非常的amazing啊,而且测试一下还会发现,业务层里的所有方法都被切入了。

具体原理如下:

  1. 访问修饰符可以省略。

  2. 使用通配符来表示任意的返回值,也就是第一个*

  3. 包名也可以用通配符*表示,其中,有几级包,就用几个*.代替。例如上述org.example.service.impl.AccountServiceImpl.saveAccount(),有四级包,就需要用* *.*.*.*.AccountServiceImpl.saveAccount()来表示。此外,还可以用..来代替当前包以下的所有包,例如,用* *.*.AccountServiceImpl.saveAccount()来表示``org.example下的所有包,只要里面有AccountServiceImpl.saveAccount(),就可以切入增强。所以,我们可以用* *..AccountServiceImpl.saveAccount()`来进行全通配。

  4. 类名和方法名也可以用通配符。例如* *..*.*(),但此时只能切入所有不需要参数的方法,需要传参的方法仍需要在括号中指定参数的类型,其中,基本类型直接写名称,引用类型需使用”包名.类名“。

  5. 参数名也可以使用全通配符*,即* *..*.*(*),但这表示切入点方法必须要有参数,不能切入没有参数的方法。若要实现全通配,需要用..来表示任意参数,包括有参数和无参数,即* *..*.*(..)

若使用全通配,整个项目中的所有方法都会被切入,在实际开发中,一般只切入到业务层实现类下的所有方法,具体表达式大致为* org.example.service.impl.*.*(..)

<aop:pointcut>

<aop:pointcut>是一个用来表示通用切入点表达式的标签。例如在Logger类中,我们有多个方法需要以不同advice类型来进行切入,切入位置都是一样的,那么写好几个重复的切入表达式未免有些繁琐,我们就可以用<aop:pointcut>来表示一个通用的切入表达式,并在每个advice所对应的标签内使用pointcut-ref属性来进行指定:

1
2
3
4
5
6
7
8
9
10
<aop:config>
<aop:aspect id="logAdvice" ref="logger">
<aop:before method="beforeLog" pointcut-ref="servicePointcut"/>
<aop:after-returning method="afterReturningLog" pointcut-ref="servicePointcut"/>
<aop:after-throwing method="afterThrowingLog" pointcut-ref="servicePointcut"/>
<aop:after method="afterLog" pointcut-ref="servicePointcut"/>

<aop:pointcut id="servicePointcut" expression="execution(* org.example.service.impl.*.*(..))"/>
</aop:aspect>
</aop:config>

此时上述<aop:pointcut>只作用于当前<aop:aspect>表示的切面内,若要通用与所有切面,需放在<aop:aspect>之外<aop:config>内,且必须置于<aop:aspect>标签之前。

Around Advice切入

Around advice与其他类型的advice不同的地方就在于我们需要让切入点方法处于around advice中被调用,若我们什么都不做的话,单纯按照其他advice的切入方法进行配置的话,会发现around advice执行了,但切入点的方法不会执行。

为了想办法明确切入点方法在around advice中被调用,需要用到Spring提供的ProceedingJoinPoint接口。此接口有一个方法proceed,这个方法能够明确调用切入点方法。我们要做的,就是把这个接口作为参数传入around advice中,在执行时,Spring会自动提供该接口的实现。

例如,我们在Logger中添加一个around advice方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Object aroundLog(ProceedingJoinPoint pjp) {
Object rtValue = null;
try {
System.out.println("Before in around advice...");
Object[] args = pjp.getArgs(); // 获取方法所需的参数列表
rtValue = pjp.proceed(args); // 明确切入点方法
System.out.println("After-returning in around advice...");
return rtValue;
} catch (Throwable t) {
System.out.println("After-throwing in around advice...");
throw new RuntimeException(t);
} finally {
System.out.println("After in around advice...");
}
}

可以看到,我们使用ProceedingJoinPointgetArgs方法来获取参数,并传入proceed方法来进行切入点方法调用。

值得一提的是,上面这个方法与一开始我们手撕的动态代理的代码基本一样。我在这个方法中写了四个打印函数,这四个打印函数,恰恰分别代表了另外四种advice。也就是说,在Spring中,around advice就是一种可以在代码中手动控制增强方法何时执行的方式。

Spring中基于注解的AOP

首先,自然需要先给需要通过AOP代理的切面类注上@Component来告诉Spring需要把这个类放入IoC容器中。

<aop:aspectj-autoproxy>

我们首先需要在bean.xml中加上这个标签,来表示在项目中我们使用注解来进行AOP。

1
2
<context:component-scan base-package="org.example"/>
<aop:aspectj-autoproxy/>

@EnableAspectJAutoProxy

想要完全脱离xml,我们可以用@EnableAspectJAutoProxy来代替<aop:aspectj-autoproxy>。需要将此注解标注在配置类上:

1
2
3
4
5
@Configuration
@ComponentScan("org.example")
@EnableAspectJAutoProxy
public class SpringConfiguration {
}

@Aspect

所有切面类需要用@Aspect来进行标注,以此来代替<aop:aspect>

1
2
3
4
@Component("logger")
@Aspect
public class Logger {
}

@Pointcut

此注解是用来代替<aop:pointcut>标签的。其需要指定一个切入表达式,并标注在一个方法上,该方法内可以什么都不写:

1
2
@Pointcut("execution(* org.example.service.impl.*.*(..))")
private void pointCut() {}

@Before, @After, @AfterReturing, @AfterThrowing和@Around

很显然,这几个注解是用来分别标注五种不同类型的advice的。需要注意的是,Junit也有@Before@After这两个注解,选择的时候记得看包名。

同时,这些注解需要指定一个被@Pointcut标注的方法,注意,方法需要带上括号:

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
27
28
29
30
31
32
33
34
35
36
@Before("pointCut()")
public void beforeLog() {
System.out.println("Before logging...");
}

@After("pointCut()")
public void afterLog() {
System.out.println("After logging...");
}

@AfterReturning("pointCut()")
public void afterReturningLog() {
System.out.println("After-returning logging...");
}

@AfterThrowing("pointCut()")
public void afterThrowingLog() {
System.out.println("After-throwing logging...");
}

@Around("pointCut()")
public Object aroundLog(ProceedingJoinPoint pjp) {
Object rtValue = null;
try {
System.out.println("Before in around advice...");
Object[] args = pjp.getArgs(); // 获取方法所需的参数列表
rtValue = pjp.proceed(args); // 明确切入点方法
System.out.println("After-returning in around advice...");
return rtValue;
} catch (Throwable t) {
System.out.println("After-throwing in around advice...");
throw new RuntimeException(t);
} finally {
System.out.println("After in around advice...");
}
}

注,在一些旧版本的Spring中,使用注解AOP后无法保证每个advice的执行顺序。例如,after也有可能在after-returning之前执行。对于around advice无此问题。5.2.7版本已经修复了这个问题。

Author

s.x.

Posted on

2020-08-10

Updated on

2021-11-19

Licensed under

Comments