Spring AOP:概念和用法

在一个应用系统中,我们会有一些核心业务逻辑之外的关注点,比如安全、日志、事务,这些关注点横跨整个业务系统,与具体业务功能交织在一起。对于此类关注点,面向对象编程束手无策。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下面的类。

示例:

  1. execution(public * *(..))
    返回值是*, 没有类型限定,方法名是*,参数是…, 因此匹配所有pulic方法

  2. execution(* set*(..))
    没有权限限定,返回值是*, 没有类型限定,方法名是set*,参数是…,因此匹配所有名字以set开头的方法

  3. execution(* com.xyz.service.AccountService.*(..))
    没有权限限定,返回值是*,类型是com.xyz.service.AccountService,方法名是*,参数是是…,因此匹配AccountService的所有方法

  4. 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();
    }
}

完整的示例代码:SpringBasic的子工程aop

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章