Spring学习笔记 AOP

时间:2021-10-06 10:01:18

AOP,也就是面向方面编程或者说面向面编程,是一种很重要的思想。在企业级系统中经常需要打印日志、事务管理这样针对某一方面的需求,但是传统的面向对象编程无法很好的满足这些需求。因此催生了面向切面编程这样的思想。面向切面编程,通过动态代理这样的功能,向要执行的方法添加钩子,能够在不改动原方法的情况下,动态添加新功能。所以在现代系统中算是一项必需的功能了。Spring框架也很好的支持了AOP。

AOP的几个术语如下,详细的使用方法会在具体使用的时候说明。
- 切面(Aspect),官方的抽象定义为“一个关注点的模块化,这个关注点可能会横切多个对象”,上面所说的打印日志、事务管理这样的需求,就是切面。
- 连接点(JoinPoint),程序执行过程中的某一行为。比如说我们计划在某个方法执行的时候打印日志,那么这个方法就是连接点。
- 通知(Advice),切面对于某个连接点产生的动作就是通知。比如说我们上面计划在某个方法执行的时候打印日志,那么打印日志这件事情就是通知。通知按照执行时机可以分为前置通知、后置通知等五种通知。
- 切入点(Pointcut),可以简单地理解为正则表达式之类的东西。我们想要在哪些方法上应用打印日志的通知,就需要一个切入点来匹配。
- 目标对象(Target Object),被切面通知的对象就是目标对象。

环境配置

Spring核心的依赖注入功能不需要AOP等其他组件的支持即可使用。不过反过来AOP却需要依赖注入的支持。因此我们需要添加比较多的依赖。以下是Gradle的依赖配置,为了运行后面的Hibernate例子,需要Hibernate等几个额外的包。

dependencies {
testCompile group: 'junit', name: 'junit', version: '4.12'
compile group: 'org.springframework', name: 'spring-core', version: springVersion
compile group: 'org.springframework', name: 'spring-context', version: springVersion
compile group: 'org.springframework', name: 'spring-aop', version: springVersion
compile group: 'org.springframework', name: 'spring-test', version: springVersion
compile group: 'org.springframework', name: 'spring-orm', version: springVersion
compile group: 'org.projectlombok', name: 'lombok', version: '1.16.12'
compile group: 'org.hibernate', name: 'hibernate-core', version: '5.2.6.Final'
compile group: 'mysql', name: 'mysql-connector-java', version: '5.1.40'
compile group: 'org.apache.commons', name: 'commons-dbcp2', version: '2.1.1'

}

定义服务

要使用AOP,我们首先需要确定要把AOP用在什么地方。这里我定义了一个小服务,执行几个方法。这几个方法列举了最常用的几种使用情景,无参方法、有参方法、有返回值方法。

public class MyService {
public void doSomething() {
System.out.println("做一些事情...");
}

public void printSomething(String msg) {
System.out.println("信息室:" + msg);
}

public int calculateSomething(int a, int b) {
return a + b;
}

public void throwSomething() {
throw new RuntimeException("一个异常");
}

public void longWork() {
int N = 10000;
int sum = 0;
for (int i = 0; i < N; ++i) {
sum += i;
}
}
}

然后将这个服务注册为Spring Bean。

<bean id="myService" class="yitian.learn.aop.MyService"/>

XML方式配置AOP

定义切面

我们在这里定义一个切面,这个切面包含几个方法,将会在我们的服务执行前、执行后输出信息,追踪服务的参数、返回值和异常信息等。在编程实践中,一个切面一般是一个类,其中包含若干方法将会代理到服务方法上。

public class MyAspect {
public void before() {
System.out.println("在方法之前");
}

public void after() {
System.out.println("在方法之后");
}

public void printDataFlow(int input1, int input2, int output) {
System.out.println(
String.format("程序输入是:%d,%d,输出是:%d", input1, input2, output));
}

public void afterThrow(Exception e) {
System.out.println("方法抛出了" + e);
}
}

定义好日志切面之后,我们同样需要将其配置为一个Bean。

<bean id="myAspect" class="yitian.learn.aop.MyAspect"/>

要将某个Bean配置为切面还需要一步,也就是在XML配置文件中beans根节点添加如下一行,引用AOP的相关规则。

xmlns:aop="http://www.springframework.org/schema/aop"

然后在配置文件中添加如下一节。将Bean声明为切面。所有的AOP相关配置,都只能编写在<aop:config>节点中,而且顺序必须按照切入点、通知和切面的顺序声明。

<aop:config>
<aop:aspect id="logAspect" ref="logAspect">

</aop:aspect>
</aop:config>

定义切入点

