Spring-AOP作用及原理剖析

AOP的作用

AOP(Aspect Orient Programming),作为面向对象编程的一种补充,广泛应用于处理一些具有横切性质的系统级服务,如事务管理、安全检查、缓存、对象池管理等。AOP 实现的关键就在于 AOP 框架自动创建的 AOP 代理,AOP 代理则可分为静态代理和动态代理两大类,其中静态代理是指使用 AOP 框架提供的命令进行编译,从而在编译阶段就可生成 AOP 代理类,因此也称为编译时增强;而动态代理则在运行时借助于 JDK 动态代理、CGLIB 等在内存中“临时”生成 AOP 动态代理类,因此也被称为运行时增强。

在OOP中,正是这种分散在各处且与对象核心功能无关的代码(横切代码)的存在,使得模块复用难度增加。AOP则将封装好的对象剖开,找出其中对多个对象产生影响的公共行为,并将其封装为一个可重用的模块,这个模块被命名为“切面”(Aspect),切面将那些与业务无关,却被业务模块共同调用的逻辑提取并封装起来,减少了系统中的重复代码,降低了模块间的耦合度,同时提高了系统的可维护性。

面向切面编程(Aspect Oriented Programming, AOP)是面向对象编程(Object Oriented Programming,OOP)的强大补充,通过横切面注入的方式引入其他额外功能,比如日志记录,事务处理等,用户无需修改源代码就可以”优雅”的实现额外功能的补充,对于Programmer来说,AOP是个非常强大的工具。

Spring AOP 原理剖析

通过前面介绍可以知道:AOP 代理其实是由 AOP 框架动态生成的一个对象,该对象可作为目标对象使用。AOP 代理包含了目标对象的全部方法,但 AOP 代理中的方法与目标对象的方法存在差异:AOP 方法在特定切入点添加了增强处理,并回调了目标对象的方法。

AOP 代理所包含的方法与目标对象的方法示意图

图 3.AOP 代理的方法与目标对象的方法

Spring 的 AOP 代理由 Spring 的 IoC 容器负责生成、管理,其依赖关系也由 IoC 容器负责管理。因此,AOP 代理可以直接使用容器中的其他 Bean 实例作为目标,这种关系可由 IoC 容器的依赖注入提供。

纵观 AOP 编程,其中需要程序员参与的只有 3 个部分:

  • 定义普通业务组件。
  • 定义切入点,一个切入点可能横切多个业务组件。
  • 定义增强处理,增强处理就是在 AOP 框架为普通业务组件织入的处理动作。

切点

和大多数技术类似,AOP技术也有自己的行话。切面(Aspects)常常通过通知(advice)、切点(pointcuts)和织入点(join points)来描述。下图展示了这几个概念如何被联系在一起。

在Spring AOP中,使用AspectJ的切点表达式语言定义切点其中excecution()是最重要的描述符,其它描述符用于辅助excecution()。

excecution()的语法如下

这个语法看似复杂,但是我们逐个分解一下,其实就是描述了一个方法的特征:

问号表示可选项,即可以不指定。

excecution(* com.tianmaying.service.BlogService.updateBlog(..))

  • modifier-pattern:表示方法的修饰符
  • ret-type-pattern:表示方法的返回值
  • declaring-type-pattern?:表示方法所在的类的路径
  • name-pattern:表示方法名
  • param-pattern:表示方法的参数
  • throws-pattern:表示方法抛出的异常

注意事项

  • 其中后面跟着“?”的是可选项。
  • 在各个pattern中,可以使用”*”来表示匹配所有。
  • 在param-pattern中,可以指定具体的参数类型,多个参数间用“,”隔开,各个也可以用“*”来表示匹配任意类型的参数,如(String)表示匹配一个String参数的方法;(*,String)表示匹配有两个参数的方法,第一个参数可以是任意类型,而第二个参数是String类型。
  • 可以用(..)表示零个或多个任意的方法参数。

使用&&符号表示与关系,使用||表示或关系、使用!表示非关系。在XML文件中使用and、or和not这三个符号。

在切点中引用Bean

Spring还提供了一个bean()描述符,用于在切点表达式中引用Spring Beans。例如:

这表示将切面应用于BlogService的updateBlog方法上,但是仅限于ID为tianmayingBlog的Bean。

