Spring Boot AOP - 面向切面编程

7,588 阅读7分钟

AOP,即面向切面编程,其核心思想就是把业务分为核心业务非核心业务两大部分。例如一个论坛系统,用户登录、发帖等等这是核心功能,而日志统计等等这些就是非核心功能。

在Spring Boot AOP中,非核心业务功能被定义为切面,核心和非核心功能都开发完成之后,再将两者编织在一起,这就是AOP。

AOP的目的就是将那些与业务无关,却需要被业务所用的逻辑单独封装,以减少重复代码,减低模块之间耦合度,利于未来系统拓展和维护。

今天,我将做一个简单的打印用户信息的程序,即后端接受POST请求中的User对象将其打印这样一个逻辑,在这个上面实现AOP。

首先放上用户打印服务逻辑的方法代码:

Service层:

package com.example.springbootaop.service;

import com.example.springbootaop.model.User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

@Service
public class UserService {

	/**
	 * 使用Logger
	 */
	private final Logger logger = LoggerFactory.getLogger(this.getClass());

	public void printUserInfo(User user) {
		logger.info("用户id:" + user.getId());
		logger.info("用户名:" + user.getUsername());
		logger.info("用户昵称:" + user.getNickname());
	}

}

Control层:

package com.example.springbootaop.api;

import com.example.springbootaop.model.User;
import com.example.springbootaop.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserAPI {

	@Autowired
	private UserService userService;

	@PostMapping("/user")
	public String printUser(@RequestBody User user) {
		userService.printUserInfo(user);
		return "已完成打印!";
	}

}

User是一个简单的封装类,这里就不展示了,文章末尾会给出整个示例程序代码地址。

1,AOP到底有什么不同

这里只是实现一个简单的逻辑,打印用户信息,这就是我们今天的核心功能。

如果这个时候我们要给它加上非核心功能:在打印之前和打印之后分别执行一个方法,如果你不知道AOP,你可能会把Control层的方法改成如下形式:

@PostMapping("/user")
public String printUser(@RequestBody User user) {
	// 执行核心业务之前
	doBefore();
	// 执行核心业务
	userService.printUserInfo(user);
	// 执行核心业务之后
	doAfter();
	...
	return "已完成打印!";
}

如果说方法多了,业务多了,非核心业务的逻辑一变,所有Controller的全部方法都要改动,非常麻烦,且代码冗余,耦合度高。

这时,就需要AOP来解决这个问题。

AOP只需要我们单独定义一个切面,在里面写好非核心业务的逻辑,即可将其织入核心功能中去,无需我们再改动Service层或者Control层。

2,AOP中的编程术语和常用注解

在学习AOP之前,我们还是需要了解一下常用术语:

  • 切面:非核心业务功能就被定义为切面。比如一个系统的日志功能,它贯穿整个核心业务的逻辑,因此叫做切面
  • 切入点:在哪些类的哪些方法上面切入
  • 通知:在方法执行前/后或者执行前后做什么
    • 前置通知:在被代理方法之前执行
    • 后置通知:在被代理方法之后执行
    • 返回通知:被代理方法正常返回之后执行
    • 异常通知:被代理方法抛出异常时执行
    • 环绕通知:是AOP中强大、灵活的通知,集成前置和后置通知
  • 切面:在什么时机、什么地方做什么(切入点+通知)
  • 织入:把切面加入对象,是生成代理对象并将切面放入到流程中的过程(简而言之,就是把切面逻辑加入到核心业务逻辑的过程)

在Spring Boot中,我们使用@AspectJ注解开发AOP,首先需要在pom.xml中引入如下依赖:

<!-- Spring Boot AOP -->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

然后就可以进行AOP开发了!

这里先给出常用注解,大家联系着下面的例子看就可以了:

  • @Pointcut 定义切点
  • @Before 前置通知
  • @After 后置通知
  • @AfterReturning 返回通知
  • @AfterThrowing 异常通知
  • @Around 环绕通知

3,定义切面

新建aop包,在里面新建类作为我们的切面类,先放出切面类代码:

package com.example.springbootaop.aop;

import org.aspectj.lang.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class LogAspect {

	/**
	 * 日志打印
	 */
	private Logger logger = LoggerFactory.getLogger(this.getClass());

	/**
	 * 使用Pointcut给这个方法定义切点,即UserService中全部方法均为切点。<br>
	 * 这里在这个log方法上面定义切点,然后就只需在下面的Before、After等等注解中填写这个切点方法"log()"即可设置好各个通知的切入位置。
	 * 其中:
	 * <ul>
	 *	  <li>execution:代表方法被执行时触发</li>
	 *	  <li>*:代表任意返回值的方法</li>
	 *	  <li>com.example.springbootaop.service.impl.UserServiceImpl:这个类的全限定名</li>
	 *	  <li>(..):表示任意的参数</li>
	 * </ul>
	 */
	@Pointcut("execution(* com.example.springbootaop.service.impl.UserServiceImpl.*(..))")
	public void log() {

	}

	/**
	 * 前置通知:在被代理方法之前调用
	 */
	@Before("log()")
	public void doBefore() {
		logger.warn("调用方法之前:");
		logger.warn("接收到请求!");
	}

	/**
	 * 后置通知:在被代理方法之后调用
	 */
	@After("log()")
	public void doAfter() {
		logger.warn("调用方法之后:");
		logger.warn("打印请求内容完成!");
	}

	/**
	 * 返回通知:被代理方法正常返回之后调用
	 */
	@AfterReturning("log()")
	public void doReturning() {
		logger.warn("方法正常返回之后:");
		logger.warn("完成返回内容!");
	}

	/**
	 * 异常通知:被代理方法抛出异常时调用
	 */
	@AfterThrowing("log()")
	public void doThrowing() {
		logger.error("方法抛出异常!");
	}

}

切面类需要打上@Aspect注解表示这是一个切面类,然后不要忘了打上@Component注解。

我们逐步来看。

首先是定义切点,只需定义一个空方法,在上面使用@Pointcut注解即可,注解里面内容含义如下:

  • execution 代表方法被执行时触发
  • * 代表任意返回值的方法
  • com.example.springbootaop.service.impl.UserServiceImpl 被织入类的全限定名
  • (..) 表示任意的参数

定义完切点之后,就可以定义各个通知的方法逻辑了,这些就是我们的切面逻辑,也就是非核心业务的逻辑。

上面在doBefore方法上面,我们使用了@Before注解,这样就标明了doBefore方法是前置通知逻辑,会在被织入方法之前执行。我们把log方法定义为切入点,然后下面各个通知注解中,填写这个切入点方法名称即可。

我们也并不需要定义所有的通知,只需定义需要的即可。

其实,如果不定义上面的切入点方法log@Pointcut,你仍然可以把execution表达式直接写在各个通知的注解里面,例如:

/**
 * 前置通知:在被代理方法之前调用
 */
@Before("execution(* com.example.springbootaop.service.impl.UserServiceImpl.*(..))")
public void doBefore(JoinPoint joinPoint) {
	logger.warn("调用方法之前:"); logger.warn("接收到请求!");
}

但是大多数情况并不推荐这样,这种写法较为复杂。

我们发送一个请求测试一下:

image.png

通过这个,我们也可以发现各个通知的执行顺序:

Before -> AfterReturning -> After

4,环绕通知

环绕通知是AOP中最强大的通知,可以同时实现前置和后置通知,不过它的可控性没那么强,如果不用大量改变业务逻辑,一般不需要用到它。我们在上述切面加入下列环绕通知方法:

/**
 * 环绕通知
 */
@Around("log()")
public void around(ProceedingJoinPoint joinPoint) {
	logger.warn("执行环绕通知之前:");
	try {
		joinPoint.proceed();
	} catch (Throwable e) {
		e.printStackTrace();
	}
	logger.warn("执行环绕通知之后");
}

通知方法中有一个ProceedingJoinPoint类型参数,通过其proceed方法来调用原方法。需要注意的是环绕通知是会覆盖原方法逻辑的,如果上面代码不执行joinPoint.proceed();这一句,就不会执行原被织入方法。因此环绕通知一定要调用参数的proceed方法,这是通过反射实现对被织入方法调用。

再次测试如下:

image.png

5,通知方法传参

上面每个通知方法是没有参数的。其实,通知方法是可以接受被织入方法的参数的。我们上述被织入方法参数就是一个User对象,因此通知方法也可以加上这个参数接受。我们改变前置通知方法如下:

/**
 * 前置通知:在被代理方法之前调用
 */
@Before("log() && args(user)")
public void doBefore(User user) {
	logger.warn("调用方法之前:");
	logger.warn("接收到请求!");
	logger.warn("得到用户id:" + user.getId());
}

测试结果:

image.png

可见在注解后面加一个args选项,里面写参数名即可。

需要注意的是,通知方法的参数必须和被织入方法参数一一对应例如:

/**
 * 被织入方法
 */
public void print(User user, int num) {
	...
}

/**
 * 通知
 */
@Before("log() && args(user, num)")
public void doBefore(User user, int num) {
	...
}

6,总结

AOP其实使用起来是个很方便的东西,大大降低了相关功能之间的耦合度,使得整个系统井井有条。

定义切面,然后定义切点,再实现切面逻辑(各个通知方法),就完成了一个简单的切面。

示例程序仓库地址