切入点可以理解为正则表达式,简单地说,切入点和目标方法之间的关系就像正则表达式和要匹配的字符串的关系一样。切入点定义了一个模式,可以匹配一个或多个目标方法。Spring的切入点表达式使用的是AspectJ的切入点表达式语法,详细信息可以参考Spring AspectJ文档。Spring没有支持所有的AspectJ语法,只支持了一部分。

Spring AOP支持以下几种指示符:

  • execute,匹配指定方法执行的连接点,这是我们最常用的一种。
  • within,匹配指定类型内的连接点。
  • this,匹配bean引用(AOP代理)是指定类型的连接点。
  • target,匹配目标对象(被代理的对象)是指定类型的连接点。
  • args,匹配方法参数是指定类型的连接点。
  • @target,匹配目标对象的类被指定注解标记的连接点。
  • @args,匹配方法参数标记有指定注解的连接点。
  • @within,匹配被指定注解标记的类型的连接点。
  • @annotation,匹配执行方法含有指定注解的连接点。
  • bean,Spring AOP特有的,匹配指定id或名称的Spring Bean的连接点。

在指示符后面,需要一组括号,括号内容是方法的匹配,语法如下:

指示符(返回类型 包名.类名.方法名(参数列表) )

下面这个切入点表示的是yitian.learn.aop.MyService类下的返回任意值的任意名称和任意个参数的方法执行时。这样这个切入点代表的就是MyService类的所有方法。id属性指定切入点标识符,expression指定切入点表达式。切入点既可以定义在切面内部,也可以定义在切面外。如果定义在切面外,就可以被多个切面所共享。但是必须定义在所有切面之前,顺序上面已经说了。

这里使用到了两个通配符。星号*代表单个的任意类型和名称,两个点..表示任意多个名称或参数。此外还有一个通配符+,用在某个类型之后,表示该类型的子类或者实现了该接口的某个类。

<aop:pointcut id="myService"
expression="execution(* yitian.learn.aop.MyService.*(..))"/>

再来几个例子。匹配任意公有方法。

execution(public * *(..))

匹配com.xyz.someapp.trading及其子包下所有方法执行。

within(com.xyz.someapp.trading..*)

匹配以set开头的所有方法执行。

execution(* set*(..))

匹配com.xyz.service包下的任意类的任意方法。

execution(* com.xyz.service.*.*(..))

匹配任何实现了com.xyz.service.AccountService接口目标对象的切入点。

target(com.xyz.service.AccountService)

切入点还可以叠加,使用&&||!表示切入点的与或非。由于在XML配置文件中存在字符转义现象,所以在XML配置中还可以使用andornot来替代上面的关系运算符。

定义通知

切面对于某个连接点所执行的动作就是通知。通知有以下几种:

  • 前置通知(before),在目标方法执行前执行、
  • 返回后通知(after-returning),在目标方法正常返回之后执行。
  • 异常后通知(after-throwing),在目标方法抛出异常之后执行。
  • 后置通知(after),在目标方法结束(包括正常返回和抛出异常)之后执行。
  • 环绕通知(around),将目标方法包裹到切面方法中执行。

通知将切面和目标方法之间联系起来。pointcut-ref属性指定命名切入点的引用,如果不想使用命名切入点也可以使用pointcut指定切入点表达式;method指定切面中当连接点执行时所执行的方法。通知需要定义在切面之中。下面定义了前置通知和后置通知。其他通知的定义类似,写在上面通知的括号中了。

<aop:aspect id="aspect" ref="myAspect">
<aop:before method="before" pointcut-ref="something"/>
<aop:after method="after" pointcut-ref="something"/>
</aop:aspect>

这样定义之后,每当连接点执行的时候,通知随之执行。如果AOP的功能仅仅是这样的话显然没什么作用。在通知中,我们还可以获取目标方法的参数和返回值。下面定义了一个通知,切入点是当calculateSomething方法执行的时候;返回值使用returning属性指明;参数在切入点表达式中使用args指明;最后指定了这几个参数在切面方法中的顺序。这样,连接的参数和返回值就可以正确的绑定到切面方法上了。

<aop:after-returning method="printDataFlow"
pointcut="execution(int yitian.learn.aop.MyService.calculateSomething(int,int)) and args(input1,input2)"
returning="output"
arg-names="input1,input2,output"/>

如果要获取方法抛出的异常,需要throwing属性,这样切面方法就可以顺利获取到异常对象了。

<aop:after-throwing method="afterThrow"
pointcut="execution(* yitian..MyService.throwSomething())"
throwing="e"/>

最后来说说环绕通知。相比而言环绕通知应该是最复杂的通知了。连接点会被包裹在环绕通知方法内执行。如何来处理连接点的执行和返回值呢?这需要环绕通知的方法具有一些特征:

  • 必须有一个org.aspectj.lang.ProceedingJoinPoint类型的参数作为方法的第一个参数,否则无法执行方法。
  • 环绕通知方法最好有返回值,如果没有返回值,连接点方法的返回值将会丢失。

下面我们在MyAspect类中新建一个方法,用于测试连接点方法执行时间,因为只是测试执行时间,因此这里没有为方法添加返回值。

public void around(ProceedingJoinPoint pjp) {
StopWatch watch = new StopWatch();
watch.start();
try {
pjp.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
watch.stop();
System.out.println(watch.shortSummary());
}

然后,我们定义一个环绕通知。

<aop:around method="around"
pointcut="execution(void yitian..MyService.longWork())"/>

这样的话,在执行longWork方法的时候就会自动包裹在around方法中执行。环绕通知主要用于事务处理等必须包裹的情形当中。使用前面几种通知可以实现功能的话就不要使用环绕通知。

定义引入

引入(Introduction)是AOP的一项功能,可以在不改变源代码的情况下,动态的让某个对象实现某个接口。

首先我们需要一个接口和一个默认实现。

public interface Service {
void doService();
}
public class ServiceImpl implements Service {
@Override
public void doService() {
System.out.println("实现了Service接口");
}
}

然后在<aop:aspect>中添加如下一节。<aop:declare-parents>来指定一个引入。types-matching属性指定要匹配的类;implement-interface属性指定要实现的接口;default-impl属性指定该接口的默认实现。

<aop:declare-parents types-matching="yitian.learn.aop.MyService"
implement-interface="yitian.learn.aop.Service"
default-impl="yitian.learn.aop.ServiceImpl"/>

然后我们就可以将MyService转换成Service接口了。

Service s = context.getBean("myService", Service.class);
s.doService();

@AspectJ配置

前面用的是XML方式配置的AOP,由于Spring AOP的很多概念和类直接来自于AspectJ开源项目。当然也支持AspectJ形式的注解配置。要启用AspectJ注解形式的配置,需要在Java配置类上添加@EnableAspectJAutoProxy注解。

@Configuration
@EnableAspectJAutoProxy
public class AppConfig {

}

如果使用XML配置Spring而使用注解配置Spring AOP,需要在配置文件中添加下面一行。

<aop:aspectj-autoproxy/>

定义切面

定义切面很简单,在切面类上应用@Aspect即可。

@Aspect
public class MyAspect {
...
}

定义切入点

定义切入点需要在切面类中定义一个空方法,方法名会作为切入点的名称,切入点表达式使用注解声明。这里这个方法的作用就是充当一个占位符,所以方法体为空,这个方法返回类型必须是void。

@Pointcut(value = "execution(* yitian..MyService.doSomething())")
private void something() {
}

定义通知

定义通知和配置XML文件类似。这里不说了。直接上代码。

@Aspect
public class MyAspect {
//定义切入点
@Pointcut("execution(* yitian..MyService.doSomething())")
private void something() {
}

//定义通知
@Before("something()")
public void before() {
System.out.println("在方法之前");
}

@After("something()")
public void after() {
System.out.println("在方法之后");
}

@AfterReturning(pointcut = "execution(* yitian..MyService.calculateSomething(..)) && args(input1,input2)",
returning = "output", argNames = "input1,input2,output")
public void printDataFlow(int input1, int input2, int output) {
System.out.println(
String.format("程序输入是:%d,%d,输出是:%d", input1, input2, output));
}

@AfterThrowing(pointcut = "execution(* yitian..MyService.throwSomething())",
throwing = "e")
public void afterThrow(Exception e) {
System.out.println("方法抛出了" + e);
}

@Around("execution(* yitian..MyService.longWork())")
public void around(ProceedingJoinPoint pjp) {
System.out.println("开始计时");
StopWatch watch = new StopWatch();
watch.start();
try {
pjp.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
watch.stop();
System.out.println(watch.shortSummary());
}

}

可以看到使用注解配置的优势就是配置文件和切面类在一起,阅读方便。如果使用XML配置的话,要查看切面应用了什么方法,需要同时查看XML和Java代码,比较麻烦。

此外通知还有一个顺序的问题,在前面没有说明。如果有两个切面的相同通知(比如都是前置通知)要应用到某个连接点上,我们就可以定义它们之间的顺序。有两种方法,第一种是让通知所在的切面类实现org.springframework.core.Ordered接口,这个接口有一个getValue()方法,我们可以实现这个方法来确定顺序。第二种方法就是在切面类上应用Order注解,并给定一个值。不论用哪种方法,值较小的通知会先执行。同一切面中的通知,执行顺序是未定义的,也就是不确定的,我们无法指定它们的执行顺序。

定义引入

在切面类中定义一个接口类型的字段,然后应用DeclareParents注解并定义要引入的类和该接口的默认实现。

//定义引入
@DeclareParents(value = "yitian..MyService", defaultImpl = ServiceImpl.class)
private Service service;

理解Spring AOP

Spring AOP是一个基于代理实现的框架,因此有一些事情需要我们注意。举个例子,我们定义如下一个类。

public class SimplePojo {

public void foo() {
System.out.println("调用了foo");
bar();
}

public void bar() {
System.out.println("调用了bar");
}
}

然后定义一个切面和两个通知,在目标方法之后执行。

@Aspect
public class PojoAspect {


@AfterReturning(pointcut = "execution(* yitian..SimplePojo.foo())")
public void afterFoo() {
System.out.println("代理了foo");
}

@AfterReturning(pointcut = "execution(* yitian..SimplePojo.bar())")
public void afterBar() {
System.out.println("代理了bar");
}
}

然后我们运行一下foo方法,看看会出现什么情况。

@Test
public void testProxy() {
ApplicationContext context = new AnnotationConfigApplicationContext(AOPConfig.class);
SimplePojo pojo = context.getBean("simplePojo", SimplePojo.class);
pojo.foo();
}

结果如下:

调用了foo
调用了bar
代理了foo

我们注意到一个事实,在foo方法中调用bar方法并没有相应的通知执行。由于Spring AOP是一个基于代理的框架,因此我们从ApplicationContext中获取到的Bean其实是一个代理,因此foo方法会执行相应的通知。但是,foo方法调用自己类中的bar方法,使用的是this引用,没有经过代理,因此无法触发AOP的通知执行。这一点需要注意。如果我们希望编写一个目标类型,让其能够使用Spring AOP,那么尽量不要出现调用自己类中的方法的情况。由于AspectJ不是基于代理的框架,因此如果你使用AspectJ,就不会出现上面的问题。

小例子

我们来使用环绕通知配置一下Hibernate的事务管理。

首先需要定义一个实体类。

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@NaturalId
private String username;
@Column(nullable = false)
private String password;
@Column
private String nickname;
@Column
private LocalDate birthday;
}

然后添加一个用户服务,向数据库中添加用户。这里为了使用环绕通知来进行事务管理,故意将Session写在参数中,方便环绕通知获取Session。

public class UserService {

public void add(Session session, User user) {
session.save(user);

}
}

然后我们需要一个切面和一个环绕通知,环绕通知将连接点的代码用事务处理语句环绕。

@Aspect
public class TransactionAspect {
@Pointcut("execution(* yitian..UserService.add(..))&&args(session,user)")
private void addUser(Session session, User user) {
}

@Around(value = "addUser(session,user)", argNames = "pjp,session,user")
public void manageTransaction(ProceedingJoinPoint pjp, Session session, User user) {
Transaction transaction = session.beginTransaction();
try {
pjp.proceed(new Object[]{session, user});
transaction.commit();
} catch (Throwable e) {
transaction.rollback();
}
}
}

当然上面这几个类应该注册为Spring Bean。

@Configuration
@EnableAspectJAutoProxy
public class HibernateConfig {
@Autowired
private SessionFactory sessionFactory;

@Bean
public SessionFactory sessionFactory() {
final StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
.configure()
.build();
try {
SessionFactory sessionFactory = new MetadataSources(registry).buildMetadata().buildSessionFactory();
return sessionFactory;
} catch (Exception e) {
StandardServiceRegistryBuilder.destroy(registry);
throw new RuntimeException(e);
}
}

@Bean
public Session session() {
return sessionFactory.openSession();
}

@Bean
public UserService userService() {
return new UserService();
}
}

最后来测试一下,我们运行测试方法,然后查看一下数据库,看是否成功插入了。

@ContextConfiguration(classes = {HibernateConfig.class})
@RunWith(SpringRunner.class)
public class HibernateTest {

@Autowired
private UserService userService;

@Autowired
private Session session;


@Test
public void testTransactionAspect() {
User user = new User();
user.setUsername("yitian");
user.setPassword("123456");
user.setNickname("易天");
user.setBirthday(LocalDate.now());
userService.add(session, user);
}
}

参考资料

https://my.oschina.net/sniperLi/blog/491854
http://blog.csdn.net/wangpeng047/article/details/8556800

项目代码

项目在csdn代码库中,见下。
https://code.csdn.net/u011054333/spring-core-sample/tree/master