Spring实战 四 AOP编程
AOP编程
程序中有很多代码存在于各个业务逻辑中,这些代码要做的工作往往是相同的,比如记录日志,开启事务,关闭事务等等,我们的业务逻辑并不应该把目光放在这些代码中,而是要把目光放在自己独有的逻辑上。
在没用过AOP之前,我每次编写程序中的日志相关的代码的时候都会想,这好乱啊,并且它对我的程序逻辑起不到任何作用,还必须要放在这。AOP就用来解决这个问题,它将我们正在执行的一个任务切开,在执行的某个特定阶段,运行指定的功能,比如在方法开始运行之前记录日志。这看起来使用继承和委托也能实现,但AOP的魅力就是,它对你的业务代码完全没有侵入性,不用修改业务代码,而是在外部定义一些切面,实现对业务代码的横向扩展。
术语
AOP有一些并不生动甚至有点让人莫名其妙的术语,但是历史原因,想改变这些术语很难,所以我们只能学习了。
首先我先说一些我会在后面用到的说法以确保它们出现时你能知道是什么意思
- 目标方法,指我们的业务逻辑,指要被扩展的方法
通知(Advice)
通知是需要执行的附加功能,比如在方法开始运行前记录日志,这个记录日志操作就是通知。为啥要取这么个名字????
通知除了定义要执行什么功能之外,还要定义何时执行
- 前置通知(Before),在目标方法调用之前调用通知功能
- 后置通知(After),在目标方法调用之后调用通知功能,无论方法是正确结束还是失败结束
- 返回通知(After-returning),在目标方法正常返回之后调用通知功能
- 异常通知(After-throwing),在目标方法发生异常后调用通知功能
- 环绕通知(Around),包裹住被通知的方法,在目标方法调用之前,之后,包括异常等等都执行自定义的通知功能。
连接点(Join point)
连接点和通知何时执行的概念差不多,不过我的理解,它是相对于目标方法而言的。
就是指能够扩展代码的一个时机,比如一个字段修改后,一个方法执行前,一个方法抛出异常时。当然,纯Spring的AOP只能对方法进行扩展,而不能对字段进行扩展。
切点(Pointcut)
切点就是目标方法,就是要将功能横向扩展到哪里。一般使用一种表达式来精确匹配要扩展的目标方法。
切面(Aspect)
切面是通知和切点的集合,用来描述一次横向扩展的所有细节。
引入(Introduction)
如果说通知是在对目标方法的一个扩展,那么引入就是在现有的类中添加新的属性和行为。
织入(Weaving)
把切面应用到目标对象并创建新的代理对象的过程。因为Java中的AOP大部分都是基于代理的,Java本身不支持这些横向扩展功能。有以下几个织入时机。
- 编译期,切面在编译器被织入,需要特殊的编译器
- 类加载器,切面在目标类加载到JVM时动态修改字节码实现织入,需要特殊的类加载器
- 运行期,切面在应用运行的某个时刻被织入,AOP容器使用动态代理技术实现,Spring AOP以此种方式实现切面
Spring中的AOP
各种AOP实现的原理不同,Spring的AOP自然有自己的限制。
Spring通知是Java编写的
在AspectJ的早先版本中的通知不是使用Java编写的,而是使用Java语言的一种扩展,虽然功能上来说更加精细和强大,但也增加了额外的学习成本。
Spring选择了另一条路,使用标准的Java类来编写通知。
Spring在运行时通知对象
Spring使用动态代理技术,用代理类包装目标类,拦截被通知方法的调用,执行切面逻辑并调用目标方法。
直到应用需要被代理的bean时Spring才创建代理对象。如果使用ApplicationContext的话,代理对象在加载Bean时创建。
Spring只支持方法级别的连接点
由于使用的技术限制,Spring只能支持方法级别的连接点,无法对属性,构造器进行扩展。如果需要对应功能可以使用Aspect来补充对应功能。
通过切点来选择连接点
Spring AOP中使用AspectJ的切点表达式语言来定义切点。但由于Spring AOP只支持方法级别的连接点,所以AspectJ的很多功能它无法使用,所以严格来说Spring AOP使用的切点表达式是AspectJ的一个子集。
实际上,只有execution
是实际执行匹配的,其它的都是限制匹配的。
编写切点
假设我们要将这个perform方法定义成一个切点
package concert;
public interface Performance {
void perform();
}
那么我们可以这样写表达式
execution(* concert.Performance.perform(..))
我们看看这其中的结构
现在假设我们只想定义在concert
包下的Performance
实现类的perform
方法为切点的话,那么我们需要使用within
指示器。
execution(* concert.Performance.perform(..)) && within(concert.*)
注意这两个指示器中间使用&&连接,在使用XML时,&有其他含义,可以使用and代替。
在切点中选择Bean
这是Spring的扩展功能,可以基于Bean的id来指定一个bean。
execution(* concert.Performance.perform(..)) && bean('woodstock')
上面的代码会将通知织入到id为woodstock的bean中。
execution(* concert.Performance.perform(..)) && !bean('woodstock')
上面的代码会将通知织入到所有id不为woodstock的bean中。
使用注解创建切面
一场演出如果没有观众那是没意义的,观众会在演出之前做一些动作,比如有序进场,静音手机等等,会在演出之后鼓掌等等。传统的面向对象编程很有可能就把这部分交给Performance管理了,或者就是使用监听器等一系列设计模式,但,无论何时,Performance都不应该处理这些代码,而是专注于演出,专注于表演的业务逻辑。所以将观众定义为一个切面再好不过。
我们使用AspectJ的注解方式创建切面
@Aspect
public class Audience {
@Before("execution(* io.lilpig.springlearn.springlearn01.chapter04.concert.Performance.perform(..))")
public void silenceCellPhones() {
System.out.println("Silence Cell Phones...");
}
@Before("execution(* io.lilpig.springlearn.springlearn01.chapter04.concert.Performance.perform(..))")
public void takeSeats() {
System.out.println("Take Seats...");
}
@AfterReturning("execution(* io.lilpig.springlearn.springlearn01.chapter04.concert.Performance.perform(..))")
public void applause() {
System.out.println("CLAP CLAP CLAP...");
}
@AfterThrowing("execution(* io.lilpig.springlearn.springlearn01.chapter04.concert.Performance.perform(..))")
public void demandRefund() {
System.out.println("Demanding A Refund...");
}
}
擦,好丑啊,在每一个注解里我们都使用了类似的AspectJ切点表达式,并且在我的项目中,包名巨长无比,看起来让人眼花缭乱。
我们可以将相同的表达式提取出来。
@Aspect
public class Audience {
@Pointcut("execution(* io.lilpig.springlearn.springlearn01.chapter04.concert.Performance.perform(..))")
public void performance() {}
@Before("performance()")
public void silenceCellPhones() {
System.out.println("Silence Cell Phones...");
}
@Before("performance()")
public void takeSeats() {
System.out.println("Take Seats...");
}
@AfterReturning("performance()")
public void applause() {
System.out.println("CLAP CLAP CLAP...");
}
@AfterThrowing("performance()")
public void demandRefund() {
System.out.println("Demanding A Refund...");
}
}
看上面的代码,首先,我们通过@PointCut
注解定义了一个切入点,在后面我们可以通过方法名引用这个切入点,相当于我们对AspectJ表达式的一个扩展。
其次,我们通过@Before
定义了两个前置通知,用@AfterReturning
定义了一个正常退出时的后置通知,用@AfterThrowing
定义了一个异常退出时的后置通知。
这个类虽然被各种注解包裹,可以被视作一个切面,但它实际上和普通的Bean并无区别。我们可以在配置中像声明其他Bean一样声明它
@Configuration
public class ConcertConfig {
@Bean
public Audience audience() {
return new Audience();
}
@Bean("woodstock")
public Performance performance() {
return new WoodStock();
}
}
但至今为止,Audience还只是一个Bean,它不会被Spring识别成一个切面,并在合适的时候织入,如果你想这样,你必须在配置类上使用@EnableAspectJAutoProxy
来启动AspectJ的自动代理功能,或者通过别的方式来启动。
在XML中可以通过加入aop命名空间中的<aop:aspectj-autoproxy />
来启动。
我已经迫不及待的测试一波了
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = ConcertConfig.class)
public class AspectTest01 {
@Autowired
private Performance performance;
@Test
public void testPerform() {
performance.perform();
System.out.println(performance.getClass().getName());
}
}
可以看到通知都被执行了,除了出错的通知,而且返回的对象其实是代理对象了。我们让这个演出执行出错,看看会出啥问题。我就加了个简单的除零操作
可以看到正常的后置通知没有执行,而异常后置通知执行了。
环绕通知
其实在这种业务逻辑并不复杂的情况下,我们没必要去写好几个通知,而是直接使用环绕通知。
@Around("performance()")
public void around(ProceedingJoinPoint joinPoint) {
System.out.println("Silence Cell Phones...");
System.out.println("Take Seats...");
try {
joinPoint.proceed();
System.out.println("CLAP CLAP CLAP...");
} catch (Throwable e) {
e.printStackTrace();
System.out.println("Demanding A Refund...");
}
}
使用@Around
声明一个环绕通知,其中依然传入切入点表达式。
这个方法中有一个参数ProceedingJoinPoint
,它就是我们调用目标方法的一个接口,使用joinPoint.proceed
方法可以调用目标方法,并且目标方法抛出的异常会被抛到proceed上。
我们在joinPoint.proceed
之前执行的代码就可以看作前置通知,之后执行的就可以看作后置通知,而异常执行的就可以看作异常后置通知。
其实使用环绕通知时就不必要区分这么多通知类型了,你可以在任意位置通知,并且可以任意多次调用目标方法,甚至不调用。
参数传递
有时,目标方法可能会有参数,这个参数切入点同样需要。
比如之前的CDPlayer例子,我们给它添加上播放CD中任意一个轨道的方法。
public interface MediaPlayer {
void playTrack(int i);
}
public interface CompactDisc {
void playTrack(int i);
}
@Component
public class CDPlayer implements MediaPlayer {
private CompactDisc cd;
@Autowired
public void setCompactDisc(CompactDisc cd) {
this.cd = cd;
}
@Override
public void playTrack(int i) {
cd.playTrack(i);
}
}
@Component
public class BlankCompactDisc implements CompactDisc {
private final String title;
private final String author;
private final List<String> tracks;
public BlankCompactDisc(@Value("${disc.title}") String title,
@Value("${disc.author}") String author,
@Value("${disc.tracks}") String[] tracks) {
this.title = title;
this.author = author;
this.tracks = Arrays.asList(tracks);
}
@Override
public void playTrack(int i) {
System.out.println("Playing " + tracks.get(i) + " from " + title + " by " + author);
}
}
注意,这里,playTrack
方法有参数i,用来播放播放源中第i个轨道。
这时我们需要一个功能,我们为MediaPlayer添加多一个计数器,每当播放一个轨道,就递增对应轨道的计数器。这个功能显然不应该让MediaPlayer来实现,切面是最好不过的。
@Aspect
public class TrackCounter {
private Map<Integer,Integer> cntMap = new HashMap<>();
@After("execution(* cdplayer.MediaPlayer.playTrack(int)) && args(trackNumber)")
public void playTrack(int trackNumber) {
if (!cntMap.containsKey(trackNumber)) cntMap.put(trackNumber,0);
cntMap.put(trackNumber,cntMap.get(trackNumber) + 1);
}
public int getTrackPlayedCount(int trackNumber) {
if (cntMap.containsKey(trackNumber)) return cntMap.get(trackNumber);
return 0;
}
}
上面为了方便,我已经把包名简化了,你要把包名写全。
上面的execution
描述的方法中的参数不再是..
,而是具体的类型,并且后面使用args
指示器来指定参数名。这样我们就能够接收到这个参数了。
编写配置文件,别忘了使用@Value
需要声明一个PlaceHolder和引入资源文件。
@Configuration
@EnableAspectJAutoProxy
@ComponentScan
@PropertySource("classpath:app.properties")
public class CDPlayerConfiguration {
@Bean
public static PropertySourcesPlaceholderConfigurer placeholderConfigurer() {
return new PropertySourcesPlaceholderConfigurer();
}
@Bean
public TrackCounter trackCounter() {
return new TrackCounter();
}
}
资源文件如下:
disc.author=Giveon
disc.title=When It's All Said And Done... Take Time
disc.tracks=The Beach,World We Created,Take Time(Interlude),Favorite Mistake
测试代码如下,我们注入了TrackCounter和MediaPlayer,并且随便播放了几条音轨,然后使用assert来断言它们播放的次数:
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = CDPlayerConfiguration.class)
public class AspectTest02CDPlayer {
@Autowired
private TrackCounter counter;
@Autowired
private MediaPlayer player;
@Test
public void testPlay() {
player.playTrack(1);
player.playTrack(2);
player.playTrack(0);
player.playTrack(0);
player.playTrack(0);
player.playTrack(3);
assertEquals(3,counter.getTrackPlayedCount(0));
assertEquals(1,counter.getTrackPlayedCount(1));
assertEquals(1,counter.getTrackPlayedCount(2));
assertEquals(1,counter.getTrackPlayedCount(3));
}
}
需要注意,这个TrackCounter显然是单例的,并不是每个MediaPlayer对象享有一个,所以任何Player对象中的playTrack被调用都会让它产生效果。
XML声明切面
声明切面的时候XML还是很常用的,因为我们很多时候都在为不是我们编写的类添加切面,我们没有源码,就没法使用注解。
下面是一些XML声明切面的元素,都使用aop命名空间
还是基于上面CDPlayer的例子,我们这次使用XML配置文件,先写一些基础的Bean定义和placeholder以确保能正常运行。
同样为了清晰,我把包名省略一部分。
<?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"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:p="http://www.springframework.org/schema/p"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-4.0.xsd
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop-4.0.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context-4.0.xsd">
<!-- 不使用注解,如果想自动解析注解,添加这行 -->
<!-- <aop:aspectj-autoproxy/> -->
<context:property-placeholder location="app.properties"/>
<bean id="compactdisc" class="cdplayer.BlankCompactDisc"/>
<bean id="cdplayer" class="cdplayer.CDPlayxxr" p:compactDisc-ref="compactdisc"/>
<bean id="trackcounter" class="cdplayer.TrackCounter"/>
</beans>
接下来使用aop明明空间声明和注解中类似的内容。
<aop:config>
<aop:aspect ref="trackcounter">
<aop:pointcut id="playTrack" expression="execution(* cdplayer.MediaPlayer.playTrack(int)) and args(trackNumber)"/>
<aop:after method="playTrack" pointcut-ref="playTrack"/>
</aop:aspect>
</aop:config>
很好理解,aop:config
中用来配置AOP相关的内容,aop:aspect
针对一个对象声明一个切面,这里就是针对我们的计数器。然后我们也可以用aop:pointcut
来声明一个切入点,就相当于使用@PointCut
注解了,当然也可以直接在通知中使用pointcut
属性来声明。使用aop:连接点
可以声明一个通知,这里是aop:after
,通知需要使用method
属性绑定到切面的一个方法上,pointcut-ref
指定一个切入点引用,也可以直接使用pointcut
编写表达式。