Spring Boot 如何使用 Spring AOP
面向切面编程
面向切面编程(Aspect Oriented Programming),可以将与业务无关但是被各个业务模块共同调用的逻辑抽取出来,以切面的方式切入到代码中,从而降低系统中代码的耦合度,减少重复的代码。
Spring AOP 是通过预编译方式和运行期间动态代理实现程序面向切面编程。
试想我们的项目中有一个接口,它的代码逻辑是这样的:
public R api() {
查询数据库;
返回数据;
}
现在我们需要对该接口进行登录验证,只有登录了的用户才能访问该接口,如果用户没有登录,那么返回一个错误结果。此时,最简单的方式就是使用 if-else 进行判断,添加到代码逻辑中。但如果这种接口数量一多,那我们的工作量就势必加大了。
如果后续开发中,我们还需要给接口添加权限验证,只有具有某种权限的用户才能访问接口,那我们又需要添加大量重复代码。
这种应用场景,例如登录校验、权限校验、日志处理等这种多个模块可能会共同调用的代码,我们完全可以使用切面的方式,将逻辑切入到业务模块中。
AOP 的底层实现原理
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 执行主体功能之后,增强功能.......
AOP 的相关术语
- 连接点:被代理(被增强)的类中的方法
- 切入点:实际上需要被增强的方法
- 通知:要增强的逻辑代码
- 前置通知:在主体功能执行之前执行
- 后置通知:在主体功能执行之后执行
- 环绕通知:在主体功能执行前后执行
- 异常通知:在主体功能执行出现异常时执行
- 最终通知:主体功能无论执行是否成功都会执行
- 切面:切入点和切面的结合,即被增强的方法和增强的功能组成切面
Spring Boot 使用 Spring AOP
接下来看看在 Spring Boot 中如何使用 Spring AOP。
首先引入一个 spring-boot-starter-aop
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>
在实际开发中,我们可以使用切入点表达式声明切入点,如:
- 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 实现登录鉴权。
使用注解和 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 声明优先级,值越小优先级越高,越先执行。
#java#