也可以排除特定的Bean:

其它切点描述符

其它可用的描述符包括:

  • args()
  • @args()
  • execution()
  • this()
  • target()
  • @target()
  • within()
  • @within()
  • @annotation

当你有更加复杂的切点需要描述时,你可能可以用上这些描述符,通过这些你可以设置目标类实现的接口、方法和类拥有的标注等信息。具体可以参考Spring的官方文档。

这里一共有9个描述符,execution()前面已经详细讨论过,其它几个可以做一个简单的分类:

  • this()是用来限定方法所属的类,比如this(com.tianmaying.service.BlogServiceInterface)表示实现了com.tianmaying.service.BlogServiceInterface的所有类。如果this括号内是具体类而不是接口的话,则表示单个类。
  • @annotation表示具有某个标注的方法,比如@annotation(org.springframework.transaction.annotation.Transactional)表示被Transactional标注的方法
  • args 表示方法的参数属于一个特定的类
  • within 表示方法属于一个特定的类
  • target 表示方法所属的类
  • 它们对应的加了@的版本则表示对应的类具有某个标注。

单独定义切点

详细了解了定义切点之后,在回顾上一节中的代码:

可以看到通过标注方式定义切点只需要两个步骤:

  1. 定义一个空方法
  2. 使用@Piontcut标注,填入切点表达式

@AfterReturning(pointcut = “execution(* com.tianmaying.aopdemo..*.bookFlight(..))”, returning = “retVal”)中通过pointcout = “logPointCut”引用了这个切点。当然也可以在@AfterReturning()直接定义切点表达式,如:

推荐使用前一种方法,因为这样可以在多个通知中复用切点的定义。

切点定义实例

这里我们给出一些切点的定义实例。

上面的代码定义了三个切点:

  1. 任意公共方法(实际应用中一般不会定义这样的切点)
  2. 在within(com.xyz.someapp.web包或者其子包下任意类的方法
  3. 同时满足切点1和切点2条件的切点,这里使用了&&符号
  4. 标注了Transactional的类的方法
  5. 标注了Transactional的方法

定义通知

依然回到TimeRecordingAspect的代码:

定义了切点之后,我们需要定义何时调用recordTime方法记录时间,即需要定义通知。

AspectJ提供了五种定义通知的标注:

  • @Before:前置通知,在调用目标方法之前执行通知定义的任务
  • @After:后置通知,在目标方法执行结束后,无论执行结果如何都执行通知定义的任务
  • @After-returning:后置通知,在目标方法执行结束后,如果执行成功,则执行通知定义的任务
  • @After-throwing:异常通知,如果目标方法执行过程中抛出异常,则执行通知定义的任务
  • @Around:环绕通知,在目标方法执行前和执行后,都需要执行通知定义的任务

通过标注定义通知只需要两个步骤:

  1. 将以上五种标注之一添加到切面的方法中
  2. 在标注中设置切点的定义

创建环绕通知

环绕通知相比其它四种通知有其特殊之处。环绕通知本质上是将前置通知、后置通知和异常通知整合成一个单独的通知。

用@Around标注的方法,该方法必须有一个ProceedingJoinPoint类型的参数,比如上面代码中的recordTime的签名:

在方法体中,需要通过这个参数,以joinPoint.proceed();的形式调用目标方法。注意在环绕通知中必须进行该调用,否则目标方法本身的执行就会被跳过。

比如在recoredTime的实现中:

在目标方法调用前首先记录系统时间,然后通过pjp.proceed()调用目标方法,调用完之后再次记录系统时间,即可计算出目标方法的耗时。

处理通知中参数

有时我们需要给通知中的方法传递目标对象的一些信息,比如传入目标业务方法的参数。

在前面的代码中我们曾经通过@AfterReturning(pointcut = “logPointCut()”, returning = “retVal”)在通知中获取目标业务方法的返回值。获取参数的方式则需要使用关键词是args。

假设需要对系统中的accountOperator方法,做Account的验证,验证逻辑以切面的方式显示,示例如下:

args()中参数的名称必须跟切点方法的签名中(public void validateAccount(Account account))的参数名称相同。如果使用切点函数定义,其中的参数名称也必须与通知方法签名中的参数完全相同,例如: