面向切面编程(Aspect Oriented Programming),可以将与业务无关但是被各个业务模块共同调用的逻辑抽取出来,以切面的方式切入到代码中,从而降低系统中代码的耦合度,减少重复的代码。
Spring AOP 是通过预编译方式和运行期间动态代理实现程序面向切面编程。
试想我们的项目中有一个接口,它的代码逻辑是这样的:
现在我们需要对该接口进行登录验证,只有登录了的用户才能访问该接口,如果用户没有登录,那么返回一个错误结果。此时,最简单的方式就是使用 if-else 进行判断,添加到代码逻辑中。但如果这种接口数量一多,那我们的工作量就势必加大了。
如果后续开发中,我们还需要给接口添加权限验证,只有具有某种权限的用户才能访问接口,那我们又需要添加大量重复代码。
这种应用场景,例如登录校验、权限校验、日志处理等这种多个模块可能会共同调用的代码,我们完全可以使用切面的方式,将逻辑切入到业务模块中。
AOP 底层使用动态代理完成需求,为需要增加增强功能的类生成代理类,有两种生成代理类的方式,对于被代理类(即需要增强的类),如果:
实现了接口,使用 JDK 动态代理,生成的代理类会使用其接口
没有实现接口,使用 CGlib 动态代理,生成的代理类会继承被代理类
简单看看 JDK 动态代理的实现方式,可以看到使用了设计模式-代理模式:
// 我们定义一个接口,声明一个登录功能的方法
public interface UserService {
void login(String username, String password);
}
// 有一个实现类,实现登录功能
public class UserServiceImpl implements UserService{
@Override
public void login(String username, String password) {
System.out.println("登录功能, username="+ username + ",password=" + password);
}
}
// 创建一个代理类,完成代理,增强被代理类的功能
public class UserServiceProxy implements InvocationHandler {
// 被代理类的实例,传递进来的就是 UserServiceImpl 的实例
private Object obj;
public UserServiceProxy(Object obj) {
this.obj = obj;
}
// 定义如何增强功能
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().equals("login")) {
System.out.println("执行主体功能之前,增强功能.......");
System.out.println("执行方法:" + method.getName() + ",方法参数: {" + Arrays.toString(args) + "}");
// 增强功能:给用户名添加后缀,实际情况中,可能我们可以判断以下请求的 IP 地址是否在运行范围内
args[0] += "123123";
// 如果我们直接 return method.invoke, 不编写其他代码,那么就等于没有增强功能
// 调用 method.invoke 就是方法执行后的返回结果,如果不调用 method.invoke,就不会执行主体功能
Object res = method.invoke(obj, args);
System.out.println("执行主体功能之后,增强功能.......");
return res;
}
return method.invoke(obj, args);
}
}
// 测试:
public class Main {
public static void main(String[] args) {
Class[] interfaces = {UserService.class};
UserServiceImpl userServiceImpl = new UserServiceImpl();
UserService userService = (UserService) Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(), interfaces,
new UserServiceProxy(userServiceImpl));
userService.login("username", "123123");
}
}
//======================================= 运行结果
执行主体功能之前,增强功能.......
执行方法:login,方法参数: {[username, 123123]}
登录功能, username=username123123,password=123123
执行主体功能之后,增强功能.......
连接点:被代理(被增强)的类中的方法
切入点:实际上需要被增强的方法
通知:要增强的逻辑代码
前置通知:在主体功能执行之前执行
后置通知:在主体功能执行之后执行
环绕通知:在主体功能执行前后执行
异常通知:在主体功能执行出现异常时执行
最终通知:主体功能无论执行是否成功都会执行
切面:切入点和切面的结合,即被增强的方法和增强的功能组成切面
接下来看看在 Spring Boot 中如何使用 Spring AOP。
首先引入一个 spring-boot-starter-aop
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-aop
在实际开发中,我们可以使用切入点表达式声明切入点,如:
execution([权限修饰符] [返回类型] [类全路径].[方法名称] ([参数类型列表])
execution(* com.xxx.ABC.add()), 对 ABC 类的方法进行增强
@annotaion(注解)标注了指定注解的方法
@bean(beanName)指定的 beanName 的 bean 的方法会被增强
切入点表达式可以用上 || 、&& 逻辑运算符。
我们会使用到如下几个注解:
我们编写两个类:
代码如下:
//=================================== 切面代码 ===================================
@Component // 这是一个组件,会交由 IOC 容器管理
@Aspect // 这个类是一个切面
public class TestAOP {
// 切入点表达式,TestController 下的 test 方法为切入点
public static final String EXECUTION = "execution(public void com.example.aopdemo.controller.TestController.test())";
// 也可以这样使用 @Before("execution(public void com.example.aopdemo.controller.TestController.test())")
@Before(EXECUTION)
public void before() {
System.out.println("前置通知");
}
// 切入点的另一种编写方式,具体使用查看第【20】行
@Pointcut(value="execution(public void com.example.aopdemo.controller.TestController.test())")
public void pointCut() {
}
@AfterReturning("pointCut()")
public void afterReturning() {
System.out.println("后置通知");
}
// ProceedingJoinPoint 实例含有切入点的信息,可以获取方法签名,参数列表等
// 环绕通知使用这个对象实例执行切入点的功能
@Around(EXECUTION)
public void around(ProceedingJoinPoint joinPoint) throws Throwable {
System.out.println("环绕通知之前");
// 执行切入点
joinPoint.proceed();
System.out.println("环绕通知之后");
}
// 类似于 finally,保证一定会执行
@After(EXECUTION)
public void after() {
System.out.println("最终通知");
}
@AfterThrowing(EXECUTION)
public void afterThrowing() {
System.out.println("异常通知");
}
}
//=================================== 控制层测试代码 ===================================
@RestController
public class TestController {
@GetMapping("/test")
public void test() {
// 可以查看如果发生异常,“通知”的执行顺序是怎样的
// int i = 1/0;
System.out.println("test 请求");
}
}
发送一个 /test 请求,查看控制台打印结果,可以看到,各个通知的执行顺序:
## 没有异常发生的情况
环绕通知之前
前置通知
test 请求
后置通知
最终通知
环绕通知之后
## 异常发生的情况
环绕通知之前
前置通知
异常通知
最终通知
2022-08-23 14:34:06.587 ERROR 22324 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.ArithmeticException: / by zero] with root cause
java.lang.ArithmeticException: / by zero
可以看到,如果发生了异常,那么切入点发生异常后续的代码、后置通知的代码、环绕通知之后的代码不会运行,并且最终通知一定会运行。
至此,Spring AOP 在 Spring Boot 如何使用已经简单介绍完毕,接下来看看如何使用 Spring AOP 实现登录鉴权。
假设我们有这样一个场景,某个接口需要用户具有管理员权限才能访问,如果没有权限则抛出异常,交给全局统一异常处理。
我们可以使用拦截器完成,也可以使用自定义切面编程实现。
我们需要编写三个类:
代码如下:
//=================================== 自定义注解代码 ===================================
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Target(ElementType.METHOD)
@Component
public @interface PermissionRole {
// role 字段声明接口需要哪种权限角色才能访问,假定我们有两种角色,普通用户,管理员(admin)
String role() default "";
}
//=================================== 切面代码 ===================================
@Aspect
@Component
public class PermissionRoleAspect {
// 声明一个切入点,即标注了 @PermissionRole 注解的方法
@Pointcut("@annotation(com.example.aopdemo.annotation.PermissionRole)")
public void check() {
}
// 声明切入点,这里 check() 主要是让我们获得“方法的信息”,
//@annotation(permissionRole) 主要是让我们获得注解的信息,下面方法参数才能获取到 @PermissionRole 注解的实例信息
@Before("check() && @annotation(permissionRole)")
public void before(JoinPoint joinPoint, PermissionRole permissionRole) throws Exception {
// 可以在这里获取 token,检验用户是否登录,再执行后续代码
// 获取 @PermissionRole 中 role 字段的值
String role = permissionRole.role();
// 这里仅是为了方便测试,获取切入点的方法参数中携带过来信息,直接判断是否具有权限
// 实际过程中,我们应该根据 token 得到用户信息,在根据用户信息查询数据库该用户的权限,进行判断
for (Object arg : joinPoint.getArgs()) {
if (arg instanceof String && arg.equals(role)) {
System.out.println("权限验证通过");
return;
}
}
throw new Exception("当前登录用户没有操作权限");
}
}
//=================================== 控制层测试代码 ===================================
@RestController
public class TestController {
// 使用了自定义注解,当权限角色是 “admin” 时才能访问该接口
@PermissionRole(role = "admin")
@GetMapping("/permission")
public String roleApi(String token) {
System.out.println("token = " + token);
return "请求通过!";
}
}
发送请求进行测试
## 发送请求 http://localhost:8080/permission ,不携带数据,即没有权限的情况下
2022-08-23 15:02:26.058 ERROR 14404 --- [nio-8080-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.reflect.UndeclaredThrowableException] with root cause
java.lang.Exception: 当前登录用户没有操作权限
## 发送请求 http://localhost:8080/permission?token=admin, 携带数据,有权限的情况下
权限验证通过
token = admin
在 Spring Boot 使用 Spring AOP 时,我们需要引入一个 spring-boot-starter-aop ,就可以进行切面编程。
我们需要了解几个常用注解的用法:
@Aspect
@Pointcut
@Before
@Around
@AfterReturning
@AfterThrowing
@After
在声明切入点的时候,我们可以使用切入点表达式声明切入点。
此外,如果有多个切面,可以在切面类上使用注解 @Order 声明优先级,值越小优先级越高,越先执行。