在一个应用系统中,我们会有一些核心业务逻辑之外的关注点,比如安全、日志、事务,这些关注点横跨整个业务系统,与具体业务功能交织在一起。对于此类关注点,面向对象编程束手无策。AOP(面向切面编程)是解决该问题的技术概念模型,一个切面是对某个横切关注点的模块化,织入将这个切面插入目标模块而不需要目标模块修改代码。
有很多的AOP实现方案,比如强大的AspectJ;Spring AOP借鉴了AspectJ的概念和工具,但是比AspectJ要轻量级很多,绝大多数情况下能满足需求。
AOP是Spring知识图谱里面比较难懂的一部分,能够搞明白Spring AOP基本就够了,因此本章略去了“如何在Spring中使用AspectJ”的介绍。另外由于基于注解的AOP是主流形式,本章也跳过了“xml格式的AOP配置方法”。
本章对应的官方文档地址。
概念术语
AOP是一种完全不同于面向对象的编程理念,涉及一系列该领域的术语,所以要学习AOP首先要理解这些术语。本人的切身体验,只要理解了术语,AOP就掌握了大半。注意,下面的术语并不是Spring特有的。
切面(Aspect)
切面是应用系统中某个横切关注点的模块化。事务的处理是一个典型的横切关注点;所谓模块化,对Spring AOP来说,即用一个类来实现切面。切面类是一个普通的java类,加上@Aspect注解,或者在xml里面使用aop相关标签进行配置。由于xml配置现在使用已经较少,本章不再介绍基于xml的aop配置,仅关注基于注解的配置。
连接点(Join point)
连接点指应用程序的一个执行点,在连接点上,AOP才可以将切面功能插入进去。对Spring AOP来说,连接点就是指方法的执行。
通知 (Advice)
通知是指切面在连接点上执行的行为。通知有不同的类型,包括“around”、 “before” 和“after”,它们的含义后面会解释。Spring将通知建模为拦截器(interceptor),并在连接点上维护了一个拦截器链(interceptor chain)。
切点(Pointcut)
切点是谓词逻辑,用来匹配连接点和通知。通知总会与一个切点相关联,以确定在哪些连接点上执行。Spring AOP借用了AspectJ的切点表达式语言来声明切点。
引入(Introduction)
指动态地给现有的类型添加新的行为,Spring AOP允许我们给一个bean引入新的接口实现。
目标(Target Object)
被一个或多个切面通知的对象,也即切面织入的目标bean。
AOP代理(AOP Proxy)
为了绑定通知和连接点,Spring需要创建目标对象的代理对象。Spring创建的Proxy要么是一个JDK动态代理,或CGLIB代理。注意:其他AOP框架也许不需要创建代理。
织入
织入是指把切面应用到目标对象并创建代理对象的过程。切面在指定的连接点被织入到目标对象中,在目标对象的声明周期中,可以有多个织入点:编译时、类加载时、运行时;Spring AOP的织入总是发生在运行时。
上面的术语很多,目前我们只需要记住切面从技术上就是一个java类,它包含了切点+通知,切点描述了切面想要在哪些目标方法上插入逻辑,通知则定义插入的位置和逻辑。这几个概念及其之间的关系是Spring AOP的核心。
@AspectJ注解
@AspectJ指一系列注解,可以在一个普通的java上面定义切面、切点和通知。它是AspectJ项目引入的,Spring借用了这套注解,并使用AspectJ提供的工具库来做切点表达式解析和连接点匹配。
启用@AspectJ
在@Configuration类上面加上@EnableAspectJAutoProxy即可:
@Configuration
@EnableAspectJAutoProxy
public class AppConfig {
}
切面
只要在一个普通的bean上加上@Aspect注解即可,
@Component
@Aspect
public class NotVeryUsefulAspect {
}
Aspect类和普通的java类一样,可以有字段,方法;最重要的是包含了切点、通知和引入的定义。在Spring里面,Aspect必须要声明为一个bean,才能被扫描到且被处理为一个切面,所以上面的声明加上了@Component。
另外,一个切面不会被另一个切面织入,一旦Spring发现某个bean是一个切面,就会将其排除在AOP织入目标之外。
切点
AspectJ类型匹配符
在介绍切点表达式之前,我们先简单学习一下AspectJ的类型匹配符,它的规则有三条:
- *:匹配任意字符串,不包括分隔符;
- …:相当于模式*的重复,用于有多个段的模式匹配,如在类型模式中匹配任何数量子包;而在方法参数模式中匹配任何数量参数;不能匹配类名;另外也不能用在某个模式的开头处;
- +:匹配指定类型的所有子类型,放在类名的后面;
例子:
模式 | 匹配 |
---|---|
java.lang.String | 匹配String类型; |
java.*.String | 匹配java包下的任何“一级子包”下的String类型,如匹配java.lang.String,但不匹配java.lang.ss.String |
java…* | 匹配java包及任何子包下的任何类型; 如匹配java.lang.String、java.lang.annotation.Annotation |
java.lang.*ing | 匹配任何java.lang包下的以ing结尾的类型; |
java.lang.Number+ | 匹配java.lang包下的任何Number的自类型; 如匹配java.lang.Integer,也匹配java.math.BigInteger |
注:此部分内容源来自互联网
切点表达式
切点表达式用来定义切点,指明切面要通知的bean方法。切点表达是切点指示器(PCD, Pointcut Designaor)组成,Spring AOP支持AspectJ PCD的一个子集。每个PCD定义了一个匹配规则,多个PCD可以通过逻辑操作符结合起来;下面是Spring支持的所有PCD。
execution
这是切点表达式的主要PCD,它用来匹配一个方法的执行,定义模式如下:
execution(modifiers-pattern? ret-type-pattern declaring-type-pattern?name-pattern(param-pattern)
throws-pattern?)
- modifiers-pattern: 方法的权限修饰符,如public,可选;
- ret-type-pattern: 返回值类型模式,可是使用*通配符;
- declaring-type-pattern:对象类型模式,可选;
- name-pattern:方法名模式,可使用*通配符;
- param-pattern:参数模式,()代表无参数,(…)代表任意参数,(*,String)代表两个参数,且第二个是String类型;
其实declaring-type-pattern还可以拆成两部分,包名部分和类名部分,可以只有类名部分,此时匹配Aspect所在package下面的类。
示例:
-
execution(public * *(..))
返回值是*, 没有类型限定,方法名是*,参数是…, 因此匹配所有pulic方法 -
execution(* set*(..))
没有权限限定,返回值是*, 没有类型限定,方法名是set*,参数是…,因此匹配所有名字以set开头的方法 -
execution(* com.xyz.service.AccountService.*(..))
没有权限限定,返回值是*,类型是com.xyz.service.AccountService,方法名是*,参数是是…,因此匹配AccountService的所有方法 -
execution(* com.xyz.service..*(..))
没有权限限定,包限定为com.xyz.service…,类限定为*,方法限定为…,因此匹配com.xyz.service及子包下任意类定义的任意方法。
注:上面的示例来自源文档,经过亲测,源文档有些示例解释是错误的。
within
执行方法的对象的class类型。
比如within(com.xyz.service.*)
表示,方法必须在com.xyz.service
这个package的某个类里面; within(com.xyz.service..*)
表示,方法必须在com.xyz.service及其子包的某个类里面。
within(AccountService)
不会匹配AccountService的子类对象,除非声明为within(AccountService+)
。
this
方法执行的bean,必须是某个类型,类型模式参数不能使用*通配符,可以使用+
。
并且这里的bean在Spring AOP里是指代理bean对象。
this(com.xyz.service.Interface)
限定织入的代理必须实现了该接口。
target
方法执行的target bean必须是某个类型,在Spring AOP里是指被代理的对象;不能使用*通配符,可以使用+
。
target(com.xyz.service.Interface)
限定织入的目标对象必须实现该接口。
注:this和target的不同,来源于代理对象和目标对象之间类型信息的不同。
args
限定方法执行参数的类型匹配模式,不能使用*通配符,可以使用+
。
args(java.io.Serializable)
限定方法有一个参数,且类型是Serializable。
注意:execution指示器里的参数列表匹配的是方法的签名参数,args匹配的是参数的运行时类型。
@target
方法执行的目标对象class,拥有某个注解。比如@target(org.springframework.transaction.annotation.Transactional)
@within
执行方法的声明类型,拥有某个注解。
@annotation
执行方法的本身拥有某个注解。
bean
这是Spring额外添加的PCD,限制方法执行target bean的名字。
bean(*Service)
限定只有名字以Service结尾的bean。
PCD组合
PCD本质上是一个谓词逻辑,因此可以通过逻辑操作符结合在一起,支持3个操作符:&& || !,分别是:与、或、非。 比如@Pointcut("within(com.xyz.service.*) && args(java.io.Serializable)")
。
声明切点
有了切点表达式,就可以声明切点了,声明切点使用@Pointcut注解。
@Aspect
public class NotVeryUsefulAspect {
@Pointcut("execution(* transfer(..))") // the pointcut expression
private void transfer() {} // the pointcut signature
@Pointcut("transfer && within(com.service..*")
private void serviceTransfer() {} // the pointcut signature
}
@Pointcut注解加在一个方法上面,方法的实现体无关紧要,关键是transfer这个方法名成为了切点的名字。后者又可以在另一个切点表达式中使用。我们可以把切点名,理解为切点表达式的别名。
需要注意的是,切点匹配的是一个方法的执行,而不是静态的方法定义。
通知(Advice)
Spring AOP支持三种类型的通知,Before、After、Around。
Before
Before是指在方法执行之前执行通知:
@Aspect
public class BeforeExample {
@Poincut("execution(* com.xyz.myapp.dao..(..))")
private void daoOperation() {}
@Before("daoOperation")
public void doAccessCheck() {
// ...
}
}
这里我们先定义切点,通知通过名字引用该切点,这是复用切点定义的推荐方法。从技术上,也可以直接将切点表达式内嵌在通知定义里:
@Before("execution(* com.xyz.myapp.dao..(..))")
public void doAccessCheck() {
// ...
}
After
After是指在方法执行之后执行通知
@After("daoOperation")
public void doAccessCheck() {
// ...
}
After还可以区分正常返回和异常返回:
@AfterReturning("daoOperation")
public void doAccessCheck() {
// ...
}
@AfterThrowing("daoOperation)")
public void doRecoveryActions() {
// ...
}
After通知还可以捕获返回值或抛出的异常:
@AfterReturning(
pointcut="daoOperation",
returning="retVal")
public void doAccessCheck(Object retVal) {
// ...
}
@AfterThrowing(
pointcut="daoOperation",
throwing="ex")
public void doRecoveryActions(DataAccessException ex) {
// ...
}
Around
Around通知是最强大的形式,它完全拦截了方法的调用,让我们可以在方法执行前、后插入逻辑,甚至跳过方法的执行或修改返回值。
@Aspect
public class AroundExample {
@Around("pointCutName")
public Object doBasicProfiling(ProceedingJoinPoint pjp) throws Throwable {
// start stopwatch
Object retVal = pjp.proceed();
// stop stopwatch
return retVal;
}
}
Around通知方法的第一个参数必须是ProceedingJoinPoint,它包含了此次方法执行的封装,调用ProceedingJoinPoint.proceed()以继续方法执行(执行目标方法,或其他通知)。
通知的参数
很多情况下,通知需要知道目标方法的一些信息。上面介绍的Around通知有一个ProceedingJoinPoint参数,通过它可以得到很多信息,实际上所有的通知都可以加上一个JoinPoint类型的参数(如果有多个参数,必须JoinPoint必须放在第一个)。
JoinPoint包含以下信息:
- getArgs(): 方法参数
- getThis(): proxy对象引用;
- getTarget(): target对象引用;
- getSignature(): 方法签名;
唯一的缺陷是这些信息都是无类型的,需要我们自己通过java反射或或强制类型转换来使用。
参数绑定
可以通过参数绑定来捕获连接点的实参和返回值,以及执行切点匹配的相关参数。前面展示了@AfterReturn和@AfterThrow如何绑定返回值和异常,而更普遍的参数绑定的方式是通过切点指示器。
args
如果在args里面放置一个参数名而不是一个类型名,那么方法调用的参数就能够绑定到这个名字。
@Aspect
public class BeforeExample {
@Poincut("execution(* com.xyz.myapp.dao..(..)) && args(name)")
private void daoOperation(name) {}
@Before("daoOperation(name)")
public void doAccessCheck(String name) {
// ...
}
}
这个示例中,args限定了方法调用只有一个参数,同时将参数值绑定到name这个名字。在@Before通知里,可以使用这个名字来获取方法调用的参数。
如果方法有多个参数,但是我们只想绑定第一个,可以这样写:args(name,...)
;如果既想用args来绑定参数,又想限定参数类型,可以使用两个args:args(name) && args(String)
。
其他PCD
this和target指示器可以分别绑定代理对象和目标对象到通知里面。
@Before("daoOperation(name) && this(bean)")
public void doAccessCheck(String name, BeanType bean) {
// ...
}
@within, @target, @annotation, and @args可以绑定他们所匹配的注解对象。
@Before("daoOperation(name) && @annotation(a)")
public void doAccessCheck(String name, AnnotationType a) {
// ...
}
绑定参数的名字
我们知道,java方法在Release模式编译之后,参数名字是丢失了的,所以Spring AOP把绑定传参数传递给通知方法时,只能依据参数的类型来推断。为了解决这个问题,通知注解增加了argNames属性,指定了绑定参数传递的顺序。
@Before(pointCut="daoOperation(name) && @annotation(a)",
argNames="name,a")
public void doAccessCheck(String name, AnnotationType a) {
// ...
}
不过需要注意的是,JointPoint参数永远位于第一个,也不需要在argsNames里面指定:
@Before(pointCut="daoOperation(name) && @annotation(a)",
argNames="name,a")
public void doAccessCheck(JointPoint joinPoint, String name, AnnotationType a) {
// ...
}
通知执行顺序
如果不同的切面,同时切入了同一个连接点(JoinPoint),那么它的执行顺序是不确定的。可以通过@Order注解来定义相对顺序:
@Component
@Aspect
@Order(1)
public class NotVeryUsefulAspect {
}
如果同一个切面的不同通知,都织入了同一个连接点,那么无法通过技术手段来定义相对顺序。
引入(Introduction)
"引入"指为现有对象动态添加新的接口,实现方式如下:
@Component
@Aspect
public class NotVeryUsefulAspect {
@DeclareParents(value = "beans.service.*",defaultImpl= IntroductionImpl.class)
private IntroductionInterface mixin;
}
@DeclareParents注解附加在切面的一个成员变量上面,这个成员名字没有意义,可以是static的。它的类型决定了要引入的接口类型。注解的value属性值是AspectJ类型表达式,defaultImpl属性指向接口实现类。
上面的定义给beans.service下面所有的类,添加了IntroductionInterface实现。
切面的实例化模式
切面也是一个bean,默认情况下也是singleton模式的。所有的匹配的连接点都共享这个切面实例。
Spring支持AspectJ提供的perthis和pertarget两种模式,看一个实例:
@Aspect("perthis(com.xyz.myapp.SystemArchitecture.businessService())")
public class MyAspect {
private int someState;
@Before(com.xyz.myapp.SystemArchitecture.businessService())
public void recordServiceUsage() {
// ...
}
}
对匹配的连接点所在每个proxy对象,创建一个MyAspect实例,pertarget是类似的机制。
注: 说实话,这块没看明白,proxy和target对象难道不是一一对应的吗?那么perthis和pertarget还有什么区别呢?望明白的同行不吝赐教。
示例
现在我们通过一个简单的示例来展示Spring AOP的用法,它的目标是通过Around通知来拦截一个方法,并修改调用的实参。
先定义目标service,只有一个方法,就是简单打印参数:
@Service
public class NormalService implements PrintInterface {
public void print(String value) {
System.out.println("NormalService.print:" + value);
}
}
定义切面,包含切点和Around通知,并且绑定了连接点的实参到通知方法(param)。这个通知做了两件事,一是记录了连接点的执行轨迹,二是替换了连接点的调用参数为“gogo”
。
@Component
@Aspect
public class SmallAspect {
@Pointcut("execution(* beans.service.NormalService.print(..)) && args(param)")
private void pointCut(String param) {
}
@Around("pointCut(param)")
private void aroundAdvice(ProceedingJoinPoint joinPoint, String param) throws Throwable {
System.out.println("\naroundAdvice before " + joinPoint.getTarget().getClass().getSimpleName() + "." + joinPoint.getSignature().getName());
System.out.println("param is " + param);
joinPoint.proceed(new Object[]{"gogo"});
System.out.println("aroundAdvice after " + joinPoint.getTarget().getClass().getSimpleName() + "." + joinPoint.getSignature().getName());
System.out.println("\n");
}
}
基本这样就好了,我们可以来写main函数运行这个示例,看看效果:
@Configuration
@EnableAspectJAutoProxy
public class Main {
public static void main(String[] args) {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Main.class);
PrintInterface normalService = (PrintInterface)context.getBean("normalService");
normalService.print("raw");
}
@Bean
public NormalService normalService() {
return new NormalService();
}
@Bean
public SmallAspect aspect() {
return new SmallAspect();
}
}